import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js'; import { SessionDirector } from './session-director.service.js'; import { Candidate, VibeCalendarContext } from './generators.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', '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; resumeSessionId?: string; context?: VibeCalendarContext; } export interface AppendVibeEventInput { eventId?: string; type: VibeEventType; trackId?: string; occurredAt?: Date; positionMs?: number; durationMs?: number; payload?: Record; } /** * An explicit advancement protocol for a plan item that was durably served * but cannot be played locally. `eventId` is the idempotency key for this * state transition; it is intentionally separate from a retry of /next. */ export interface AdvanceUnplayableVibeItemInput { expectedPlanVersion: number; planVersionId: string; ordinal: number; trackId: string; eventId: string; } /** * How much of the plan a client is shown and holds ready. The plan itself is * PLAN_SIZE long and stays that way; this is only the served window. Every * preview item costs the client one track fetch on every advance, and a replan * discards whatever is still unplayed, so a long window buys little beyond a * longer Up next list. */ const PREVIEW_SIZE = 3; 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; replanned: boolean; replanReason: string | null; } export class VibeSessionCoordinator { constructor( private readonly db: DbService, private readonly director: Pick, ) {} async start(userId: string, input: StartVibeSessionInput): Promise { if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId); // Calendar context is a weak boot prior, never a substitute for listening // feedback or long-term preference. It is intentionally coarse and is // persisted with the session so a resume remains coherent. const calendar = input.context; const contextualEnergy = calendar && calendar.localHour < 6 ? 0.32 : calendar && calendar.localHour >= 18 && calendar.localHour < 23 ? 0.58 : 0.5; const initialState = { energy: contextualEnergy, noveltyHunger: 0.3, explorationCoefficient: 0.3, discoveryRadius: 0.38, sessionGoal: { type: 'discovery' as const, target: 1, progress: 0 }, }; const session = await this.db.createVibeSession({ userId, policyVersion: DEFAULT_VIBE_POLICY_VERSION, seedTrackId: input.seedTrackId ?? null, context: calendar ? { ...calendar } : undefined, profile: { goals: initialState.sessionGoal, explorationCoefficient: initialState.explorationCoefficient, discoveryRadius: initialState.discoveryRadius, }, }); // 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, calendar ? JSON.stringify(calendar) : undefined, { energy: initialState.energy, noveltyHunger: initialState.noveltyHunger, explorationCoefficient: initialState.explorationCoefficient, discoveryRadius: initialState.discoveryRadius, sessionGoal: initialState.sessionGoal, }, session.id, ); await this.db.recordVibeEvent({ sessionId: session.id, userId, type: 'session_started', payload: { policyVersion: DEFAULT_VIBE_POLICY_VERSION, ...(calendar ? { calendar } : {}) }, }); 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: DEFAULT_VIBE_POLICY_VERSION, horizonTracks: candidates.length, ...(candidates[0]?.plan?.objective ?? {}), }, items: candidates.map((candidate, ordinal) => ({ ordinal, track_id: candidate.trackId, slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null), candidate_source: candidate.generatorId, score: candidate.plan?.score ?? candidate.relevance, score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance }, explanation: candidate.plan ? { paths: candidate.explanation, planner: candidate.plan.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 { 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 { 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 advancePastUnplayable( userId: string, sessionId: string, input: AdvanceUnplayableVibeItemInput, ): Promise { try { const served = await this.db.advancePastUnplayableVibePlanItem(sessionId, userId, input); const response = await this.getPlan(userId, sessionId); 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 { try { // Do this before the ledger write, rather than after it, because Vibe // events are immutable. The DB repeats this boundary for non-HTTP // callers; keeping it here also makes coordinator callers see exactly // what will be persisted. 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, }); // The ledger write is authoritative; this idempotent projection updates // exploration only after the exact event exists. Keep the compatibility // guard for old coordinator test doubles during the migration. const projectSessionFeedback = (this.db as Partial).projectVibeSessionFeedback; if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event); // A dislike in a Vibe is the same verdict as a dislike anywhere else. The // ledger alone only excludes the track from this one session, which is // why a disliked track kept coming back the next evening. Run it once per // distinct event so a retried delivery cannot log a second feedback row. if (input.type === 'disliked' && input.trackId && result.inserted) { const dislikeTrack = (this.db as Partial).dislikeTrack; if (dislikeTrack) await dislikeTrack.call(this.db, userId, input.trackId); } 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; // Revisions retain the durable, unserved queue tail rather than building // an unrelated plan after every signal. Besides reducing churn, this // preserves a valid callback/recovery pair that has already been shown // to the client while allowing the director to refill under the same // hard caps and current feedback state. const current = await this.db.getVibePlan(sessionId, userId); const retained = current ? this.unservedCandidates(current) : []; const excludedTrackIds = new Set([ ...(seedTrackId ? [seedTrackId] : []), ]); const candidates = await this.director.replan( userId, sessionId, retained, input.trackId ? [input.trackId] : [], input.trackId ?? seedTrackId, { excludedTrackIds }, ); 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, ...(candidates[0]?.plan?.objective ?? {}), }, items: candidates.map((candidate, ordinal) => ({ ordinal, track_id: candidate.trackId, slot_role: candidate.plan?.slotRole ?? (ordinal === 0 ? 'next' : null), candidate_source: candidate.generatorId, score: candidate.plan?.score ?? candidate.relevance, score_breakdown: candidate.plan?.scoreBreakdown ?? { relevance: candidate.relevance }, explanation: candidate.plan ? { paths: candidate.explanation, planner: candidate.plan.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 { 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 { 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 { 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, ): 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, PREVIEW_SIZE) ?? []; return { session, sessionId: session.id, planVersion: plan?.version ?? null, now: preview[0] ?? null, preview, state, replanned: false, replanReason: null, }; } /** Reconstruct the planner envelope from the durable revision. Older * revisions stored only paths, so they remain valid retention inputs. */ private unservedCandidates(plan: VibePlan): Candidate[] { return plan.items .filter(item => !item.committed) .map(item => { const stored = item.explanation; const hasPlanner = !!stored && !Array.isArray(stored) && typeof stored === 'object' && 'planner' in stored; const object = hasPlanner ? stored as { paths?: Candidate['explanation']; planner?: Record } : undefined; const planner = object?.planner; return { trackId: item.track_id, generatorId: item.candidate_source, relevance: item.score, explanation: object?.paths ?? (Array.isArray(stored) ? stored : []), plan: planner ? { slotRole: item.slot_role ?? 'retained', score: item.score, scoreBreakdown: item.score_breakdown, explanation: planner, objective: { policy: planner.policy, constraints: planner.constraints, relaxations: planner.relaxations, }, } : undefined, }; }); } 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(['skipped', 'disliked', 'completed', 'kept']); function isMaterialFeedback(type: VibeEventType): boolean { return MATERIAL_FEEDBACK_EVENTS.has(type); }