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
+427 -15
View File
@@ -1478,10 +1478,17 @@ export class DbService {
/**
* Create a new session state row.
*/
async createSessionState(userId: string, context?: string, stateVector?: Record<string, unknown>): Promise<string> {
async createSessionState(
userId: string,
context?: string,
stateVector?: Record<string, unknown>,
sessionId?: string
): Promise<string> {
const res = await this.pgClient.query(
`INSERT INTO session_state (user_id, context, state_vector) VALUES ($1, $2, $3) RETURNING session_id`,
[userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}']
`INSERT INTO session_state (session_id, user_id, context, state_vector)
VALUES (COALESCE($1::uuid, gen_random_uuid()), $2, $3, $4::jsonb)
RETURNING session_id`,
[sessionId ?? null, userId, context ?? null, stateVector ? JSON.stringify(stateVector) : '{}']
);
return res.rows[0].session_id as string;
}
@@ -1508,18 +1515,45 @@ export class DbService {
seedTrackId?: string | null;
context?: Record<string, unknown>;
}): Promise<VibeSession> {
const res = await this.pgClient.query(
`INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version)
VALUES ($1, 'active', $2, $3::jsonb, $4)
RETURNING *`,
[
params.userId,
params.seedTrackId ?? null,
JSON.stringify(params.context ?? {}),
params.policyVersion,
]
);
return res.rows[0] as VibeSession;
return this.withTransaction(async (client) => {
// Serialize starts for one listener even when there is no active row to
// lock yet. The row lock below then safely replaces any prior session.
await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [params.userId]);
// Lock every active session first. This makes concurrent starts converge
// on one active durable session instead of creating overlapping streams.
const active = await client.query(
`SELECT id FROM vibe_sessions WHERE user_id = $1 AND status = 'active' FOR UPDATE`,
[params.userId],
);
if (active.rows.length > 0) {
const replaced = await client.query(
`UPDATE vibe_sessions
SET status = 'replaced', ended_at = NOW(), last_event_at = NOW()
WHERE user_id = $1 AND status = 'active'
RETURNING id`,
[params.userId],
);
for (const session of replaced.rows as Array<{ id: string }>) {
await client.query(
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
VALUES ($1, $2, 'session_ended', NOW(), '{"reason":"replaced"}'::jsonb)`,
[session.id, params.userId],
);
}
}
const res = await client.query(
`INSERT INTO vibe_sessions (user_id, status, seed_track_id, context, policy_version)
VALUES ($1, 'active', $2, $3::jsonb, $4)
RETURNING *`,
[
params.userId,
params.seedTrackId ?? null,
JSON.stringify(params.context ?? {}),
params.policyVersion,
],
);
return res.rows[0] as VibeSession;
});
}
/** Fetch a Vibe session only when it belongs to the requesting user. */
@@ -1552,6 +1586,70 @@ export class DbService {
return (res.rows[0] as VibeSession) ?? null;
}
/**
* Resume an owned paused/active session exactly once. The row lock makes
* the transition and its ledger entry inseparable and prevents retries from
* manufacturing a stream of session_resumed events.
*/
async resumeVibeSession(sessionId: string, userId: string): Promise<{ session: VibeSession; resumed: boolean }> {
return this.withTransaction(async (client) => {
const result = await client.query(
`SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
[sessionId, userId]
);
const session = result.rows[0] as VibeSession | undefined;
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
throw new Error(`Cannot resume ${session.status} Vibe session`);
}
const prior = await client.query(
`SELECT 1 FROM vibe_events WHERE session_id = $1 AND type = 'session_resumed' LIMIT 1`,
[sessionId]
);
if (prior.rowCount) return { session, resumed: false };
const updated = await client.query(
`UPDATE vibe_sessions SET status = 'active', ended_at = NULL, last_event_at = NOW()
WHERE id = $1 RETURNING *`,
[sessionId]
);
const resumed = updated.rows[0] as VibeSession;
await client.query(
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
VALUES ($1, $2, 'session_resumed', NOW(), '{}'::jsonb)`,
[sessionId, userId]
);
return { session: resumed, resumed: true };
});
}
/** End a session and append its terminal event under one session-row lock. */
async endVibeSessionWithEvent(sessionId: string, userId: string): Promise<{ session: VibeSession; ended: boolean }> {
return this.withTransaction(async (client) => {
const result = await client.query(
`SELECT * FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
[sessionId, userId]
);
const session = result.rows[0] as VibeSession | undefined;
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
if (session.status === 'ended' || session.status === 'expired' || session.status === 'replaced') {
return { session, ended: false };
}
await client.query(
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
VALUES ($1, $2, 'session_ended', NOW(), '{}'::jsonb)`,
[sessionId, userId]
);
const updated = await client.query(
`UPDATE vibe_sessions SET status = 'ended', ended_at = NOW(), last_event_at = NOW()
WHERE id = $1 RETURNING *`,
[sessionId]
);
return { session: updated.rows[0] as VibeSession, ended: true };
});
}
/**
* Append an immutable Vibe event. A supplied clientEventId is idempotent per
* session: a retry returns the original event and does not advance the
@@ -1596,6 +1694,7 @@ export class DbService {
);
const existing = existingRes.rows[0] as VibeEvent | undefined;
if (existing) {
await this.projectVibeFeedback(existing, client);
return { event: existing, inserted: false };
}
}
@@ -1627,6 +1726,8 @@ export class DbService {
throw new Error('Vibe event could not be recorded');
}
await this.projectVibeFeedback(event, client);
await client.query(
`UPDATE vibe_sessions
SET last_event_at = GREATEST(last_event_at, $2::timestamptz)
@@ -1637,11 +1738,178 @@ export class DbService {
});
}
/**
* Materialize Vibe feedback into the listener inputs used by the incumbent
* director. The projection marker and every write share the event's
* transaction, so a retry either sees the completed projection or performs
* it once; it can never double-count a completed/skip/dislike/kept signal.
*/
private async projectVibeFeedback(event: VibeEvent, client: PoolClient): Promise<void> {
if (!event.track_id || !['completed', 'skipped', 'disliked', 'kept'].includes(event.type)) return;
const projection = await client.query(
`INSERT INTO vibe_event_projections (event_id)
VALUES ($1)
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id`,
[event.id],
);
if (!projection.rows[0]) return;
const occurredAt = event.occurred_at?.toISOString?.() ?? new Date().toISOString();
switch (event.type) {
case 'completed':
await client.query(
`INSERT INTO play_history (user_id, track_id, completed, played_at)
VALUES ($1, $2, true, $3::timestamptz)`,
[event.user_id, event.track_id, occurredAt],
);
await client.query(
`UPDATE tracks
SET play_count = play_count + 1, last_played_at = $2::timestamptz
WHERE id = $1`,
[event.track_id, occurredAt],
);
await this.recordTrackEvidence({
user_id: event.user_id,
track_id: event.track_id,
signal: 'playback_completed',
profile: 'longterm',
weight: 0.10,
context: { vibe_event_id: event.id, session_id: event.session_id },
}, client);
break;
case 'skipped':
await client.query('UPDATE tracks SET skip_count = skip_count + 1 WHERE id = $1', [event.track_id]);
await client.query(
`INSERT INTO feedback (user_id, track_id, action, created_at)
VALUES ($1, $2, 'skipped', $3::timestamptz)`,
[event.user_id, event.track_id, occurredAt],
);
await this.recordTrackEvidence({
user_id: event.user_id,
track_id: event.track_id,
signal: 'skip_quick',
profile: 'negative',
weight: -0.20,
context: { vibe_event_id: event.id, session_id: event.session_id },
}, client);
break;
case 'disliked':
await client.query('UPDATE tracks SET dislike_count = dislike_count + 1 WHERE id = $1', [event.track_id]);
await client.query(
`INSERT INTO feedback (user_id, track_id, action, created_at)
VALUES ($1, $2, 'disliked', $3::timestamptz)`,
[event.user_id, event.track_id, occurredAt],
);
await this.recordTrackEvidence({
user_id: event.user_id,
track_id: event.track_id,
signal: 'hidden',
profile: 'negative',
weight: -0.60,
context: { vibe_event_id: event.id, session_id: event.session_id },
}, client);
break;
case 'kept':
await this.recordTrackEvidence({
user_id: event.user_id,
track_id: event.track_id,
signal: 'kept',
profile: 'longterm',
weight: 0.05,
context: { vibe_event_id: event.id, session_id: event.session_id },
}, client);
break;
}
}
/**
* Persist one complete revision of a session plan atomically. The caller
* supplies the monotonically increasing version; session-level scheduling
* will own version allocation when the director is migrated to this ledger.
*/
async publishVibePlan(params: {
sessionId: string;
userId: string;
/** Supplying one is for the first revision; otherwise allocate the next. */
version?: number;
reason: string;
stateSnapshot: Record<string, unknown>;
objectiveSnapshot: Record<string, unknown>;
items: Array<Omit<VibePlanItem, 'plan_version_id'>>;
}): Promise<VibePlan> {
return this.withTransaction(async (client) => {
// The session lock is also the concurrency boundary for starts/ends and
// plan revisions. In particular, a slow initial planner cannot publish
// into a session a newer start has already replaced.
const sessionRes = await client.query(
`SELECT id, status FROM vibe_sessions
WHERE id = $1 AND user_id = $2
FOR UPDATE`,
[params.sessionId, params.userId],
);
const session = sessionRes.rows[0] as Pick<VibeSession, 'id' | 'status'> | undefined;
if (!session) throw new Error('Vibe session was not found or is not owned by this user');
if (session.status !== 'active') {
throw new Error(`Cannot publish a plan for ${session.status} Vibe session`);
}
const version = params.version ?? Number((await client.query(
`SELECT COALESCE(MAX(version), 0) + 1 AS version
FROM vibe_plan_versions WHERE session_id = $1`,
[params.sessionId],
)).rows[0].version);
const header = await client.query(
`INSERT INTO vibe_plan_versions
(session_id, version, reason, state_snapshot, objective_snapshot)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb)
RETURNING *`,
[
params.sessionId,
version,
params.reason,
JSON.stringify(params.stateSnapshot),
JSON.stringify(params.objectiveSnapshot),
],
);
const planVersion = header.rows[0] as VibePlan | undefined;
if (!planVersion) throw new Error('Vibe plan could not be published');
for (const item of params.items) {
await client.query(
`INSERT INTO vibe_plan_items
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
[
planVersion.id, item.ordinal, item.track_id, item.slot_role,
item.candidate_source, item.score, JSON.stringify(item.score_breakdown),
JSON.stringify(item.explanation), item.committed,
],
);
}
// Header/items and this ledger event deliberately commit together. A
// client retry can therefore find either neither or the same canonical
// revision; it can never observe a published event without its plan.
await client.query(
`INSERT INTO vibe_events (session_id, user_id, type, occurred_at, payload)
VALUES ($1, $2, 'plan_published', NOW(), $3::jsonb)`,
[params.sessionId, params.userId, JSON.stringify({
planVersion: planVersion.version,
planVersionId: planVersion.id,
reason: params.reason,
itemCount: params.items.length,
feedbackEventId: params.objectiveSnapshot.feedbackEventId ?? null,
})],
);
await client.query(
`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`,
[params.sessionId],
);
return {
...planVersion,
items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })),
};
});
}
async persistVibePlan(params: {
sessionId: string;
userId: string;
@@ -1696,6 +1964,115 @@ export class DbService {
});
}
/** Allocate and persist the next immutable revision while holding the session lock. */
async persistNextVibePlan(params: Omit<Parameters<DbService['persistVibePlan']>[0], 'version'>): Promise<VibePlan> {
return this.withTransaction(async (client) => {
const session = await client.query(
`SELECT id, status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
[params.sessionId, params.userId]
);
if (!session.rows[0]) throw new Error('Vibe session was not found or is not owned by this user');
if ((session.rows[0] as Pick<VibeSession, 'status'>).status !== 'active') {
throw new Error(`Cannot record a new event for ${(session.rows[0] as Pick<VibeSession, 'status'>).status} Vibe session`);
}
const versionResult = await client.query(
`SELECT COALESCE(MAX(version), 0) + 1 AS version FROM vibe_plan_versions WHERE session_id = $1`,
[params.sessionId]
);
const version = Number(versionResult.rows[0].version);
const header = await client.query(
`INSERT INTO vibe_plan_versions
(session_id, version, reason, state_snapshot, objective_snapshot)
VALUES ($1, $2, $3, $4::jsonb, $5::jsonb) RETURNING *`,
[params.sessionId, version, params.reason, JSON.stringify(params.stateSnapshot), JSON.stringify(params.objectiveSnapshot)]
);
const planVersion = header.rows[0] as VibePlan;
for (const item of params.items) {
await client.query(
`INSERT INTO vibe_plan_items
(plan_version_id, ordinal, track_id, slot_role, candidate_source, score, score_breakdown, explanation, committed)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9)`,
[planVersion.id, item.ordinal, item.track_id, item.slot_role, item.candidate_source,
item.score, JSON.stringify(item.score_breakdown), JSON.stringify(item.explanation), item.committed]
);
}
return { ...planVersion, items: params.items.map((item) => ({ ...item, plan_version_id: planVersion.id })) };
});
}
/**
* Atomically commit one item from the latest plan. A version-aware request
* acts as an idempotency key: retrying the same expected version receives
* the original item, while a replaced plan is returned as stale without
* committing any old item. Calls without an expected version preserve the
* original legacy cursor behaviour.
*/
async serveNextVibePlanItem(
sessionId: string,
userId: string,
expectedPlanVersion?: number,
): Promise<{ item: VibePlanItem | null; stale: boolean }> {
return this.withTransaction(async (client) => {
const session = await client.query(
`SELECT status FROM vibe_sessions WHERE id = $1 AND user_id = $2 FOR UPDATE`,
[sessionId, userId]
);
const row = session.rows[0] as Pick<VibeSession, 'status'> | undefined;
if (!row) throw new Error('Vibe session was not found or is not owned by this user');
if (row.status !== 'active') throw new Error(`Cannot record a new event for ${row.status} Vibe session`);
const latest = await client.query(
`SELECT id, version FROM vibe_plan_versions WHERE session_id = $1 ORDER BY version DESC LIMIT 1 FOR UPDATE`,
[sessionId],
);
const plan = latest.rows[0] as Pick<VibePlan, 'id' | 'version'> | undefined;
if (!plan) return { item: null, stale: false };
if (expectedPlanVersion !== undefined && expectedPlanVersion !== plan.version) {
return { item: null, stale: true };
}
if (expectedPlanVersion !== undefined) {
const prior = await client.query(
`SELECT i.*
FROM vibe_events e
JOIN vibe_plan_items i
ON i.plan_version_id = (e.payload->>'planVersionId')::uuid
AND i.ordinal = (e.payload->>'ordinal')::integer
WHERE e.session_id = $1
AND e.type = 'track_served'
AND e.payload->>'planVersion' = $2::text
ORDER BY e.occurred_at ASC
LIMIT 1`,
[sessionId, expectedPlanVersion],
);
const servedPreviously = prior.rows[0] as VibePlanItem | undefined;
if (servedPreviously) return { item: servedPreviously, stale: false };
}
const item = await client.query(
`WITH next_item AS (
SELECT i.plan_version_id, i.ordinal FROM vibe_plan_items i
WHERE i.plan_version_id = $2 AND NOT i.committed ORDER BY i.ordinal ASC LIMIT 1 FOR UPDATE
)
UPDATE vibe_plan_items i SET committed = true
FROM next_item n WHERE i.plan_version_id = n.plan_version_id AND i.ordinal = n.ordinal
RETURNING i.*`,
[sessionId, plan.id]
);
const served = item.rows[0] as VibePlanItem | undefined;
if (!served) return { item: null, stale: false };
await client.query(
`INSERT INTO vibe_events (session_id, user_id, track_id, type, occurred_at, payload)
VALUES ($1, $2, $3, 'track_served', NOW(), $4::jsonb)`,
[sessionId, userId, served.track_id, JSON.stringify({
planVersion: plan.version,
planVersionId: served.plan_version_id,
ordinal: served.ordinal,
})]
);
await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]);
return { item: served, stale: false };
});
}
/** Read a specific plan revision, or the latest revision for a session. */
async getVibePlan(sessionId: string, userId: string, version?: number): Promise<VibePlan | null> {
const res = await this.pgClient.query(
@@ -1745,6 +2122,41 @@ export class DbService {
return plan;
}
/** Return the replacement revision caused by a material feedback event. */
async getVibePlanForFeedbackEvent(sessionId: string, userId: string, eventId: string): Promise<VibePlan | null> {
const res = await this.pgClient.query(
`SELECT version
FROM vibe_plan_versions p
JOIN vibe_sessions s ON s.id = p.session_id
WHERE p.session_id = $1
AND s.user_id = $2
AND p.objective_snapshot->>'feedbackEventId' = $3
ORDER BY p.version DESC
LIMIT 1`,
[sessionId, userId, eventId],
);
const version = res.rows[0]?.version as number | undefined;
return version === undefined ? null : this.getVibePlan(sessionId, userId, version);
}
/**
* Tracks exposed by a durable session are never eligible for another
* revision of that same session. This includes served items and every
* explicit feedback target, not just completed play history.
*/
async getVibeSessionTrackIds(sessionId: string, userId: string): Promise<string[]> {
const res = await this.pgClient.query(
`SELECT DISTINCT e.track_id
FROM vibe_events e
JOIN vibe_sessions s ON s.id = e.session_id
WHERE e.session_id = $1
AND s.user_id = $2
AND e.track_id IS NOT NULL`,
[sessionId, userId],
);
return res.rows.map((row: { track_id: string }) => row.track_id);
}
/**
* Upsert a diversity budget for a user.
*/