refactor(vibe): simplify durable session flow
Typecheck / typecheck (backend) (push) Has been cancelled
Typecheck / typecheck (workers) (push) Has been cancelled
Typecheck / typecheck (backend) (pull_request) Has been cancelled
Typecheck / typecheck (workers) (pull_request) Has been cancelled

This commit is contained in:
kami
2026-08-03 12:44:08 +04:00
parent 61a1373ca9
commit 9eba247a58
16 changed files with 191 additions and 520 deletions
+2 -62
View File
@@ -6,7 +6,6 @@ import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { Pool, PoolClient } from 'pg';
import { SearchService } from './search.service.js';
import { normalizeVibeContext, normalizeVibeEventPayload } from './vibe-context.service.js';
/** Anything with a `.query()` — either the shared Pool or a checked-out client. */
type Queryable = Pool | PoolClient;
@@ -1515,18 +1514,12 @@ export class DbService {
userId: string;
policyVersion: string;
seedTrackId?: string | null;
context?: Record<string, unknown>;
profile?: {
goals: Record<string, unknown>;
explorationCoefficient: number;
discoveryRadius: number;
};
}): Promise<VibeSession> {
// DbService is also used directly by workers and migrations. Keep the
// durable storage boundary canonical even when callers bypass the HTTP
// coordinator, so opaque or precise client telemetry can never become
// session context.
const canonicalContext = normalizeVibeContext(params.context ?? {});
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.
@@ -1568,7 +1561,7 @@ export class DbService {
[
params.userId,
params.seedTrackId ?? null,
JSON.stringify(canonicalContext),
'{}',
params.policyVersion,
JSON.stringify(params.profile?.goals ?? { type: 'discovery', target: 1, progress: 0 }),
params.profile?.explorationCoefficient ?? 0.3,
@@ -1615,29 +1608,6 @@ export class DbService {
return (res.rows[0] as VibeSessionProfile | undefined) ?? null;
}
/** Replace only the coarse, sanitised context attached to an active session.
* The immutable context_changed event remains the audit trail. */
async updateVibeSessionContext(sessionId: string, userId: string, context: Record<string, unknown>): Promise<void> {
const canonicalContext = normalizeVibeContext(context);
await this.withTransaction(async client => {
const updated = await client.query(
`UPDATE vibe_sessions SET context = $3::jsonb, last_event_at = NOW()
WHERE id = $1 AND user_id = $2 AND status = 'active'
RETURNING id`,
[sessionId, userId, JSON.stringify(canonicalContext)],
);
if (!updated.rows[0]) throw new Error('Vibe session was not found or is not owned by this user');
await client.query(
`UPDATE session_state
SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'),
state_vector = state_vector || jsonb_build_object('context', $3::jsonb),
last_interaction = NOW()
WHERE session_id = $1 AND user_id = $2`,
[sessionId, userId, JSON.stringify(canonicalContext)],
);
});
}
/**
* Project unknown-track feedback into the session exploration controls.
* This deliberately runs behind its own projection marker: the immutable
@@ -1825,10 +1795,6 @@ export class DbService {
durationMs?: number | null;
payload?: Record<string, unknown>;
}): Promise<RecordedVibeEvent> {
// This service is also called by jobs and tests which bypass the HTTP
// route. Preserve the context privacy boundary at the final point before
// an immutable ledger write.
const payload = normalizeVibeEventPayload(params.type, params.payload);
return this.withTransaction(async (client) => {
// A session-row lock serializes both event writes and terminal state
// transitions. In particular, it avoids the READ COMMITTED CTE snapshot
@@ -1880,7 +1846,7 @@ export class DbService {
occurredAt,
params.positionMs ?? null,
params.durationMs ?? null,
JSON.stringify(payload ?? {}),
JSON.stringify(params.payload ?? {}),
]
);
const event = insertRes.rows[0] as VibeEvent | undefined;
@@ -1889,8 +1855,6 @@ export class DbService {
}
await this.projectVibeFeedback(event, client);
await this.projectVibeContextChanged(event, client);
await client.query(
`UPDATE vibe_sessions
SET last_event_at = GREATEST(last_event_at, $2::timestamptz)
@@ -1901,30 +1865,6 @@ export class DbService {
});
}
/** Apply the context projection in the same transaction as its *inserted*
* ledger event. A client-event retry returns before this method, so its body
* can never overwrite session state with a different context. */
private async projectVibeContextChanged(event: VibeEvent, client: PoolClient): Promise<void> {
if (event.type !== 'context_changed') return;
const context = event.payload?.context;
if (!context || typeof context !== 'object' || Array.isArray(context)) return;
const canonicalContext = context as Record<string, unknown>;
await client.query(
`UPDATE vibe_sessions
SET context = $3::jsonb
WHERE id = $1 AND user_id = $2 AND status = 'active'`,
[event.session_id, event.user_id, JSON.stringify(canonicalContext)],
);
await client.query(
`UPDATE session_state
SET context = COALESCE($3::jsonb->>'activity', $3::jsonb->>'device'),
state_vector = state_vector || jsonb_build_object('context', $3::jsonb),
last_interaction = NOW()
WHERE session_id = $1 AND user_id = $2`,
[event.session_id, event.user_id, JSON.stringify(canonicalContext)],
);
}
/**
* Materialize Vibe feedback into the listener inputs used by the incumbent
* director. The projection marker and every write share the event's