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
@@ -0,0 +1,136 @@
import Fastify from 'fastify';
import { describe, expect, it, vi } from 'vitest';
import vibeSessionsRoutes, { VibeIdentityResolver } from './vibe-sessions.routes.js';
import { VibeSessionLifecycleError } from '../services/vibe-session-coordinator.service.js';
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
function response() {
return {
sessionId: SESSION_ID, planVersion: 1, now: null, preview: [], state: {},
replanned: false, replanReason: null,
session: { id: SESSION_ID, status: 'active' },
};
}
async function appWithCoordinator(identityResolver: VibeIdentityResolver = () => USER_ID) {
const coordinator = {
start: vi.fn().mockResolvedValue(response()),
getPlan: vi.fn().mockResolvedValue(response()),
appendEvent: vi.fn().mockResolvedValue({ ...response(), event: { id: 'event-1' }, idempotent: false }),
end: vi.fn().mockResolvedValue(response()),
serveNext: vi.fn().mockResolvedValue(response()),
} as any;
const app = Fastify();
await app.register(vibeSessionsRoutes, { coordinator, identityResolver });
await app.ready();
return { app, coordinator };
}
describe('durable Vibe session routes', () => {
it('uses the trusted identity resolver and never accepts a spoofed x-user-id header', async () => {
const { app, coordinator } = await appWithCoordinator();
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} });
expect(result.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object));
await app.close();
});
it('rejects requests when no trusted identity is configured instead of defaulting a shared user', async () => {
const { app, coordinator } = await appWithCoordinator(() => null);
const result = await app.inject({
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {},
});
expect(result.statusCode).toBe(401);
expect(coordinator.start).not.toHaveBeenCalled();
await app.close();
});
it('creates a session and validates event payloads before touching the coordinator', async () => {
const { app, coordinator } = await appWithCoordinator();
const created = await app.inject({
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' },
payload: { seedTrackId: TRACK_ID, context: { activity: 'focus' }, policyVersion: 'test-policy' },
});
const invalidEvent = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
payload: { type: 'definitely-not-an-event' },
});
expect(created.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ policyVersion: 'test-policy' }));
expect(invalidEvent.statusCode).toBe(400);
expect(coordinator.appendEvent).not.toHaveBeenCalled();
await app.close();
});
it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => {
const { app, coordinator } = await appWithCoordinator();
coordinator.start.mockRejectedValueOnce(
new VibeSessionLifecycleError('Cannot publish a plan for ended Vibe session'),
);
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} });
expect(result.statusCode).toBe(409);
expect(result.json()).toEqual({
error: 'Cannot publish a plan for ended Vibe session',
code: 'VIBE_SESSION_NOT_ACTIVE',
});
await app.close();
});
it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => {
const { app, coordinator } = await appWithCoordinator();
const plan = await app.inject({
method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' },
});
const event = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
payload: { eventId: '33333333-3333-4333-8333-333333333333', type: 'completed', trackId: TRACK_ID, positionMs: 5_000 },
});
expect(plan.statusCode).toBe(200);
expect(coordinator.getPlan).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
expect(event.statusCode).toBe(200);
expect(coordinator.appendEvent).toHaveBeenCalledWith(USER_ID, SESSION_ID, expect.objectContaining({ type: 'completed', positionMs: 5_000 }));
await app.close();
});
it('validates occurredAt and exposes owned resume and next operations', async () => {
const { app, coordinator } = await appWithCoordinator();
const resume = await app.inject({
method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID },
});
const badTime = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
payload: { type: 'completed', occurredAt: 'not-a-date' },
});
const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next` });
expect(resume.statusCode).toBe(201);
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID }));
expect(badTime.statusCode).toBe(400);
expect(next.statusCode).toBe(200);
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID);
await app.close();
});
it('passes a version-aware next request through and rejects an invalid expected version', async () => {
const { app, coordinator } = await appWithCoordinator();
const valid = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 2 },
});
const invalid = await app.inject({
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/next`, payload: { expectedPlanVersion: 0 },
});
expect(valid.statusCode).toBe(200);
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
expect(invalid.statusCode).toBe(400);
expect(coordinator.serveNext).toHaveBeenCalledTimes(1);
await app.close();
});
});
+175
View File
@@ -0,0 +1,175 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import {
VIBE_EVENT_TYPES,
VibeSessionCoordinator,
VibeSessionLifecycleError,
VibeSessionNotFoundError,
VibePlanNotFoundError,
} from '../services/vibe-session-coordinator.service.js';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/** Identity must come from authenticated server configuration/middleware, never a client header. */
export type VibeIdentityResolver = (request: FastifyRequest) => string | null;
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function validUuid(value: unknown): value is string {
return typeof value === 'string' && UUID_RE.test(value);
}
function validOccurredAt(value: unknown): value is string {
// Require an actual offset-bearing timestamp, rather than Date.parse's
// permissive inputs such as "2026" or locale-dependent strings.
return typeof value === 'string'
&& /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i.test(value)
&& !Number.isNaN(Date.parse(value));
}
export default async function vibeSessionsRoutes(
fastify: FastifyInstance,
options: { coordinator: VibeSessionCoordinator; identityResolver: VibeIdentityResolver },
) {
const { coordinator, identityResolver } = options;
const requireUser = (request: FastifyRequest, reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }): string | null => {
const userId = identityResolver(request);
if (userId && validUuid(userId)) return userId;
reply.code(401).send({ error: 'A trusted Vibe identity is required' });
return null;
};
fastify.post('/v2/vibe/sessions', async (request, reply) => {
const userId = requireUser(request, reply);
if (!userId) return;
const body = isObject(request.body) ? request.body : {};
if (body.resumeSessionId !== undefined && !validUuid(body.resumeSessionId)) {
return reply.code(400).send({ error: 'resumeSessionId must be a UUID' });
}
if (body.resumeSessionId !== undefined && body.seedTrackId !== undefined) {
return reply.code(400).send({ error: 'resumeSessionId cannot be combined with seedTrackId' });
}
if (body.seedTrackId !== undefined && !validUuid(body.seedTrackId)) {
return reply.code(400).send({ error: 'seedTrackId must be a UUID' });
}
if (body.context !== undefined && !isObject(body.context)) {
return reply.code(400).send({ error: 'context must be an object' });
}
if (body.intent !== undefined && typeof body.intent !== 'string') {
return reply.code(400).send({ error: 'intent must be a string' });
}
if (body.policyVersion !== undefined && (typeof body.policyVersion !== 'string' || !body.policyVersion.trim())) {
return reply.code(400).send({ error: 'policyVersion must be a non-empty string' });
}
try {
return reply.code(201).send(await coordinator.start(userId, {
seedTrackId: body.seedTrackId as string | undefined,
context: body.context as Record<string, unknown> | undefined,
intent: body.intent as string | undefined,
policyVersion: body.policyVersion as string | undefined,
resumeSessionId: body.resumeSessionId as string | undefined,
}));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => {
const userId = requireUser(request, reply);
if (!userId) return;
const { sessionId } = request.params as { sessionId: string };
const { version } = request.query as { version?: string };
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
const parsedVersion = version === undefined ? undefined : Number(version);
if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) {
return reply.code(400).send({ error: 'version must be a positive integer' });
}
try {
return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => {
const userId = requireUser(request, reply);
if (!userId) return;
const { sessionId } = request.params as { sessionId: string };
const body = isObject(request.body) ? request.body : null;
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
if (!body || !VIBE_EVENT_TYPES.includes(body.type as typeof VIBE_EVENT_TYPES[number])) {
return reply.code(400).send({ error: 'type must be a supported Vibe event type' });
}
if (body.eventId !== undefined && !validUuid(body.eventId)) {
return reply.code(400).send({ error: 'eventId must be a UUID' });
}
if (body.trackId !== undefined && !validUuid(body.trackId)) {
return reply.code(400).send({ error: 'trackId must be a UUID' });
}
if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) {
return reply.code(400).send({ error: 'occurredAt must be an ISO-8601 timestamp' });
}
if (body.positionMs !== undefined && (!Number.isInteger(body.positionMs) || (body.positionMs as number) < 0)) {
return reply.code(400).send({ error: 'positionMs must be a non-negative integer' });
}
if (body.durationMs !== undefined && (!Number.isInteger(body.durationMs) || (body.durationMs as number) < 0)) {
return reply.code(400).send({ error: 'durationMs must be a non-negative integer' });
}
if (body.payload !== undefined && !isObject(body.payload)) {
return reply.code(400).send({ error: 'payload must be an object' });
}
try {
return reply.send(await coordinator.appendEvent(userId, sessionId, {
eventId: body.eventId as string | undefined,
type: body.type as typeof VIBE_EVENT_TYPES[number],
trackId: body.trackId as string | undefined,
occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string),
positionMs: body.positionMs as number | undefined,
durationMs: body.durationMs as number | undefined,
payload: body.payload as Record<string, unknown> | undefined,
}));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => {
const userId = requireUser(request, reply);
if (!userId) return;
const { sessionId } = request.params as { sessionId: string };
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
try {
return reply.send(await coordinator.end(userId, sessionId));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
fastify.post('/v2/vibe/sessions/:sessionId/next', async (request, reply) => {
const userId = requireUser(request, reply);
if (!userId) return;
const { sessionId } = request.params as { sessionId: string };
const body = isObject(request.body) ? request.body : {};
if (!validUuid(sessionId)) return reply.code(400).send({ error: 'sessionId must be a UUID' });
if (body.expectedPlanVersion !== undefined
&& (!Number.isInteger(body.expectedPlanVersion) || (body.expectedPlanVersion as number) < 1)) {
return reply.code(400).send({ error: 'expectedPlanVersion must be a positive integer' });
}
try {
const expectedPlanVersion = body.expectedPlanVersion as number | undefined;
return reply.send(expectedPlanVersion === undefined
? await coordinator.serveNext(userId, sessionId)
: await coordinator.serveNext(userId, sessionId, expectedPlanVersion));
} catch (error) {
return sendCoordinatorError(reply, error);
}
});
}
function sendCoordinatorError(reply: { code: (statusCode: number) => { send: (payload: unknown) => unknown } }, error: unknown) {
if (error instanceof VibeSessionNotFoundError) return reply.code(404).send({ error: error.message });
if (error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message });
if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' });
throw error;
}