From a686f3a23a67873fd5795eb20b752ff639d9cbc4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:59:56 -0700 Subject: [PATCH] feat(byo): record the first daemon registration per person (TASK-661) The first successful POST /api/machines for a user writes one byo_telemetry row, daemon-registered, keyed by the user so a second machine writes nothing, and sets the write-once onboarding mark byoDaemonRegisteredAt. A failed register writes neither. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: kai Agent-Model: claude/claude-haiku-5-5/default --- .../__tests__/unit/routes/machines.test.js | 57 +++++++++++++++++++ .../unit/services/onboardingMarks.test.js | 34 ++++++++++- backend/models/User.ts | 3 + backend/routes/machines.ts | 9 +++ backend/services/byoTelemetry.ts | 8 ++- backend/services/onboardingMarks.ts | 26 +++++++++ 6 files changed, 135 insertions(+), 2 deletions(-) diff --git a/backend/__tests__/unit/routes/machines.test.js b/backend/__tests__/unit/routes/machines.test.js index 6cae3ee362..267e5470af 100644 --- a/backend/__tests__/unit/routes/machines.test.js +++ b/backend/__tests__/unit/routes/machines.test.js @@ -7,6 +7,8 @@ const mockGetMachineForDaemon = jest.fn(); const mockRecordMachineHeartbeat = jest.fn(); const mockRemoveMachine = jest.fn(); const mockFindMachine = jest.fn(); +const mockMarkByoDaemonRegistered = jest.fn(); +const mockRecordByoEvent = jest.fn(); jest.mock('../../../middleware/auth', () => (req, _res, next) => { req.user = { id: '0123456789abcdef01234567' }; @@ -29,6 +31,13 @@ jest.mock('../../../models/Machine', () => ({ jest.mock('../../../models/User', () => ({ findById: jest.fn(() => ({ select: () => ({ lean: jest.fn().mockResolvedValue({ role: 'user' }) }) })), })); +jest.mock('../../../services/onboardingMarks', () => ({ + markByoDaemonRegistered: (...args) => mockMarkByoDaemonRegistered(...args), +})); +jest.mock('../../../services/byoTelemetry', () => ({ + ...jest.requireActual('../../../services/byoTelemetry'), + recordByoEvent: (...args) => mockRecordByoEvent(...args), +})); jest.mock('../../../services/machineService', () => ({ registerMachine: (...args) => mockRegisterMachine(...args), listMachinesForOwner: (...args) => mockListMachinesForOwner(...args), @@ -65,6 +74,54 @@ describe('machine lifecycle routes', () => { expect(mockRegisterMachine).toHaveBeenCalledWith(expect.objectContaining({ name: 'Sam’s Mac' })); }); + it('marks and records the daemon registration once per person (TASK-661)', async () => { + mockRegisterMachine.mockResolvedValue({ + machine: { + id: 'm1', name: 'Sam’s Mac', machineId: 'daemon-machine', status: 'online', + }, + token: 'cm_daemon_once', + }); + + const res = await request(app).post('/api/machines').send({ name: 'Sam’s Mac' }); + + expect(res.status).toBe(201); + expect(mockMarkByoDaemonRegistered).toHaveBeenCalledWith('0123456789abcdef01234567'); + expect(mockRecordByoEvent).toHaveBeenCalledWith( + '0123456789abcdef01234567', + 'daemon-registered', + {}, + 'daemon-registered:0123456789abcdef01234567', + ); + }); + + it('keys the registration row by person, so a second machine uses the same key', async () => { + mockRegisterMachine.mockResolvedValue({ + machine: { + id: 'm2', name: 'Work Mac', machineId: 'daemon-machine-2', status: 'online', + }, + token: 'cm_daemon_two', + }); + + await request(app).post('/api/machines').send({ name: 'Work Mac' }); + await request(app).post('/api/machines').send({ name: 'Work Mac' }); + + const keys = mockRecordByoEvent.mock.calls.map(([, , , key]) => key); + expect(keys).toEqual([ + 'daemon-registered:0123456789abcdef01234567', + 'daemon-registered:0123456789abcdef01234567', + ]); + }); + + it('records nothing when registration fails', async () => { + mockRegisterMachine.mockRejectedValue(new Error('Machine limit reached')); + + const res = await request(app).post('/api/machines').send({ name: 'Sam’s Mac' }); + + expect(res.status).toBe(400); + expect(mockMarkByoDaemonRegistered).not.toHaveBeenCalled(); + expect(mockRecordByoEvent).not.toHaveBeenCalled(); + }); + it('lists only the caller’s machine views', async () => { mockListMachinesForOwner.mockResolvedValue({ machines: [{ id: 'm1', name: 'Sam’s Mac', status: 'online' }], diff --git a/backend/__tests__/unit/services/onboardingMarks.test.js b/backend/__tests__/unit/services/onboardingMarks.test.js index 21e63bc251..3b6331ad3b 100644 --- a/backend/__tests__/unit/services/onboardingMarks.test.js +++ b/backend/__tests__/unit/services/onboardingMarks.test.js @@ -10,7 +10,8 @@ jest.mock('../../../models/User', () => ({ const { markPodOpened, markStartPicked, markByoOpened, markByoCommandCopied, - markByoConnectionAttempt, markWelcomeHostedStart, markSurface, resetOnboardingMarksForTest, + markByoConnectionAttempt, markWelcomeHostedStart, markSurface, markByoDaemonRegistered, + resetOnboardingMarksForTest, } = require('../../../services/onboardingMarks'); const flush = () => new Promise((r) => setImmediate(r)); @@ -259,6 +260,37 @@ describe('BYO setup marks', () => { }); }); +describe('markByoDaemonRegistered (TASK-661)', () => { + test('sets byoDaemonRegisteredAt write-once in the filter, never for bots', () => { + markByoDaemonRegistered('u1'); + + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'u1', isBot: { $ne: true }, 'onboarding.byoDaemonRegisteredAt': { $exists: false } }, + { $set: { 'onboarding.byoDaemonRegisteredAt': expect.any(Date) } }, + ); + }); + + test('one write per person per process, however many machines they register', () => { + markByoDaemonRegistered('u1'); + markByoDaemonRegistered('u1'); + markByoDaemonRegistered('u2'); + expect(mockUpdateOne).toHaveBeenCalledTimes(2); + }); + + test('a failed write is retried on the next registration and never throws', async () => { + mockUpdateOne.mockRejectedValueOnce(new Error('db down')); + markByoDaemonRegistered('u1'); + await flush(); + markByoDaemonRegistered('u1'); + expect(mockUpdateOne).toHaveBeenCalledTimes(2); + }); + + test('no person, no write', () => { + markByoDaemonRegistered(undefined); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); +}); + describe('markSurface', () => { test.each(['inviteCardShownAt', 'inviteCardDismissedAt', 'byoOfferShownAt'])('%s is write-once in the filter, never for bots', (mark) => { markSurface('u1', mark); diff --git a/backend/models/User.ts b/backend/models/User.ts index 80c8d60814..bc27dcb5dd 100644 --- a/backend/models/User.ts +++ b/backend/models/User.ts @@ -224,6 +224,8 @@ export interface IUser extends Document { byoCliCopiedAt?: Date; /** First authenticated BYO runtime request, never overwritten. */ byoFirstConnectionAttemptAt?: Date; + /** First successful `commonly daemon register`, never overwritten (TASK-661). */ + byoDaemonRegisteredAt?: Date; /** First "invite a teammate" card mounted, and first dismissed. */ inviteCardShownAt?: Date; inviteCardDismissedAt?: Date; @@ -480,6 +482,7 @@ const userSchema = new Schema({ byoMcpCopiedAt: { type: Date }, byoCliCopiedAt: { type: Date }, byoFirstConnectionAttemptAt: { type: Date }, + byoDaemonRegisteredAt: { type: Date }, inviteCardShownAt: { type: Date }, inviteCardDismissedAt: { type: Date }, byoOfferShownAt: { type: Date }, diff --git a/backend/routes/machines.ts b/backend/routes/machines.ts index e50ac86970..a43e0c30d8 100644 --- a/backend/routes/machines.ts +++ b/backend/routes/machines.ts @@ -18,6 +18,10 @@ const auth = require('../middleware/auth'); const User = require('../models/User'); // eslint-disable-next-line @typescript-eslint/no-require-imports const Machine = require('../models/Machine'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { markByoDaemonRegistered } = require('../services/onboardingMarks'); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { recordByoEvent, daemonRegisteredKey } = require('../services/byoTelemetry'); const router = express.Router(); @@ -51,6 +55,11 @@ router.post('/', machineRateLimit, auth, async (req: AuthReq, res: express.Respo name: req.body?.name, deviceTokenHash: req.deviceTokenHash, }); + // First registration per person only: the mark and the funnel row are both + // write-once, so a second machine writes nothing. Written after success, so a + // failed register records nothing. + markByoDaemonRegistered(ownerUserId); + await recordByoEvent(ownerUserId, 'daemon-registered', {}, daemonRegisteredKey(ownerUserId)); // The raw daemon credential is intentionally only ever returned here. return res.status(201).json({ machine, daemonToken: token }); } catch (error) { diff --git a/backend/services/byoTelemetry.ts b/backend/services/byoTelemetry.ts index 962e1630dd..fba7443bec 100644 --- a/backend/services/byoTelemetry.ts +++ b/backend/services/byoTelemetry.ts @@ -5,7 +5,7 @@ import mongoose from 'mongoose'; // POST /api/stats/byo-step; events only the backend can see (an agent's first // authenticated call) are written here directly, so they never depend on the // user keeping the page open. Fixed step names only: never a command, key or name. -export type ServerByoStep = 'seat-registered'; +export type ServerByoStep = 'seat-registered' | 'daemon-registered'; // A once-per-seat event is keyed by a hash of the token record's hash, so the // unique _id index makes concurrent first calls write one row. The hash is one-way @@ -14,6 +14,12 @@ export function seatRegisteredKey(tokenHash: string): string { return `seat-registered:${createHash('sha256').update(tokenHash).digest('hex')}`; } +// A person registers a daemon once per setup, however many machines they add, +// so the row is keyed by the user alone. +export function daemonRegisteredKey(userId: unknown): string { + return `daemon-registered:${String(userId)}`; +} + export async function recordByoEvent( userId: unknown, step: ServerByoStep, diff --git a/backend/services/onboardingMarks.ts b/backend/services/onboardingMarks.ts index 71265733d1..432e36d8a2 100644 --- a/backend/services/onboardingMarks.ts +++ b/backend/services/onboardingMarks.ts @@ -233,6 +233,31 @@ export function markByoConnectionAttempt( } } +/** + * The first `commonly daemon register` for a person (TASK-661). Written from + * the machine route, after registration succeeds, so a failed register writes + * nothing. The byo_telemetry row is keyed per person by the caller, so the + * row and this mark each land once however many machines are registered. + */ +const daemonRegisteredMarked = new Set(); + +export function markByoDaemonRegistered(userId: unknown): void { + const id = String(userId || ''); + if (!id || daemonRegisteredMarked.has(id)) return; + daemonRegisteredMarked.add(id); + try { + User.updateOne( + { _id: id, isBot: { $ne: true }, 'onboarding.byoDaemonRegisteredAt': { $exists: false } }, + { $set: { 'onboarding.byoDaemonRegisteredAt': new Date() } }, + ).catch(() => { + daemonRegisteredMarked.delete(id); + }); + } catch { + // Telemetry fails open: a registration must never fail on its mark. + daemonRegisteredMarked.delete(id); + } +} + /** Record the first time a person met one of the onboarding surfaces. */ export function markSurface(userId: unknown, mark: SurfaceMark): void { const id = String(userId || ''); @@ -263,5 +288,6 @@ export function resetOnboardingMarksForTest(): void { byoCommandCopiedMarked.clear(); byoCommandKindMarked.clear(); byoConnectionAttemptMarked.clear(); + daemonRegisteredMarked.clear(); surfaceMarked.clear(); }