feat(vibe): add durable versioned session API
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user