feat(vibe): reconcile mutable session previews in playback

This commit is contained in:
kami
2026-08-01 23:47:02 +04:00
parent 51ef7c84db
commit 57df1cfe9f
20 changed files with 1311 additions and 312 deletions
+147
View File
@@ -2073,6 +2073,153 @@ export class DbService {
});
}
/**
* Advance an already-served item which the player could not resolve (for
* example, its file was hidden after the revision was published). Unlike a
* normal version-aware /next retry, this has a distinct client event id and
* therefore intentionally moves beyond the item previously served for that
* revision. The event records both the rejected item and the replacement so
* a lost response can be retried without consuming another plan item.
*/
async advancePastUnplayableVibePlanItem(
sessionId: string,
userId: string,
input: {
expectedPlanVersion: number;
planVersionId: string;
ordinal: number;
trackId: string;
eventId: string;
},
): 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 sessionRow = session.rows[0] as Pick<VibeSession, 'status'> | undefined;
if (!sessionRow) throw new Error('Vibe session was not found or is not owned by this user');
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 (plan.version !== input.expectedPlanVersion || plan.id !== input.planVersionId) {
return { item: null, stale: true };
}
// Idempotency is scoped to this explicit advancement operation, rather
// than overloading the version-aware /next retry which must keep
// returning the originally served item.
const prior = await client.query(
`SELECT type, payload
FROM vibe_events
WHERE session_id = $1 AND client_event_id = $2::uuid`,
[sessionId, input.eventId],
);
const priorEvent = prior.rows[0] as Pick<VibeEvent, 'type' | 'payload'> | undefined;
if (priorEvent && priorEvent.type !== 'playback_error') {
throw new Error('Vibe client event id was already used for a different event');
}
const priorPayload = priorEvent?.payload as Record<string, unknown> | undefined;
if (priorPayload) {
if (priorPayload.planVersionId !== input.planVersionId
|| priorPayload.ordinal !== input.ordinal
|| priorPayload.trackId !== input.trackId) {
throw new Error('Vibe playback-error event does not match the served plan item');
}
const advancedTo = priorPayload.advancedTo as { planVersionId?: unknown; ordinal?: unknown } | null | undefined;
if (!advancedTo || typeof advancedTo.planVersionId !== 'string' || !Number.isInteger(advancedTo.ordinal)) {
return { item: null, stale: false };
}
const replacement = await client.query(
`SELECT * FROM vibe_plan_items WHERE plan_version_id = $1 AND ordinal = $2`,
[advancedTo.planVersionId, advancedTo.ordinal],
);
return { item: (replacement.rows[0] as VibePlanItem | undefined) ?? null, stale: false };
}
if (sessionRow.status !== 'active') {
throw new Error(`Cannot record a new event for ${sessionRow.status} Vibe session`);
}
// A client may advance only the cursor it was just served. Checking for
// any historical serve event would let an old version-aware /next
// response consume whichever future item happens to be uncommitted.
const current = await client.query(
`SELECT i.track_id, i.ordinal
FROM vibe_events e
JOIN vibe_plan_items i
ON i.plan_version_id = $3::uuid
AND i.ordinal = (e.payload->>'ordinal')::integer
AND i.track_id = e.track_id
WHERE e.session_id = $1
AND e.type = 'track_served'
AND e.payload->>'planVersion' = $2::text
AND e.payload->>'planVersionId' = $3
ORDER BY i.ordinal DESC
LIMIT 1`,
[sessionId, input.expectedPlanVersion, input.planVersionId],
);
const currentCursor = current.rows[0] as Pick<VibePlanItem, 'track_id' | 'ordinal'> | undefined;
if (currentCursor?.track_id !== input.trackId || currentCursor.ordinal !== input.ordinal) {
throw new Error('Vibe plan item is not the current served cursor for this session revision');
}
const payload = {
kind: 'unplayable_plan_item',
planVersion: input.expectedPlanVersion,
planVersionId: input.planVersionId,
ordinal: input.ordinal,
trackId: input.trackId,
};
const playbackError = await client.query(
`INSERT INTO vibe_events (client_event_id, session_id, user_id, track_id, type, occurred_at, payload)
VALUES ($1::uuid, $2, $3, $4::uuid, 'playback_error', NOW(), $5::jsonb)
RETURNING id`,
[input.eventId, sessionId, userId, input.trackId, JSON.stringify(payload)],
);
const eventId = playbackError.rows[0]?.id as string | undefined;
if (!eventId) throw new Error('Vibe playback-error event could not be recorded');
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 = $1 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.*`,
[plan.id],
);
const replacement = item.rows[0] as VibePlanItem | undefined;
if (replacement) {
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, replacement.track_id, JSON.stringify({
planVersion: plan.version,
planVersionId: replacement.plan_version_id,
ordinal: replacement.ordinal,
})],
);
}
await client.query(
`UPDATE vibe_events SET payload = $2::jsonb WHERE id = $1`,
[eventId, JSON.stringify({
...payload,
advancedTo: replacement
? { planVersionId: replacement.plan_version_id, ordinal: replacement.ordinal }
: null,
})],
);
await client.query(`UPDATE vibe_sessions SET last_event_at = NOW() WHERE id = $1`, [sessionId]);
return { item: replacement ?? null, 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(