feat(vibe): add durable versioned session API

This commit is contained in:
kami
2026-08-01 23:09:09 +04:00
parent 515cab2f89
commit 51ef7c84db
10 changed files with 1494 additions and 24 deletions
+227 -9
View File
@@ -20,13 +20,18 @@ function makeTransactionalService(): { service: DbService; poolQuery: ReturnType
describe('DbService v2 methods', () => {
describe('durable Vibe sessions', () => {
it('creates, reads, and ends sessions scoped to their user', async () => {
const { service, mockQuery } = makeService();
const { service, poolQuery, clientQuery } = makeTransactionalService();
const session = {
id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
context: { activity: 'focus' }, policy_version: 'v2.1',
};
mockQuery
.mockResolvedValueOnce({ rows: [session] })
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [] }) // active-session lock
.mockResolvedValueOnce({ rows: [session] }) // insert
.mockResolvedValueOnce({ rows: [] }); // COMMIT
poolQuery
.mockResolvedValueOnce({ rows: [session] })
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
@@ -36,11 +41,32 @@ describe('DbService v2 methods', () => {
await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });
expect(mockQuery.mock.calls[0][0]).toContain('INSERT INTO vibe_sessions');
expect(mockQuery.mock.calls[0][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']);
expect(mockQuery.mock.calls[1][0]).toContain('id = $1 AND user_id = $2');
expect(mockQuery.mock.calls[2][0]).toContain('COALESCE(ended_at, NOW())');
expect(mockQuery.mock.calls[2][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
expect(clientQuery.mock.calls[3][1]).toEqual(['user-1', null, JSON.stringify({ activity: 'focus' }), 'v2.1']);
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
});
it('replaces an owned active session and writes its terminal event before starting another', async () => {
const { service, clientQuery } = makeTransactionalService();
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
.mockResolvedValueOnce({ rows: [] }) // terminal event
.mockResolvedValueOnce({ rows: [replacement] }) // new session
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
.resolves.toEqual(replacement);
expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
});
it('records retry-safe events and reports whether the event was inserted', async () => {
@@ -69,12 +95,44 @@ describe('DbService v2 methods', () => {
expect(values).toEqual([
'session-1', 'user-1',
]);
expect(clientQuery.mock.calls).toHaveLength(4);
expect(clientQuery.mock.calls).toHaveLength(5);
expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
expect.stringContaining('UPDATE vibe_sessions')
);
});
it('projects material feedback once with the durable event transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const event = {
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(),
position_ms: null, duration_ms: null, payload: {},
};
const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
.mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
.mockResolvedValueOnce({ rows: [event] }) // event insert
.mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
.mockResolvedValueOnce({ rows: [] }) // play history
.mockResolvedValueOnce({ rows: [] }) // track counter
.mockResolvedValueOnce({ rows: [] }) // session timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.recordVibeEvent({
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
type: 'completed', trackId: 'track-1',
})).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });
expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
expect.stringContaining('vibe_event_projections'),
expect.stringContaining('INSERT INTO play_history'),
]));
expect(evidence).toHaveBeenCalledTimes(1);
});
it('rejects an event when no owned session is returned', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
@@ -114,9 +172,116 @@ describe('DbService v2 methods', () => {
expect.stringContaining('INSERT INTO vibe_events')
);
});
it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
const { service, clientQuery } = makeTransactionalService();
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
const ended = { ...active, status: 'ended', ended_at: new Date() };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [active] }) // lock
.mockResolvedValueOnce({ rows: [] }) // terminal event
.mockResolvedValueOnce({ rows: [ended] }) // status transition
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
.resolves.toEqual({ session: ended, ended: true });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
});
it('resumes an owned session once and records session_resumed in the same lock', async () => {
const { service, clientQuery } = makeTransactionalService();
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
.mockResolvedValueOnce({ rows: [] }) // no old resume event
.mockResolvedValueOnce({ rows: [active] }) // activate
.mockResolvedValueOnce({ rows: [] }) // ledger event
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.resumeVibeSession('session-1', 'user-1'))
.resolves.toEqual({ session: active, resumed: true });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
});
});
describe('durable Vibe plans', () => {
it('publishes a revision and its plan_published event in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
const published = {
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
.mockResolvedValueOnce({ rows: [published] }) // header
.mockResolvedValueOnce({ rows: [] }) // item
.mockResolvedValueOnce({ rows: [] }) // plan_published
.mockResolvedValueOnce({ rows: [] }) // timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {},
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
})).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
});
it('rolls back the plan header and items if writing plan_published fails', async () => {
const { service, clientQuery } = makeTransactionalService();
const published = {
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
.mockResolvedValueOnce({ rows: [published] }) // header
.mockResolvedValueOnce({ rows: [] }) // item
.mockRejectedValueOnce(new Error('ledger write failed'))
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {},
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
})).rejects.toThrow('ledger write failed');
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
});
it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
await expect(service.publishVibePlan({
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
stateSnapshot: {}, objectiveSnapshot: {}, items: [],
})).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
});
it('reads every durable session track as a replacement-plan exclusion', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
.resolves.toEqual(['served', 'skipped', 'disliked']);
expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
});
it('writes a header and all items in one transaction', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
@@ -143,6 +308,59 @@ describe('DbService v2 methods', () => {
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
});
it('serves and commits one next item under the session lock', async () => {
const { service, clientQuery } = makeTransactionalService();
const item = {
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
.mockResolvedValueOnce({ rows: [item] }) // commit item
.mockResolvedValueOnce({ rows: [] }) // track_served event
.mockResolvedValueOnce({ rows: [] }) // timestamp
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
});
it('returns a newer preview signal without committing when the expected plan is stale', async () => {
const { service, clientQuery } = makeTransactionalService();
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
.resolves.toEqual({ item: null, stale: true });
expect(clientQuery.mock.calls).toHaveLength(4);
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('returns the original item when a version-aware next request is retried', async () => {
const { service, clientQuery } = makeTransactionalService();
const item = {
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
};
clientQuery
.mockResolvedValueOnce({ rows: [] }) // BEGIN
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
.mockResolvedValueOnce({ rows: [item] }) // prior served item
.mockResolvedValueOnce({ rows: [] }); // COMMIT
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
.resolves.toEqual({ item, stale: false });
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
});
it('reads the latest revision and reconstructs ordered plan items', async () => {
const { service, mockQuery } = makeService();
mockQuery.mockResolvedValue({ rows: [{