Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions backend/__tests__/unit/routes/machines.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' };
Expand All @@ -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),
Expand Down Expand Up @@ -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' }],
Expand Down
34 changes: 33 additions & 1 deletion backend/__tests__/unit/services/onboardingMarks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions backend/models/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -480,6 +482,7 @@ const userSchema = new Schema<IUser>({
byoMcpCopiedAt: { type: Date },
byoCliCopiedAt: { type: Date },
byoFirstConnectionAttemptAt: { type: Date },
byoDaemonRegisteredAt: { type: Date },
inviteCardShownAt: { type: Date },
inviteCardDismissedAt: { type: Date },
byoOfferShownAt: { type: Date },
Expand Down
9 changes: 9 additions & 0 deletions backend/routes/machines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 7 additions & 1 deletion backend/services/byoTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions backend/services/onboardingMarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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 || '');
Expand Down Expand Up @@ -263,5 +288,6 @@ export function resetOnboardingMarksForTest(): void {
byoCommandCopiedMarked.clear();
byoCommandKindMarked.clear();
byoConnectionAttemptMarked.clear();
daemonRegisteredMarked.clear();
surfaceMarked.clear();
}
Loading