feat(vibe): reconcile mutable session previews in playback
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user