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,293 @@
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
import { SessionDirector } from './session-director.service.js';
/**
* This is deliberately a narrow bridge between the durable Vibe ledger and
* the current deterministic director. It writes authoritative revisions and
* lets the playback client replace only its unserved preview after feedback.
*/
export const DEFAULT_VIBE_POLICY_VERSION = 'vibe-v2-initial';
export const VIBE_EVENT_TYPES = [
'session_started', 'session_resumed', 'session_ended', 'context_changed',
'plan_published', 'track_served', 'playback_started', 'progress', 'completed',
'skipped', 'disliked', 'kept', 'favourite_added', 'queue_removed',
'manual_search', 'album_opened', 'artist_opened', 'playlist_added',
'track_replayed', 'volume_changed', 'playback_error',
] as const;
export type VibeEventType = (typeof VIBE_EVENT_TYPES)[number];
export interface StartVibeSessionInput {
seedTrackId?: string;
context?: Record<string, unknown>;
intent?: string;
policyVersion?: string;
resumeSessionId?: string;
}
export interface AppendVibeEventInput {
eventId?: string;
type: VibeEventType;
trackId?: string;
occurredAt?: Date;
positionMs?: number;
durationMs?: number;
payload?: Record<string, unknown>;
}
export class VibeSessionNotFoundError extends Error {}
export class VibeSessionLifecycleError extends Error {}
export class VibePlanNotFoundError extends Error {}
export interface VibeSessionResponse {
session: VibeSession;
sessionId: string;
planVersion: number | null;
now: VibePlan['items'][number] | null;
preview: VibePlan['items'];
state: Record<string, unknown>;
replanned: boolean;
replanReason: string | null;
}
export class VibeSessionCoordinator {
constructor(
private readonly db: DbService,
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState'>,
) {}
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
const context = input.context ?? {};
const policyVersion = input.policyVersion ?? DEFAULT_VIBE_POLICY_VERSION;
const session = await this.db.createVibeSession({
userId,
policyVersion,
seedTrackId: input.seedTrackId ?? null,
context,
});
// session_state is a derived cache used by the current director. Give it
// the durable ID so director state cannot accidentally bleed into another
// session while the durable tables remain the source of truth.
await this.db.createSessionState(
userId,
typeof context.activity === 'string' ? context.activity : input.intent,
{ energy: 0.5, noveltyHunger: 0.3 },
session.id,
);
await this.db.recordVibeEvent({
sessionId: session.id,
userId,
type: 'session_started',
payload: { policyVersion, context, intent: input.intent ?? null },
});
const candidates = await this.director.buildPlan(userId, session.id, input.seedTrackId);
const state = await this.director.buildState(userId, session.id);
let plan: VibePlan;
try {
plan = await this.db.publishVibePlan({
sessionId: session.id,
userId,
version: 1,
reason: 'session_started',
stateSnapshot: state,
objectiveSnapshot: {
policyVersion,
intent: input.intent ?? null,
horizonTracks: candidates.length,
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: ordinal === 0 ? 'next' : null,
candidate_source: candidate.generatorId,
score: candidate.relevance,
score_breakdown: { relevance: candidate.relevance },
explanation: candidate.explanation,
committed: false,
})),
});
} catch (error) {
throw this.mapLifecycleError(error);
}
return this.toResponse(session, plan, state);
}
async getPlan(userId: string, sessionId: string, version?: number): Promise<VibeSessionResponse> {
const session = await this.requireSession(userId, sessionId);
const plan = await this.db.getVibePlan(sessionId, userId, version);
if (!plan) throw new VibePlanNotFoundError(
version === undefined ? 'Vibe session does not have a published plan' : 'Vibe plan revision was not found',
);
return this.toResponse(session, plan, plan?.state_snapshot ?? {});
}
async serveNext(userId: string, sessionId: string, expectedPlanVersion?: number): Promise<VibeSessionResponse> {
try {
const served = expectedPlanVersion === undefined
? await this.db.serveNextVibePlanItem(sessionId, userId)
: await this.db.serveNextVibePlanItem(sessionId, userId, expectedPlanVersion);
const response = await this.getPlan(userId, sessionId);
// A plan may be replaced between the client's preview and this request.
// In that case the database does not commit anything and this is the
// current, revisable preview the client must reconcile to.
if (served.stale) return response;
return { ...response, now: served.item, preview: response.preview };
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async appendEvent(
userId: string,
sessionId: string,
input: AppendVibeEventInput,
): Promise<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
try {
const result = await this.db.recordVibeEvent({
sessionId,
userId,
clientEventId: input.eventId,
type: input.type,
trackId: input.trackId,
occurredAt: input.occurredAt,
positionMs: input.positionMs,
durationMs: input.durationMs,
payload: input.payload,
});
if (!isMaterialFeedback(input.type)) {
const response = await this.getPlan(userId, sessionId);
return { ...response, event: result.event, idempotent: !result.inserted };
}
// A material event is durable before its computed replacement can be
// written. If planning/persistence failed after that event committed, a
// retry must finish the missing replacement instead of permanently
// returning an obsolete preview. Once a replacement exists, a duplicate
// retry returns that canonical revision without doing work again.
if (!result.inserted) {
const existingReplacement = await this.db.getVibePlanForFeedbackEvent(sessionId, userId, result.event.id);
if (existingReplacement) {
const session = await this.requireSession(userId, sessionId);
return {
...this.toResponse(session, existingReplacement, existingReplacement.state_snapshot),
event: result.event,
idempotent: true,
};
}
}
const session = await this.requireSession(userId, sessionId);
const state = await this.director.buildState(userId, sessionId);
// A feedback target is useful as the local replan anchor, but it must
// never displace the durable seed from the exclusion boundary. Unlike
// feedback tracks, the seed is not necessarily present in the event
// ledger, so carry it explicitly into every replacement request.
const seedTrackId = session.seed_track_id ?? undefined;
const candidates = seedTrackId
? await this.director.buildPlan(userId, sessionId, input.trackId ?? seedTrackId, {
excludedTrackIds: new Set([seedTrackId]),
})
: await this.director.buildPlan(userId, sessionId, input.trackId);
const reason = `feedback:${input.type}`;
const plan = await this.db.publishVibePlan({
sessionId,
userId,
reason,
stateSnapshot: state,
objectiveSnapshot: {
policyVersion: session.policy_version,
feedbackEventId: result.event.id,
feedbackType: input.type,
horizonTracks: candidates.length,
},
items: candidates.map((candidate, ordinal) => ({
ordinal,
track_id: candidate.trackId,
slot_role: ordinal === 0 ? 'next' : null,
candidate_source: candidate.generatorId,
score: candidate.relevance,
score_breakdown: { relevance: candidate.relevance },
explanation: candidate.explanation,
committed: false,
})),
});
return {
...this.toResponse(session, plan, state),
event: result.event,
idempotent: !result.inserted,
replanned: true,
replanReason: reason,
};
} catch (error) {
throw this.mapLifecycleError(error);
}
}
async end(userId: string, sessionId: string): Promise<VibeSessionResponse> {
let ended: VibeSession;
try {
ended = (await this.db.endVibeSessionWithEvent(sessionId, userId)).session;
} catch (error) {
throw this.mapLifecycleError(error);
}
const plan = await this.db.getVibePlan(sessionId, userId);
return this.toResponse(ended, plan, plan?.state_snapshot ?? {});
}
private async resume(userId: string, sessionId: string): Promise<VibeSessionResponse> {
try {
const resumed = await this.db.resumeVibeSession(sessionId, userId);
const plan = await this.db.getVibePlan(sessionId, userId);
return this.toResponse(resumed.session, plan, plan?.state_snapshot ?? {});
} catch (error) {
throw this.mapLifecycleError(error);
}
}
private async requireSession(userId: string, sessionId: string): Promise<VibeSession> {
const session = await this.db.getVibeSession(sessionId, userId);
if (!session) throw new VibeSessionNotFoundError('Vibe session was not found');
return session;
}
private toResponse(
session: VibeSession,
plan: VibePlan | null,
state: Record<string, unknown>,
): VibeSessionResponse {
// A revision is immutable, but clients need a live future: already served
// rows stay in the ledger and are excluded from the replacement preview.
const preview = plan?.items.filter((item) => !item.committed).slice(0, 8) ?? [];
return {
session,
sessionId: session.id,
planVersion: plan?.version ?? null,
now: preview[0] ?? null,
preview,
state,
replanned: false,
replanReason: null,
};
}
private mapLifecycleError(error: unknown): Error {
if (error instanceof Error && (error.message.includes('Cannot record a new event for') || error.message.includes('Cannot resume ') || error.message.includes('Cannot publish a plan for'))) {
return new VibeSessionLifecycleError(error.message);
}
if (error instanceof Error && error.message.includes('not found or is not owned')) {
return new VibeSessionNotFoundError('Vibe session was not found');
}
return error instanceof Error ? error : new Error(String(error));
}
}
const MATERIAL_FEEDBACK_EVENTS = new Set<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);
function isMaterialFeedback(type: VibeEventType): boolean {
return MATERIAL_FEEDBACK_EVENTS.has(type);
}