Merge pull request 'Feat/vibe v2 session director' (#4) from feat/vibe-v2-session-director into master
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
@@ -39,6 +39,11 @@ A high-performance, distributed music orchestration and recommendation platform.
|
||||
- Docker & Docker Compose
|
||||
|
||||
### Running Locally
|
||||
|
||||
Vibe uses the same per-user identity convention as the rest of the API:
|
||||
`x-user-id` when supplied, otherwise the local default user. Each user's Vibe
|
||||
session and listening history are isolated from other users.
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
+7
-2
@@ -14,9 +14,10 @@ import quarantineRoutes from './routes/quarantine.routes.js';
|
||||
import settingsRoutes from './routes/settings.routes.js';
|
||||
import graphRoutes from './routes/graph.routes.js';
|
||||
import { SessionDirector } from './services/session-director.service.js';
|
||||
import v2Routes from './routes/v2.routes.js';
|
||||
import vibeSessionsRoutes from './routes/vibe-sessions.routes.js';
|
||||
import discoveryRoutes from './routes/discovery.routes.js';
|
||||
import imagesRoutes from './routes/images.routes.js';
|
||||
import { VibeSessionCoordinator } from './services/vibe-session-coordinator.service.js';
|
||||
|
||||
export interface AppConfig {
|
||||
port: number;
|
||||
@@ -158,8 +159,12 @@ export async function buildApp(config: AppConfig) {
|
||||
fastify.register(graphRoutes, { prefix: '/api', dbService });
|
||||
|
||||
const sessionDirector = new SessionDirector(dbService);
|
||||
const vibeSessionCoordinator = new VibeSessionCoordinator(dbService, sessionDirector);
|
||||
|
||||
fastify.register(v2Routes, { prefix: '/api', dbService, sessionDirector });
|
||||
fastify.register(vibeSessionsRoutes, {
|
||||
prefix: '/api',
|
||||
coordinator: vibeSessionCoordinator,
|
||||
});
|
||||
fastify.register(discoveryRoutes, { prefix: '/api', dbService, jobService });
|
||||
// ponytail: /api/test/enqueue-job (manual job-enqueue test endpoint) removed —
|
||||
// nothing in the deployed app or its tests called it, and deployment never
|
||||
|
||||
@@ -20,3 +20,42 @@ describe('track release-date migration', () => {
|
||||
expect(migration!.sql).toContain('WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vibe durable session migration', () => {
|
||||
const migration = MIGRATIONS.find(
|
||||
({ id }) => id === '20260801_vibe_session_persistence',
|
||||
);
|
||||
|
||||
it('creates the event ledger, retry key, and revisioned plan tables', () => {
|
||||
expect(migration).toBeDefined();
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_sessions');
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_events');
|
||||
expect(migration!.sql).toContain('UNIQUE (session_id, client_event_id)');
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_versions');
|
||||
expect(migration!.sql).toContain('UNIQUE (session_id, version)');
|
||||
expect(migration!.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_plan_items');
|
||||
expect(migration!.sql).toContain('UNIQUE (plan_version_id, track_id)');
|
||||
});
|
||||
|
||||
it('indexes session and event reads along their required time axes', () => {
|
||||
expect(migration!.sql).toContain('idx_vibe_sessions_user_last_event');
|
||||
expect(migration!.sql).toContain('idx_vibe_events_session_occurred');
|
||||
expect(migration!.sql).toContain('idx_vibe_events_user_occurred');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Vibe context memory migration', () => {
|
||||
it('adds bounded session exploration state and exactly-once projection storage', () => {
|
||||
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_context_memory_exploration');
|
||||
expect(migration?.sql).toContain('CREATE TABLE IF NOT EXISTS vibe_session_profiles');
|
||||
expect(migration?.sql).toContain('exploration_coefficient');
|
||||
expect(migration?.sql).toContain('vibe_session_feedback_projections');
|
||||
});
|
||||
|
||||
it('backfills profiles for durable sessions created before context memory', () => {
|
||||
const migration = MIGRATIONS.find(({ id }) => id === '20260802_vibe_session_profile_backfill');
|
||||
expect(migration?.sql).toContain('INSERT INTO vibe_session_profiles');
|
||||
expect(migration?.sql).toContain('SELECT id, user_id FROM vibe_sessions');
|
||||
expect(migration?.sql).toContain('ON CONFLICT (session_id) DO NOTHING');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -644,4 +644,119 @@ export const MIGRATIONS: Migration[] = [
|
||||
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Vibe v2 needs an immutable event ledger and revisioned plans. The
|
||||
// legacy session_state table remains in place as a derived-state cache so
|
||||
// existing v2 endpoints can migrate independently.
|
||||
id: '20260801_vibe_session_persistence',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
|
||||
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
policy_version TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
|
||||
ON vibe_sessions (user_id, last_event_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_event_id UUID,
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
position_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (session_id, client_event_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
|
||||
ON vibe_events (session_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
|
||||
ON vibe_events (user_id, occurred_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
reason TEXT NOT NULL,
|
||||
state_snapshot JSONB NOT NULL,
|
||||
objective_snapshot JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_items (
|
||||
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
||||
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
slot_role TEXT,
|
||||
candidate_source TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
score_breakdown JSONB NOT NULL,
|
||||
explanation JSONB NOT NULL,
|
||||
committed BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (plan_version_id, ordinal),
|
||||
UNIQUE (plan_version_id, track_id)
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// A material Vibe event is projected into the legacy listener inputs in
|
||||
// the same transaction as its ledger write. This marker makes that bridge
|
||||
// auditable and exactly-once even when a client retries an event id.
|
||||
id: '20260801_vibe_event_projections',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_event_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// Session-specific exploration, goals, and deliberately lossy session
|
||||
// fingerprints are derived from the immutable Vibe ledger. Keeping them
|
||||
// separate from listener_beliefs prevents a transient session from
|
||||
// rewriting permanent taste.
|
||||
id: '20260802_vibe_context_memory_exploration',
|
||||
sql: `
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
||||
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
||||
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
||||
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
||||
discovery_radius REAL NOT NULL DEFAULT 0.38
|
||||
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
||||
ON vibe_session_profiles (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`,
|
||||
},
|
||||
{
|
||||
// The profile table was introduced after durable sessions. Backfill every
|
||||
// pre-existing session before feedback can claim its exactly-once marker;
|
||||
// newly created sessions receive their context-derived initial goals in
|
||||
// createVibeSession's transaction.
|
||||
id: '20260802_vibe_session_profile_backfill',
|
||||
sql: `
|
||||
INSERT INTO vibe_session_profiles (session_id, user_id)
|
||||
SELECT id, user_id FROM vibe_sessions
|
||||
ON CONFLICT (session_id) DO NOTHING;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -525,6 +525,101 @@ CREATE TABLE IF NOT EXISTS session_state (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_session_state_user ON session_state (user_id, last_interaction DESC);
|
||||
|
||||
-- Durable Vibe v2 session ledger. session_state remains a rebuildable cache for
|
||||
-- the existing director; these tables are the authoritative record for the
|
||||
-- next-generation, versioned planner.
|
||||
CREATE TABLE IF NOT EXISTS vibe_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
|
||||
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
policy_version TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
|
||||
ON vibe_sessions (user_id, last_event_at DESC);
|
||||
|
||||
-- Compact, derived memory for the session director. Fingerprints are a
|
||||
-- deliberately lossy description of session shape (not a track list) and are
|
||||
-- used only as a soft freshness signal against recent sessions.
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
||||
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
||||
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
||||
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
||||
discovery_radius REAL NOT NULL DEFAULT 0.38
|
||||
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
||||
ON vibe_session_profiles (user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_event_id UUID,
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
position_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (session_id, client_event_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
|
||||
ON vibe_events (session_id, occurred_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
|
||||
ON vibe_events (user_id, occurred_at DESC);
|
||||
|
||||
-- Exactly-once projection marker for material Vibe feedback. The immutable
|
||||
-- event remains authoritative; this row proves its effect was applied to the
|
||||
-- listener inputs without double-counting an idempotent client retry.
|
||||
CREATE TABLE IF NOT EXISTS vibe_event_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Separate from listener-belief projection because exploration is session
|
||||
-- state. It lets a failed post-event replan safely retry the exact same
|
||||
-- adaptation without counting the feedback twice.
|
||||
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
||||
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
||||
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL CHECK (version > 0),
|
||||
reason TEXT NOT NULL,
|
||||
state_snapshot JSONB NOT NULL,
|
||||
objective_snapshot JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vibe_plan_items (
|
||||
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
||||
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
slot_role TEXT,
|
||||
candidate_source TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
score_breakdown JSONB NOT NULL,
|
||||
explanation JSONB NOT NULL,
|
||||
committed BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (plan_version_id, ordinal),
|
||||
UNIQUE (plan_version_id, track_id)
|
||||
);
|
||||
|
||||
-- Diversity budgets for the session director's planner.
|
||||
CREATE TABLE IF NOT EXISTS diversity_budgets (
|
||||
user_id UUID NOT NULL,
|
||||
|
||||
@@ -194,3 +194,80 @@ export interface RepetitionRule {
|
||||
dimension: string;
|
||||
min_distance: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vibe v2 durable session ledger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const VIBE_SESSION_STATUSES = ['active', 'paused', 'ended', 'expired', 'replaced'] as const;
|
||||
export type VibeSessionStatus = (typeof VIBE_SESSION_STATUSES)[number];
|
||||
|
||||
export interface VibeSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
status: VibeSessionStatus;
|
||||
seed_track_id: string | null;
|
||||
context: Record<string, unknown>;
|
||||
policy_version: string;
|
||||
started_at: Date;
|
||||
last_event_at: Date;
|
||||
ended_at: Date | null;
|
||||
}
|
||||
|
||||
export interface VibeEvent {
|
||||
id: string;
|
||||
client_event_id: string | null;
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
track_id: string | null;
|
||||
type: string;
|
||||
occurred_at: Date;
|
||||
position_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RecordedVibeEvent {
|
||||
event: VibeEvent;
|
||||
/** False when a retried client_event_id returned the original event. */
|
||||
inserted: boolean;
|
||||
}
|
||||
|
||||
export interface VibePlanVersion {
|
||||
id: string;
|
||||
session_id: string;
|
||||
version: number;
|
||||
reason: string;
|
||||
state_snapshot: Record<string, unknown>;
|
||||
objective_snapshot: Record<string, unknown>;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface VibePlanItem {
|
||||
plan_version_id: string;
|
||||
ordinal: number;
|
||||
track_id: string;
|
||||
slot_role: string | null;
|
||||
candidate_source: string;
|
||||
score: number;
|
||||
score_breakdown: Record<string, unknown>;
|
||||
explanation: unknown;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
export interface VibePlan extends VibePlanVersion {
|
||||
items: VibePlanItem[];
|
||||
}
|
||||
|
||||
/** Derived session-director memory. The ledger remains authoritative; this
|
||||
* compact row makes fingerprints, bounded goals, and exploration state cheap
|
||||
* to read while planning. */
|
||||
export interface VibeSessionProfile {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
fingerprint: Record<string, unknown>;
|
||||
goals: Record<string, unknown>;
|
||||
exploration_coefficient: number;
|
||||
discovery_radius: number;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import Fastify from 'fastify';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import vibeSessionsRoutes, { VibeIdentityResolver } from './vibe-sessions.routes.js';
|
||||
import { VibeSessionLifecycleError } from '../services/vibe-session-coordinator.service.js';
|
||||
|
||||
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const USER_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
|
||||
function response() {
|
||||
return {
|
||||
sessionId: SESSION_ID, planVersion: 1, now: null, preview: [], state: {},
|
||||
replanned: false, replanReason: null,
|
||||
session: { id: SESSION_ID, status: 'active' },
|
||||
};
|
||||
}
|
||||
|
||||
async function appWithCoordinator(identityResolver?: VibeIdentityResolver) {
|
||||
const coordinator = {
|
||||
start: vi.fn().mockResolvedValue(response()),
|
||||
getPlan: vi.fn().mockResolvedValue(response()),
|
||||
appendEvent: vi.fn().mockResolvedValue({ ...response(), event: { id: 'event-1' }, idempotent: false }),
|
||||
end: vi.fn().mockResolvedValue(response()),
|
||||
serveNext: vi.fn().mockResolvedValue(response()),
|
||||
advancePastUnplayable: vi.fn().mockResolvedValue(response()),
|
||||
} as any;
|
||||
const app = Fastify();
|
||||
await app.register(vibeSessionsRoutes, { coordinator, ...(identityResolver ? { identityResolver } : {}) });
|
||||
await app.ready();
|
||||
return { app, coordinator };
|
||||
}
|
||||
|
||||
describe('durable Vibe session routes', () => {
|
||||
it('uses the caller identity so concurrent listeners receive separate sessions', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': USER_ID }, payload: {} });
|
||||
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.any(Object));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uses the existing application default when no user header is provided', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', payload: {},
|
||||
});
|
||||
expect(result.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000000', expect.any(Object));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('creates a session and validates event payloads before touching the coordinator', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const created = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { seedTrackId: TRACK_ID },
|
||||
});
|
||||
const invalidEvent = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { type: 'definitely-not-an-event' },
|
||||
});
|
||||
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ seedTrackId: TRACK_ID }));
|
||||
expect(invalidEvent.statusCode).toBe(400);
|
||||
expect(coordinator.appendEvent).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects server-only event types from the client event ledger', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
|
||||
payload: {
|
||||
type: 'track_served',
|
||||
trackId: TRACK_ID,
|
||||
payload: { planVersionId: '33333333-3333-4333-8333-333333333333', ordinal: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.json()).toEqual({ error: 'type must be a supported client Vibe event type' });
|
||||
expect(coordinator.appendEvent).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('returns a lifecycle conflict when an initial plan race ends or replaces the session', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator();
|
||||
coordinator.start.mockRejectedValueOnce(
|
||||
new VibeSessionLifecycleError('Cannot publish a plan for ended Vibe session'),
|
||||
);
|
||||
|
||||
const result = await app.inject({ method: 'POST', url: '/v2/vibe/sessions', payload: {} });
|
||||
|
||||
expect(result.statusCode).toBe(409);
|
||||
expect(result.json()).toEqual({
|
||||
error: 'Cannot publish a plan for ended Vibe session',
|
||||
code: 'VIBE_SESSION_NOT_ACTIVE',
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('passes a requested plan revision and validated idempotent event through to the coordinator', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const plan = await app.inject({
|
||||
method: 'GET', url: `/v2/vibe/sessions/${SESSION_ID}/plans?version=2`, headers: { 'x-user-id': 'spoofed' },
|
||||
});
|
||||
const event = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`, headers: { 'x-user-id': 'spoofed' },
|
||||
payload: { eventId: '33333333-3333-4333-8333-333333333333', type: 'completed', trackId: TRACK_ID, positionMs: 5_000 },
|
||||
});
|
||||
|
||||
expect(plan.statusCode).toBe(200);
|
||||
expect(coordinator.getPlan).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
|
||||
expect(event.statusCode).toBe(200);
|
||||
expect(coordinator.appendEvent).toHaveBeenCalledWith(USER_ID, SESSION_ID, expect.objectContaining({ type: 'completed', positionMs: 5_000 }));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('validates occurredAt and exposes owned resume and advance operations', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const resume = await app.inject({
|
||||
method: 'POST', url: '/v2/vibe/sessions', payload: { resumeSessionId: SESSION_ID },
|
||||
});
|
||||
const badTime = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/events`,
|
||||
payload: { type: 'completed', occurredAt: 'not-a-date' },
|
||||
});
|
||||
const next = await app.inject({ method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance` });
|
||||
expect(resume.statusCode).toBe(201);
|
||||
expect(coordinator.start).toHaveBeenCalledWith(USER_ID, expect.objectContaining({ resumeSessionId: SESSION_ID }));
|
||||
expect(badTime.statusCode).toBe(400);
|
||||
expect(next.statusCode).toBe(200);
|
||||
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('passes a version-aware advance request through and rejects an invalid expected version', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const valid = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 2 },
|
||||
});
|
||||
const invalid = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`, payload: { expectedPlanVersion: 0 },
|
||||
});
|
||||
|
||||
expect(valid.statusCode).toBe(200);
|
||||
expect(coordinator.serveNext).toHaveBeenCalledWith(USER_ID, SESSION_ID, 2);
|
||||
expect(invalid.statusCode).toBe(400);
|
||||
expect(coordinator.serveNext).toHaveBeenCalledTimes(1);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('uses the explicit versioned advancement protocol for a served unplayable item', async () => {
|
||||
const { app, coordinator } = await appWithCoordinator(() => USER_ID);
|
||||
const result = await app.inject({
|
||||
method: 'POST', url: `/v2/vibe/sessions/${SESSION_ID}/advance`,
|
||||
payload: {
|
||||
expectedPlanVersion: 2,
|
||||
unplayable: {
|
||||
eventId: '33333333-3333-4333-8333-333333333333',
|
||||
planVersionId: '44444444-4444-4444-8444-444444444444',
|
||||
ordinal: 0,
|
||||
trackId: TRACK_ID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.statusCode).toBe(200);
|
||||
expect(coordinator.advancePastUnplayable).toHaveBeenCalledWith(USER_ID, SESSION_ID, {
|
||||
expectedPlanVersion: 2,
|
||||
eventId: '33333333-3333-4333-8333-333333333333',
|
||||
planVersionId: '44444444-4444-4444-8444-444444444444',
|
||||
ordinal: 0,
|
||||
trackId: TRACK_ID,
|
||||
});
|
||||
expect(coordinator.serveNext).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import {
|
||||
VIBE_EVENT_TYPES,
|
||||
VibeSessionCoordinator,
|
||||
VibeSessionLifecycleError,
|
||||
VibeSessionNotFoundError,
|
||||
VibePlanNotFoundError,
|
||||
} from '../services/vibe-session-coordinator.service.js';
|
||||
|
||||
const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/i;
|
||||
const CLIENT_VIBE_EVENT_TYPES = VIBE_EVENT_TYPES.filter((type) => ![
|
||||
'session_started', 'session_resumed', 'session_ended', 'plan_published', 'track_served',
|
||||
].includes(type));
|
||||
|
||||
type Reply = { code: (statusCode: number) => { send: (payload: unknown) => unknown } };
|
||||
type Body = Record<string, unknown>;
|
||||
|
||||
/** This mirrors the rest of the application until authentication owns identity. */
|
||||
export type VibeIdentityResolver = (request: FastifyRequest) => string | null;
|
||||
|
||||
function isObject(value: unknown): value is Body {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validUuid(value: unknown): value is string {
|
||||
return value === DEFAULT_USER_ID || (typeof value === 'string' && UUID_RE.test(value));
|
||||
}
|
||||
|
||||
function requestUser(request: FastifyRequest, resolveIdentity?: VibeIdentityResolver): string | null {
|
||||
const resolved = resolveIdentity?.(request);
|
||||
if (resolved !== undefined) return validUuid(resolved) ? resolved : null;
|
||||
const header = request.headers['x-user-id'];
|
||||
const userId = typeof header === 'string' && header ? header : DEFAULT_USER_ID;
|
||||
return validUuid(userId) ? userId : null;
|
||||
}
|
||||
|
||||
function validOccurredAt(value: unknown): value is string {
|
||||
return typeof value === 'string' && ISO_TIMESTAMP_RE.test(value) && !Number.isNaN(Date.parse(value));
|
||||
}
|
||||
|
||||
function validationError(reply: Reply, message: string) {
|
||||
return reply.code(400).send({ error: message });
|
||||
}
|
||||
|
||||
function sessionIdFrom(request: FastifyRequest, reply: Reply): string | null {
|
||||
const { sessionId } = request.params as { sessionId: string };
|
||||
return validUuid(sessionId) ? sessionId : (validationError(reply, 'sessionId must be a UUID'), null);
|
||||
}
|
||||
|
||||
function parseStart(body: unknown):
|
||||
| { seedTrackId?: string; resumeSessionId?: string }
|
||||
| { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
if (input.resumeSessionId !== undefined && !validUuid(input.resumeSessionId)) return { error: 'resumeSessionId must be a UUID' };
|
||||
if (input.seedTrackId !== undefined && !validUuid(input.seedTrackId)) return { error: 'seedTrackId must be a UUID' };
|
||||
if (input.resumeSessionId !== undefined && input.seedTrackId !== undefined) return { error: 'resumeSessionId cannot be combined with seedTrackId' };
|
||||
return { seedTrackId: input.seedTrackId as string | undefined, resumeSessionId: input.resumeSessionId as string | undefined };
|
||||
}
|
||||
|
||||
function parseEvent(body: unknown):
|
||||
| { type: typeof CLIENT_VIBE_EVENT_TYPES[number]; eventId?: string; trackId?: string; occurredAt?: Date; positionMs?: number; durationMs?: number; payload?: Body }
|
||||
| { error: string } {
|
||||
if (!isObject(body)) return { error: 'event body must be an object' };
|
||||
if (!CLIENT_VIBE_EVENT_TYPES.includes(body.type as typeof CLIENT_VIBE_EVENT_TYPES[number])) return { error: 'type must be a supported client Vibe event type' };
|
||||
if (body.eventId !== undefined && !validUuid(body.eventId)) return { error: 'eventId must be a UUID' };
|
||||
if (body.trackId !== undefined && !validUuid(body.trackId)) return { error: 'trackId must be a UUID' };
|
||||
if (body.occurredAt !== undefined && !validOccurredAt(body.occurredAt)) return { error: 'occurredAt must be an ISO-8601 timestamp' };
|
||||
if (body.positionMs !== undefined && (typeof body.positionMs !== 'number' || !Number.isInteger(body.positionMs) || body.positionMs < 0)) return { error: 'positionMs must be a non-negative integer' };
|
||||
if (body.durationMs !== undefined && (typeof body.durationMs !== 'number' || !Number.isInteger(body.durationMs) || body.durationMs < 0)) return { error: 'durationMs must be a non-negative integer' };
|
||||
if (body.payload !== undefined && !isObject(body.payload)) return { error: 'payload must be an object' };
|
||||
return {
|
||||
type: body.type as typeof CLIENT_VIBE_EVENT_TYPES[number],
|
||||
eventId: body.eventId as string | undefined,
|
||||
trackId: body.trackId as string | undefined,
|
||||
occurredAt: body.occurredAt === undefined ? undefined : new Date(body.occurredAt as string),
|
||||
positionMs: body.positionMs as number | undefined,
|
||||
durationMs: body.durationMs as number | undefined,
|
||||
payload: body.payload as Body | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAdvance(body: unknown):
|
||||
| { expectedPlanVersion?: number; unplayable?: { eventId: string; planVersionId: string; ordinal: number; trackId: string } }
|
||||
| { error: string } {
|
||||
const input = isObject(body) ? body : {};
|
||||
const version = input.expectedPlanVersion;
|
||||
if (version !== undefined && (typeof version !== 'number' || !Number.isInteger(version) || version < 1)) return { error: 'expectedPlanVersion must be a positive integer' };
|
||||
if (input.unplayable === undefined) return { expectedPlanVersion: version as number | undefined };
|
||||
if (!isObject(input.unplayable)) return { error: 'unplayable must be an object' };
|
||||
const unplayable = input.unplayable;
|
||||
if (version === undefined) return { error: 'expectedPlanVersion is required when advancing an unplayable item' };
|
||||
if (!validUuid(unplayable.eventId) || !validUuid(unplayable.planVersionId) || !validUuid(unplayable.trackId) || typeof unplayable.ordinal !== 'number' || !Number.isInteger(unplayable.ordinal) || unplayable.ordinal < 0) {
|
||||
return { error: 'unplayable requires UUID eventId, planVersionId, trackId and a non-negative integer ordinal' };
|
||||
}
|
||||
return { expectedPlanVersion: version as number, unplayable: unplayable as { eventId: string; planVersionId: string; ordinal: number; trackId: string } };
|
||||
}
|
||||
|
||||
export default async function vibeSessionsRoutes(
|
||||
fastify: FastifyInstance,
|
||||
options: { coordinator: VibeSessionCoordinator; identityResolver?: VibeIdentityResolver },
|
||||
) {
|
||||
const { coordinator, identityResolver } = options;
|
||||
const userFor = (request: FastifyRequest, reply: Reply) => {
|
||||
const userId = requestUser(request, identityResolver);
|
||||
return userId ? userId : (validationError(reply, 'x-user-id must be a UUID'), null);
|
||||
};
|
||||
|
||||
fastify.post('/v2/vibe/sessions', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const input = parseStart(request.body);
|
||||
if (!userId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.code(201).send(await coordinator.start(userId, input));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.get('/v2/vibe/sessions/:sessionId/plans', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const { version } = request.query as { version?: string };
|
||||
const parsedVersion = version === undefined ? undefined : Number(version);
|
||||
if (!userId || !sessionId) return;
|
||||
if (version !== undefined && (!Number.isInteger(parsedVersion) || parsedVersion! < 1)) return validationError(reply, 'version must be a positive integer');
|
||||
try {
|
||||
return reply.send(await coordinator.getPlan(userId, sessionId, parsedVersion));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/events', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const input = parseEvent(request.body);
|
||||
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.send(await coordinator.appendEvent(userId, sessionId, input));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/end', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
if (!userId || !sessionId) return;
|
||||
try {
|
||||
return reply.send(await coordinator.end(userId, sessionId));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
fastify.post('/v2/vibe/sessions/:sessionId/advance', async (request, reply) => {
|
||||
const userId = userFor(request, reply);
|
||||
const sessionId = sessionIdFrom(request, reply);
|
||||
const input = parseAdvance(request.body);
|
||||
if (!userId || !sessionId || 'error' in input) return 'error' in input ? validationError(reply, input.error) : undefined;
|
||||
try {
|
||||
return reply.send(input.unplayable
|
||||
? await coordinator.advancePastUnplayable(userId, sessionId, { expectedPlanVersion: input.expectedPlanVersion!, ...input.unplayable })
|
||||
: input.expectedPlanVersion === undefined
|
||||
? await coordinator.serveNext(userId, sessionId)
|
||||
: await coordinator.serveNext(userId, sessionId, input.expectedPlanVersion));
|
||||
} catch (error) {
|
||||
return sendCoordinatorError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sendCoordinatorError(reply: Reply, error: unknown) {
|
||||
if (error instanceof VibeSessionNotFoundError || error instanceof VibePlanNotFoundError) return reply.code(404).send({ error: error.message });
|
||||
if (error instanceof VibeSessionLifecycleError) return reply.code(409).send({ error: error.message, code: 'VIBE_SESSION_NOT_ACTIVE' });
|
||||
throw error;
|
||||
}
|
||||
@@ -7,7 +7,585 @@ function makeService(): { service: DbService; mockQuery: ReturnType<typeof vi.fn
|
||||
return { service, mockQuery };
|
||||
}
|
||||
|
||||
function makeTransactionalService(): { service: DbService; poolQuery: ReturnType<typeof vi.fn>; clientQuery: ReturnType<typeof vi.fn> } {
|
||||
const poolQuery = vi.fn();
|
||||
const clientQuery = vi.fn();
|
||||
const service = new DbService({
|
||||
query: poolQuery,
|
||||
connect: vi.fn().mockResolvedValue({ query: clientQuery, release: vi.fn() }),
|
||||
} as any);
|
||||
return { service, poolQuery, clientQuery };
|
||||
}
|
||||
|
||||
describe('DbService v2 methods', () => {
|
||||
describe('durable Vibe sessions', () => {
|
||||
it('projects unfamiliar feedback into exploration exactly once behind its own marker', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', session_id: 'session-1', user_id: 'user-1', track_id: 'track-1',
|
||||
type: 'completed', occurred_at: new Date(), client_event_id: null,
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
} as any;
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert/backfill
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: event.id }] }) // session feedback marker
|
||||
.mockResolvedValueOnce({ rows: [{ familiar: false }] }) // pre-event familiarity
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'evidence-1' }] }) // evidence
|
||||
.mockResolvedValueOnce({ rowCount: 1 }) // discovery belief
|
||||
.mockResolvedValueOnce({ rows: [] }) // no artist/genre targets
|
||||
.mockResolvedValueOnce({ rows: [] }) // no audio targets
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
exploration_coefficient: 0.36, discovery_radius: 0.434,
|
||||
goals: { type: 'familiar', target: 1, progress: 1 },
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // session_state projection
|
||||
.mockResolvedValueOnce({ rows: [] }) // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // profile upsert retry
|
||||
.mockResolvedValueOnce({ rows: [] }) // marker conflict
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT retry
|
||||
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
await service.projectVibeSessionFeedback(event);
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_session_profiles');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain('vibe_session_feedback_projections');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('EXISTS (SELECT 1 FROM play_history');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('INSERT INTO evidence');
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('exploration_coefficient');
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('ELSE goals END');
|
||||
expect(clientQuery.mock.calls[9][1][4]).toBe(JSON.stringify({ type: 'familiar', target: 1, progress: 1 }));
|
||||
expect(clientQuery.mock.calls.filter(([sql]) => String(sql).includes('INSERT INTO evidence'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates, reads, and ends sessions scoped to their user', async () => {
|
||||
const { service, poolQuery, clientQuery } = makeTransactionalService();
|
||||
const session = {
|
||||
id: 'session-1', user_id: 'user-1', status: 'active', seed_track_id: null,
|
||||
context: { activity: 'focus' }, policy_version: 'v2.1',
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
poolQuery
|
||||
.mockResolvedValueOnce({ rows: [session] })
|
||||
.mockResolvedValueOnce({ rows: [{ ...session, status: 'ended' }] });
|
||||
|
||||
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' })).resolves.toEqual(session);
|
||||
await expect(service.getVibeSession('session-1', 'user-1')).resolves.toEqual(session);
|
||||
await expect(service.endVibeSession('session-1', 'user-1')).resolves.toMatchObject({ status: 'ended' });
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('pg_advisory_xact_lock');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("status = 'active' FOR UPDATE");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('INSERT INTO vibe_sessions');
|
||||
expect(clientQuery.mock.calls[3][1]).toEqual(expect.arrayContaining([
|
||||
'user-1', null, expect.any(String), 'v2.1',
|
||||
JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]));
|
||||
expect(JSON.parse(clientQuery.mock.calls[3][1][2])).toEqual({});
|
||||
expect(poolQuery.mock.calls[0][0]).toContain('id = $1 AND user_id = $2');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('COALESCE(ended_at, NOW())');
|
||||
expect(poolQuery.mock.calls[1][0]).toContain('CASE WHEN ended_at IS NULL THEN $3 ELSE status END');
|
||||
});
|
||||
|
||||
it('starts sessions without inventing client context', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const session = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // active-session lock
|
||||
.mockResolvedValueOnce({ rows: [session] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' });
|
||||
const insertParameters = clientQuery.mock.calls[3][1];
|
||||
expect(insertParameters.slice(0, 2)).toEqual(['user-1', null]);
|
||||
expect(JSON.parse(insertParameters[2])).toEqual({});
|
||||
expect(insertParameters.slice(3)).toEqual([
|
||||
'v2.1', JSON.stringify({ type: 'discovery', target: 1, progress: 0 }), 0.3, 0.38,
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces an owned active session and writes its terminal event before starting another', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = { id: 'session-2', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // user advisory lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // active lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1' }] }) // replace
|
||||
.mockResolvedValueOnce({ rows: [] }) // terminal event
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // new session
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.createVibeSession({ userId: 'user-1', policyVersion: 'v2.1' }))
|
||||
.resolves.toEqual(replacement);
|
||||
expect(clientQuery.mock.calls[3][0]).toContain("status = 'replaced'");
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'session_ended'");
|
||||
expect(clientQuery.mock.calls[5][0]).toContain('INSERT INTO vibe_sessions');
|
||||
});
|
||||
|
||||
it('records retry-safe events and reports whether the event was inserted', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
|
||||
user_id: 'user-1', track_id: 'track-1', type: 'skipped', occurred_at: new Date(),
|
||||
position_ms: 1_500, duration_ms: 10_000, payload: { reason: 'next' },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock session
|
||||
.mockResolvedValueOnce({ rows: [event] }) // existing retry
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
|
||||
trackId: 'track-1', type: 'skipped', positionMs: 1_500, durationMs: 10_000,
|
||||
payload: { reason: 'next' },
|
||||
});
|
||||
|
||||
expect(result.inserted).toBe(false);
|
||||
expect(result.event.id).toBe('event-1');
|
||||
const [sql, values] = clientQuery.mock.calls[1];
|
||||
expect(sql).toContain('FOR UPDATE');
|
||||
expect(values).toEqual([
|
||||
'session-1', 'user-1',
|
||||
]);
|
||||
expect(clientQuery.mock.calls).toHaveLength(5);
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('vibe_event_projections');
|
||||
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
|
||||
expect.stringContaining('UPDATE vibe_sessions')
|
||||
);
|
||||
});
|
||||
|
||||
it('records a non-material event without mutating session context', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: null, session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { source: 'player' },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [event] }) // insert
|
||||
.mockResolvedValueOnce({ rows: [] }) // last event timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', type: 'progress', payload: { source: 'player' },
|
||||
});
|
||||
|
||||
const values = clientQuery.mock.calls[2][1];
|
||||
const storedPayload = JSON.parse(values[8]);
|
||||
expect(storedPayload).toEqual({ source: 'player' });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
|
||||
expect.stringContaining('SET context = $3::jsonb'),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not apply a retry body to an existing event', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const canonicalEvent = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1', user_id: 'user-1', track_id: null,
|
||||
type: 'progress', occurred_at: new Date(), position_ms: null, duration_ms: null,
|
||||
payload: { source: 'player' },
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [canonicalEvent] }) // canonical retry event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const result = await service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'progress',
|
||||
payload: { source: 'retry' },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ event: canonicalEvent, inserted: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => String(sql))).not.toContain(
|
||||
expect.stringContaining('SET context = $3::jsonb'),
|
||||
);
|
||||
});
|
||||
|
||||
it('projects material feedback once with the durable event transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
|
||||
user_id: 'user-1', track_id: 'track-1', type: 'completed', occurred_at: new Date(),
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
};
|
||||
const evidence = vi.spyOn(service as any, 'recordTrackEvidence').mockResolvedValue('evidence-1');
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // no existing idempotency key
|
||||
.mockResolvedValueOnce({ rows: [event] }) // event insert
|
||||
.mockResolvedValueOnce({ rows: [{ event_id: 'event-1' }] }) // projection marker
|
||||
.mockResolvedValueOnce({ rows: [] }) // play history
|
||||
.mockResolvedValueOnce({ rows: [] }) // track counter
|
||||
.mockResolvedValueOnce({ rows: [] }) // session timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1',
|
||||
type: 'completed', trackId: 'track-1',
|
||||
})).resolves.toMatchObject({ inserted: true, event: { id: 'event-1' } });
|
||||
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining('vibe_event_projections'),
|
||||
expect.stringContaining('INSERT INTO play_history'),
|
||||
]));
|
||||
expect(evidence).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an event when no owned session is returned', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [] }) // session lookup
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'other-user', type: 'completed',
|
||||
})).rejects.toThrow('not found or is not owned');
|
||||
});
|
||||
|
||||
it('rejects new events for terminal sessions but returns an existing idempotent retry', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const event = {
|
||||
id: 'event-1', client_event_id: 'client-event-1', session_id: 'session-1',
|
||||
user_id: 'user-1', track_id: null, type: 'completed', occurred_at: new Date(),
|
||||
position_ms: null, duration_ms: null, payload: {},
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN: idempotent retry
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
|
||||
.mockResolvedValueOnce({ rows: [event] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // COMMIT
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN: new event
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'ended' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // no matching retry
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'client-event-1', type: 'completed',
|
||||
})).resolves.toEqual({ event, inserted: false });
|
||||
await expect(service.recordVibeEvent({
|
||||
sessionId: 'session-1', userId: 'user-1', clientEventId: 'new-event-1', type: 'completed',
|
||||
})).rejects.toThrow('Cannot record a new event for ended Vibe session');
|
||||
|
||||
expect(clientQuery.mock.calls.map(([query]) => query)).not.toContain(
|
||||
expect.stringContaining('INSERT INTO vibe_events')
|
||||
);
|
||||
});
|
||||
|
||||
it('locks the terminal transition with its event and makes terminal retries no-ops', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
const ended = { ...active, status: 'ended', ended_at: new Date() };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [active] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // terminal event
|
||||
.mockResolvedValueOnce({ rows: [ended] }) // status transition
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.endVibeSessionWithEvent('session-1', 'user-1'))
|
||||
.resolves.toEqual({ session: ended, ended: true });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain("'session_ended'");
|
||||
expect(clientQuery.mock.calls[3][0]).toContain("status = 'ended'");
|
||||
});
|
||||
|
||||
it('resumes an owned session once and records session_resumed in the same lock', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const active = { id: 'session-1', user_id: 'user-1', status: 'active' };
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ ...active, status: 'paused' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [] }) // no old resume event
|
||||
.mockResolvedValueOnce({ rows: [active] }) // activate
|
||||
.mockResolvedValueOnce({ rows: [] }) // ledger event
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.resumeVibeSession('session-1', 'user-1'))
|
||||
.resolves.toEqual({ session: active, resumed: true });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'session_resumed'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable Vibe plans', () => {
|
||||
it('publishes a revision and its plan_published event in one transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const published = {
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] }) // lock
|
||||
.mockResolvedValueOnce({ rows: [published] }) // header
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockResolvedValueOnce({ rows: [] }) // plan_published
|
||||
.mockResolvedValueOnce({ rows: [] }) // timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {},
|
||||
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
|
||||
})).resolves.toMatchObject({ version: 1, items: [{ track_id: 'track-1' }] });
|
||||
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'plan_published'");
|
||||
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('rolls back the plan header and items if writing plan_published fails', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const published = {
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: {}, objective_snapshot: {}, created_at: new Date(),
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'active' }] })
|
||||
.mockResolvedValueOnce({ rows: [published] }) // header
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockRejectedValueOnce(new Error('ledger write failed'))
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {},
|
||||
items: [{ ordinal: 0, track_id: 'track-1', slot_role: 'next', candidate_source: 'comfort', score: 1, score_breakdown: {}, explanation: [], committed: false }],
|
||||
})).rejects.toThrow('ledger write failed');
|
||||
expect(clientQuery.mock.calls.at(-1)?.[0]).toBe('ROLLBACK');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain('COMMIT');
|
||||
});
|
||||
|
||||
it('refuses a delayed initial publication after a concurrent start replaced its session', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'session-1', status: 'replaced' }] })
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.publishVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: {}, objectiveSnapshot: {}, items: [],
|
||||
})).rejects.toThrow('Cannot publish a plan for replaced Vibe session');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('INSERT INTO vibe_plan_versions'));
|
||||
});
|
||||
|
||||
it('reads every durable session track as a replacement-plan exclusion', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValueOnce({ rows: [{ track_id: 'served' }, { track_id: 'skipped' }, { track_id: 'disliked' }] });
|
||||
await expect(service.getVibeSessionTrackIds('session-1', 'user-1'))
|
||||
.resolves.toEqual(['served', 'skipped', 'disliked']);
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('SELECT DISTINCT e.track_id');
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('e.track_id IS NOT NULL');
|
||||
});
|
||||
|
||||
it('writes a header and all items in one transaction', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{
|
||||
id: 'plan-1', session_id: 'session-1', version: 1, reason: 'session_started',
|
||||
state_snapshot: { energy: 0.5 }, objective_snapshot: { freshness: 0.4 }, created_at: new Date(),
|
||||
}] })
|
||||
.mockResolvedValueOnce({ rows: [] }) // item
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
const plan = await service.persistVibePlan({
|
||||
sessionId: 'session-1', userId: 'user-1', version: 1, reason: 'session_started',
|
||||
stateSnapshot: { energy: 0.5 }, objectiveSnapshot: { freshness: 0.4 },
|
||||
items: [{
|
||||
ordinal: 0, track_id: 'track-1', slot_role: 'anchor', candidate_source: 'comfort',
|
||||
score: 0.91, score_breakdown: { affinity: 0.8 }, explanation: [{ because: 'favourite' }], committed: true,
|
||||
}],
|
||||
});
|
||||
|
||||
expect(plan.items[0].plan_version_id).toBe('plan-1');
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('INSERT INTO vibe_plan_versions');
|
||||
expect(clientQuery.mock.calls[2][0]).toContain('INSERT INTO vibe_plan_items');
|
||||
expect(clientQuery.mock.calls[3][0]).toBe('COMMIT');
|
||||
});
|
||||
|
||||
it('serves and commits one next item under the session lock', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const item = {
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [item] }) // commit item
|
||||
.mockResolvedValueOnce({ rows: [] }) // track_served event
|
||||
.mockResolvedValueOnce({ rows: [] }) // timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1')).resolves.toEqual({ item, stale: false });
|
||||
expect(clientQuery.mock.calls[1][0]).toContain('FOR UPDATE');
|
||||
expect(clientQuery.mock.calls[3][0]).toContain('SET committed = true');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain("'track_served'");
|
||||
});
|
||||
|
||||
it('returns a newer preview signal without committing when the expected plan is stale', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-2', version: 2 }] }) // latest
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
|
||||
.resolves.toEqual({ item: null, stale: true });
|
||||
expect(clientQuery.mock.calls).toHaveLength(4);
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('returns the original item when a version-aware next request is retried', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const item = {
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: 'track-1', slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.9, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest
|
||||
.mockResolvedValueOnce({ rows: [item] }) // prior served item
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.serveNextVibePlanItem('session-1', 'user-1', 1))
|
||||
.resolves.toEqual({ item, stale: false });
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('advances a served unplayable item with a separate idempotent event and commits one replacement', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = {
|
||||
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
|
||||
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
|
||||
.mockResolvedValueOnce({ rows: [{ track_id: 'track-1', ordinal: 0 }] }) // current served cursor
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'error-1' }] }) // playback_error event
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // commit replacement
|
||||
.mockResolvedValueOnce({ rows: [] }) // replacement track_served event
|
||||
.mockResolvedValueOnce({ rows: [] }) // playback_error result payload
|
||||
.mockResolvedValueOnce({ rows: [] }) // session timestamp
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId: 'plan-1',
|
||||
ordinal: 0,
|
||||
trackId: 'track-1',
|
||||
eventId: 'event-1',
|
||||
})).resolves.toEqual({ item: replacement, stale: false });
|
||||
|
||||
expect(clientQuery.mock.calls[5][0]).toContain("'playback_error'");
|
||||
expect(clientQuery.mock.calls[6][0]).toContain('SET committed = true');
|
||||
expect(clientQuery.mock.calls[7][0]).toContain("'track_served'");
|
||||
expect(clientQuery.mock.calls[8][0]).toContain('UPDATE vibe_events SET payload');
|
||||
});
|
||||
|
||||
it('refuses an old served cursor when events share a timestamp by ordering the immutable plan ordinal', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [] }) // no prior playback_error event
|
||||
// The lower-ordinal event can have a lexically greater UUID at the
|
||||
// same occurred_at. The cursor must still be the highest immutable
|
||||
// plan ordinal, never whichever UUID sorts last.
|
||||
.mockResolvedValueOnce({ rows: [{ track_id: 'track-2', ordinal: 1 }] }) // current served cursor
|
||||
.mockResolvedValueOnce({ rows: [] }); // ROLLBACK
|
||||
|
||||
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId: 'plan-1',
|
||||
ordinal: 0,
|
||||
trackId: 'track-1',
|
||||
eventId: 'event-1',
|
||||
})).rejects.toThrow('not the current served cursor');
|
||||
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('JOIN vibe_plan_items');
|
||||
expect(clientQuery.mock.calls[4][0]).toContain('ORDER BY i.ordinal DESC');
|
||||
expect(clientQuery.mock.calls[4][0]).not.toContain('id DESC');
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
});
|
||||
|
||||
it('retries an unplayable advancement with the same event id without consuming another item', async () => {
|
||||
const { service, clientQuery } = makeTransactionalService();
|
||||
const replacement = {
|
||||
plan_version_id: 'plan-1', ordinal: 1, track_id: 'track-2', slot_role: null,
|
||||
candidate_source: 'discovery', score: 0.8, score_breakdown: {}, explanation: [], committed: true,
|
||||
};
|
||||
clientQuery
|
||||
.mockResolvedValueOnce({ rows: [] }) // BEGIN
|
||||
.mockResolvedValueOnce({ rows: [{ status: 'active' }] }) // session lock
|
||||
.mockResolvedValueOnce({ rows: [{ id: 'plan-1', version: 1 }] }) // latest plan
|
||||
.mockResolvedValueOnce({ rows: [{ type: 'playback_error', payload: {
|
||||
planVersionId: 'plan-1', ordinal: 0, trackId: 'track-1',
|
||||
advancedTo: { planVersionId: 'plan-1', ordinal: 1 },
|
||||
} }] }) // prior explicit advancement
|
||||
.mockResolvedValueOnce({ rows: [replacement] }) // canonical replacement
|
||||
.mockResolvedValueOnce({ rows: [] }); // COMMIT
|
||||
|
||||
await expect(service.advancePastUnplayableVibePlanItem('session-1', 'user-1', {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId: 'plan-1',
|
||||
ordinal: 0,
|
||||
trackId: 'track-1',
|
||||
eventId: 'event-1',
|
||||
})).resolves.toEqual({ item: replacement, stale: false });
|
||||
|
||||
expect(clientQuery.mock.calls.map(([sql]) => sql)).not.toContain(expect.stringContaining('SET committed = true'));
|
||||
expect(clientQuery.mock.calls).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('reads the latest revision and reconstructs ordered plan items', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
mockQuery.mockResolvedValue({ rows: [{
|
||||
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
|
||||
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
|
||||
item_plan_version_id: 'plan-2', ordinal: 0, track_id: 'track-2', slot_role: 'next',
|
||||
candidate_source: 'adjacent', score: 0.8, score_breakdown: { transition: 0.7 },
|
||||
explanation: [{ because: 'similar artist' }], committed: true,
|
||||
}, {
|
||||
id: 'plan-2', session_id: 'session-1', version: 2, reason: 'feedback',
|
||||
state_snapshot: { energy: 0.7 }, objective_snapshot: { freshness: 0.5 }, created_at: new Date(),
|
||||
item_plan_version_id: 'plan-2', ordinal: 1, track_id: 'track-3', slot_role: null,
|
||||
candidate_source: 'discovery', score: 0.6, score_breakdown: { novelty: 0.5 },
|
||||
explanation: [], committed: false,
|
||||
}] });
|
||||
|
||||
const plan = await service.getVibePlan('session-1', 'user-1');
|
||||
|
||||
expect(plan?.version).toBe(2);
|
||||
expect(plan?.items.map((item) => item.track_id)).toEqual(['track-2', 'track-3']);
|
||||
expect(mockQuery.mock.calls[0][0]).toContain('SELECT MAX(version) FROM vibe_plan_versions');
|
||||
expect(mockQuery.mock.calls[0][1]).toEqual(['session-1', 'user-1', null]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAlbum', () => {
|
||||
it('persists the canonical release date instead of discarding it', async () => {
|
||||
const { service, mockQuery } = makeService();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,20 @@ export interface Candidate {
|
||||
generatorId: string;
|
||||
explanation: ClaimEdge[];
|
||||
relevance: number;
|
||||
/**
|
||||
* Filled by the session director after sequence planning. Generators remain
|
||||
* deliberately unaware of slots and objectives, while the durable plan can
|
||||
* retain why this particular candidate won its position.
|
||||
*/
|
||||
plan?: {
|
||||
slotRole: string;
|
||||
score: number;
|
||||
scoreBreakdown: Record<string, unknown>;
|
||||
explanation: Record<string, unknown>;
|
||||
/** Revision-level policy and constraint evidence, copied into the durable
|
||||
* objective snapshot by the coordinator. */
|
||||
objective?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GeneratorContext {
|
||||
@@ -35,6 +49,10 @@ export interface GeneratorContext {
|
||||
lastGenreIds: string[];
|
||||
context: string | null;
|
||||
noveltyHunger: number;
|
||||
/** Session-local exploration controls; they never overwrite durable taste. */
|
||||
explorationCoefficient?: number;
|
||||
discoveryRadius?: number;
|
||||
sessionGoal?: { type: string; target: number; progress: number };
|
||||
sessionAgeMin: number;
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mergeUniquePlan, SessionDirector } from './session-director.service.js';
|
||||
import { mergeUniquePlan, scoreArcTransition, selectConstrainedSequence, SessionDirector, sessionSimilarityPenalty } from './session-director.service.js';
|
||||
import { DbService } from './db.service.js';
|
||||
import { ALL_GENERATORS } from './generators.service.js';
|
||||
|
||||
function makeMockDb(overrides: Record<string, any> = {}): DbService {
|
||||
const mockQuery = vi.fn();
|
||||
@@ -60,6 +61,8 @@ describe('SessionDirector', () => {
|
||||
);
|
||||
const refillExclusions = (buildPlan.mock.calls[0][3] as any).excludedTrackIds as Set<string>;
|
||||
expect(refillExclusions).toEqual(new Set(['older-skip', 'skipped', 'already-queued']));
|
||||
expect((buildPlan.mock.calls[0][3] as any).retainedPlan.map((item: any) => item.trackId))
|
||||
.toEqual(['already-queued']);
|
||||
});
|
||||
|
||||
it('does not append anything when a refill contains only queued or excluded tracks', async () => {
|
||||
@@ -119,9 +122,261 @@ describe('SessionDirector', () => {
|
||||
|
||||
it('has valid role names', () => {
|
||||
const slots = director.getArcSlots('comfort', 20);
|
||||
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow'];
|
||||
const validRoles = ['known', 'adjacent', 'favorite', 'similar', 'new', 'medium', 'high', 'peak', 'cooldown', 'soft', 'ambient', 'acoustic', 'slow', 'surprise'];
|
||||
slots.forEach(s => expect(validRoles).toContain(s.role));
|
||||
});
|
||||
|
||||
it('creates measurable targets, callbacks, and one bounded surprise in the first arc cycle', () => {
|
||||
const slots = director.getArcSlots('comfort', 20);
|
||||
expect(slots.every(slot => slot.targets && Object.keys(slot.targets).length > 0)).toBe(true);
|
||||
expect(slots.filter(slot => slot.surprise)).toHaveLength(1);
|
||||
const anchor = slots.find(slot => slot.callback?.phase === 'anchor');
|
||||
const callback = slots.find(slot => slot.callback?.phase === 'return');
|
||||
expect(anchor?.callback?.id).toBe(callback?.callback?.id);
|
||||
expect(anchor?.callback?.minSeparation).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('durable surprise delivery accounting', () => {
|
||||
it('counts only exact, served surprise plan-item exposures', async () => {
|
||||
const sessionId = '00000000-0000-4000-8000-000000000001';
|
||||
const userId = '00000000-0000-4000-8000-000000000002';
|
||||
const otherSessionId = '00000000-0000-4000-8000-000000000003';
|
||||
const revisionOneId = '00000000-0000-4000-8000-000000000011';
|
||||
const revisionTwoId = '00000000-0000-4000-8000-000000000012';
|
||||
const otherRevisionId = '00000000-0000-4000-8000-000000000013';
|
||||
const now = new Date();
|
||||
const recentAt = new Date(now.getTime() - 5 * 60 * 1000);
|
||||
const expiredAt = new Date(now.getTime() - 61 * 60 * 1000);
|
||||
const retainedTrackId = '00000000-0000-4000-8000-000000000021';
|
||||
const servedTrackId = '00000000-0000-4000-8000-000000000022';
|
||||
const oldTrackId = '00000000-0000-4000-8000-000000000023';
|
||||
|
||||
// This mirrors the three tables involved in the query. The unserved
|
||||
// retained row exists in both immutable revisions, but has no ledger
|
||||
// event and therefore must not consume a surprise budget.
|
||||
const versions = [
|
||||
{ id: revisionOneId, sessionId },
|
||||
{ id: revisionTwoId, sessionId },
|
||||
{ id: otherRevisionId, sessionId: otherSessionId },
|
||||
];
|
||||
const items = [
|
||||
{ planVersionId: revisionOneId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 6, trackId: retainedTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 8, trackId: oldTrackId, slotRole: 'surprise' },
|
||||
{ planVersionId: revisionTwoId, ordinal: 9, trackId: '00000000-0000-4000-8000-000000000024', slotRole: 'favorite' },
|
||||
{ planVersionId: otherRevisionId, ordinal: 7, trackId: servedTrackId, slotRole: 'surprise' },
|
||||
];
|
||||
const events = [
|
||||
// The exact revision-two association counts once.
|
||||
{ id: '00000000-0000-4000-8000-000000000031', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
|
||||
// A valid historical exposure remains in the session total but falls
|
||||
// out of the rolling 60-minute counter.
|
||||
{ id: '00000000-0000-4000-8000-000000000032', sessionId, userId, type: 'track_served', trackId: oldTrackId, payload: { planVersionId: revisionTwoId, ordinal: 8 }, occurredAt: expiredAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000033', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionOneId, ordinal: 7 }, occurredAt: recentAt }, // wrong ordinal
|
||||
{ id: '00000000-0000-4000-8000-000000000034', sessionId, userId, type: 'track_served', trackId: retainedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt }, // wrong track
|
||||
{ id: '00000000-0000-4000-8000-000000000035', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: '00000000-0000-4000-8000-000000000014', ordinal: 7 }, occurredAt: recentAt }, // wrong version
|
||||
{ id: '00000000-0000-4000-8000-000000000036', sessionId: otherSessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: otherRevisionId, ordinal: 7 }, occurredAt: recentAt }, // wrong session
|
||||
{ id: '00000000-0000-4000-8000-000000000037', sessionId, userId, type: 'track_finished', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 7 }, occurredAt: recentAt },
|
||||
// Legacy/corrupt payloads must neither cast-fail nor claim an actual
|
||||
// surprise exposure when somebody writes directly to the event ledger.
|
||||
{ id: '00000000-0000-4000-8000-000000000038', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: 'not-a-uuid', ordinal: 7 }, occurredAt: recentAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000039', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: 'not-an-integer' }, occurredAt: recentAt },
|
||||
{ id: '00000000-0000-4000-8000-000000000040', sessionId, userId, type: 'track_served', trackId: servedTrackId, payload: { planVersionId: revisionTwoId, ordinal: '999999999999999999999999999999999999' }, occurredAt: recentAt },
|
||||
];
|
||||
const exposureIds: string[] = [];
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[]) => {
|
||||
// Faithfully evaluate the query's joins against the in-memory rows;
|
||||
// do not treat merely planned items as delivered exposure.
|
||||
expect(params).toEqual([sessionId, userId]);
|
||||
const matching = events.filter(event => {
|
||||
const item = items.find(candidate => candidate.planVersionId === event.payload.planVersionId
|
||||
&& candidate.ordinal === event.payload.ordinal
|
||||
&& candidate.trackId === event.trackId);
|
||||
const version = item && versions.find(candidate => candidate.id === item.planVersionId);
|
||||
return event.sessionId === sessionId
|
||||
&& event.userId === userId
|
||||
&& event.type === 'track_served'
|
||||
&& item?.slotRole === 'surprise'
|
||||
&& version?.sessionId === event.sessionId;
|
||||
});
|
||||
exposureIds.push(...new Set(matching.map(event => event.id)));
|
||||
return Promise.resolve({
|
||||
rows: [{
|
||||
session_count: new Set(matching.map(event => event.id)).size,
|
||||
hour_count: new Set(matching.filter(event => event.occurredAt > new Date(Date.now() - 60 * 60 * 1000)).map(event => event.id)).size,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
const usage = await (new SessionDirector(db) as any).getSessionSurpriseUsage(sessionId, userId);
|
||||
|
||||
expect(usage).toEqual({ session: 2, hour: 1 });
|
||||
expect(exposureIds).toEqual([
|
||||
'00000000-0000-4000-8000-000000000031',
|
||||
'00000000-0000-4000-8000-000000000032',
|
||||
]);
|
||||
const [sql, params] = (db.pgClient.query as any).mock.calls[0] as [string, unknown[]];
|
||||
expect(params).toEqual([sessionId, userId]);
|
||||
expect(sql).toContain('SELECT DISTINCT e.id, e.occurred_at');
|
||||
expect(sql).toContain("e.payload->>'planVersionId' ~*");
|
||||
expect(sql).toContain("THEN (e.payload->>'planVersionId')::uuid");
|
||||
expect(sql).toContain("e.payload->>'ordinal') ~ '^(0|[1-9][0-9]{0,8})$'");
|
||||
expect(sql).toContain("THEN (e.payload->>'ordinal')::integer");
|
||||
expect(sql).toContain('AND item.track_id = e.track_id');
|
||||
expect(sql).toContain('AND version.session_id = e.session_id');
|
||||
expect(sql).toContain("e.type = 'track_served'");
|
||||
expect(sql).toContain("item.slot_role = 'surprise'");
|
||||
expect(sql).toContain('WHERE e.session_id = $1');
|
||||
expect(sql).toContain('AND e.user_id = $2');
|
||||
expect(sql).toContain("occurred_at > NOW() - INTERVAL '1 hour'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('transition-aware sequence scoring', () => {
|
||||
it('prefers a smooth, on-arc candidate and treats missing analysis as lower confidence', () => {
|
||||
const slot = {
|
||||
position: 0,
|
||||
role: 'high',
|
||||
targets: { energy: { min: 0.7, max: 0.9, maxDelta: 0.2 }, tempo: { min: 120, max: 160, maxDelta: 25 } },
|
||||
};
|
||||
const previous = { energy: 0.72, bpm: 132 };
|
||||
const smooth = scoreArcTransition(candidate('smooth'), { energy: 0.78, bpm: 140 }, previous, slot);
|
||||
const abrupt = scoreArcTransition(candidate('abrupt'), { energy: 0.15, bpm: 72 }, previous, slot);
|
||||
const unknown = scoreArcTransition(candidate('unknown'), {}, previous, slot);
|
||||
|
||||
expect(smooth.score).toBeGreaterThan(abrupt.score);
|
||||
expect(unknown.confidence).toBeLessThan(smooth.confidence);
|
||||
expect(unknown.score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('selects a callback inside its separation window while preserving hard caps', () => {
|
||||
const candidates = ['anchor', 'bridge-a', 'bridge-b', 'return', 'other'].map(id => ({ ...candidate(id), generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
|
||||
['bridge-a', { artistId: 'a2', albumId: 'a2', energy: 0.5 }],
|
||||
['bridge-b', { artistId: 'a3', albumId: 'a3', energy: 0.5 }],
|
||||
['return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.5 }],
|
||||
['other', { artistId: 'a4', albumId: 'a5', favorite: false, energy: 0.5 }],
|
||||
]);
|
||||
const token = { id: 'theme', theme: 'artist' as const, minSeparation: 2, maxSeparation: 4 };
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds: () => ['comfort'],
|
||||
slots: [
|
||||
{ position: 0, role: 'known', targets: {}, callback: { ...token, phase: 'anchor' as const } },
|
||||
{ position: 1, role: 'known', targets: {} },
|
||||
{ position: 2, role: 'known', targets: {} },
|
||||
{ position: 3, role: 'favorite', targets: {}, callback: { ...token, phase: 'return' as const } },
|
||||
],
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['anchor', 'bridge-a', 'bridge-b', 'return']);
|
||||
expect(result.plan[3].plan?.scoreBreakdown.callback).toBe(1);
|
||||
});
|
||||
|
||||
it('plans an energetic rise through peak and cooldown when measured candidates exist', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const slots = director.getArcSlots('energetic', 8);
|
||||
const entries = [
|
||||
['medium-1', 'comfort', 0.55, 110], ['medium-2', 'comfort', 0.62, 125],
|
||||
['high-1', 'discovery', 0.72, 135], ['high-2', 'discovery', 0.8, 150],
|
||||
['peak', 'deep-dive', 0.9, 165], ['surprise', 'discovery', 0.85, 155],
|
||||
['cooldown-1', 'comfort', 0.62, 130], ['cooldown-2', 'comfort', 0.5, 110],
|
||||
] as const;
|
||||
const candidates = entries.map(([trackId, generatorId]) => ({ ...candidate(trackId), generatorId }));
|
||||
const metadata: Map<string, any> = new Map(entries.map(([trackId, generatorId, energy, bpm], index) => [trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`, energy, bpm,
|
||||
valence: 0.6, acousticness: trackId.startsWith('cooldown') ? 0.3 : 0.1,
|
||||
favorite: String(trackId).startsWith('medium') || String(trackId).startsWith('cooldown'),
|
||||
newArtist: generatorId === 'discovery',
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots, metadata, budgets: [],
|
||||
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(entries.map(([trackId]) => trackId));
|
||||
expect(result.plan.map(item => metadata.get(item.trackId)?.energy))
|
||||
.toEqual([0.55, 0.62, 0.72, 0.8, 0.9, 0.85, 0.62, 0.5]);
|
||||
expect(result.relaxations).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps the familiar-new-familiar discovery callback intact', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const candidates = [
|
||||
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
|
||||
{ ...candidate('adjacent'), generatorId: 'adjacent' },
|
||||
{ ...candidate('new'), generatorId: 'discovery' },
|
||||
{ ...candidate('favorite-return'), generatorId: 'deep-dive' },
|
||||
];
|
||||
const metadata = new Map([
|
||||
['favorite-anchor', { artistId: 'theme', albumId: 'a1', favorite: true, energy: 0.5 }],
|
||||
['adjacent', { artistId: 'bridge', albumId: 'a2', energy: 0.55 }],
|
||||
['new', { artistId: 'new', albumId: 'a3', newArtist: true, energy: 0.6 }],
|
||||
['favorite-return', { artistId: 'theme', albumId: 'a4', favorite: true, energy: 0.55 }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: director.getArcSlots('discovery', 4), metadata, budgets: [],
|
||||
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId))
|
||||
.toEqual(['favorite-anchor', 'adjacent', 'new', 'favorite-return']);
|
||||
expect(result.plan[3].plan?.explanation.callback).toMatchObject({ phase: 'return', matchScore: 1 });
|
||||
});
|
||||
|
||||
it('downgrades a surprise deterministically when no preferred recovery anchor is feasible', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const candidates = [
|
||||
{ ...candidate('favorite-anchor'), generatorId: 'deep-dive' },
|
||||
{ ...candidate('adjacent'), generatorId: 'adjacent' },
|
||||
{ ...candidate('unrecoverable-surprise'), generatorId: 'discovery' },
|
||||
// This may fill the downgraded favourite slot only through the
|
||||
// explicitly persisted arc-source relaxation; it cannot reserve a
|
||||
// recovery for the surprise because it is not a favourite source.
|
||||
{ ...candidate('fallback'), generatorId: 'contextual' },
|
||||
];
|
||||
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`,
|
||||
favorite: item.trackId === 'favorite-anchor', newArtist: item.trackId === 'unrecoverable-surprise', energy: 0.5,
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: director.getArcSlots('discovery', 3), metadata, budgets: [],
|
||||
roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['favorite-anchor', 'adjacent', 'fallback']);
|
||||
expect(result.plan[2].plan?.slotRole).toBe('favorite');
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'surprise_recovery' }));
|
||||
});
|
||||
|
||||
it('falls back safely with sparse audio analysis and records arc precision', () => {
|
||||
const db = makeMockDb();
|
||||
const director = new SessionDirector(db);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates: [
|
||||
{ ...candidate('unknown-analysis'), generatorId: 'comfort' },
|
||||
{ ...candidate('wrong-energy'), generatorId: 'comfort' },
|
||||
],
|
||||
slots: director.getArcSlots('energetic', 1),
|
||||
metadata: new Map([
|
||||
['unknown-analysis', { artistId: 'a1', albumId: 'x1' }],
|
||||
['wrong-energy', { artistId: 'a2', albumId: 'x2', energy: 0.1, bpm: 70 }],
|
||||
]),
|
||||
budgets: [], roleToGeneratorIds: role => (director as any).roleToGeneratorIds(role),
|
||||
});
|
||||
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['unknown-analysis']);
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ constraint: 'arc_precision' }));
|
||||
expect(result.plan[0].plan?.explanation.arcPrecision).toMatchObject({ measuredFit: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeEntropy', () => {
|
||||
@@ -159,7 +414,7 @@ describe('SessionDirector', () => {
|
||||
{ trackId: 't1', generatorId: 'a', relevance: 0.9, explanation: [{ subjectType: 'artist', subjectId: 'a1', predicate: 'credited_main_on', objectType: 'track', objectId: 't1', fusedValue: 1 }] },
|
||||
{ trackId: 't2', generatorId: 'b', relevance: 0.3, explanation: [{ subjectType: 'artist', subjectId: 'a2', predicate: 'credited_main_on', objectType: 'track', objectId: 't2', fusedValue: 1 }] },
|
||||
];
|
||||
const fatigue = { artist: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const fatigue = { artist: new Map(), album: new Map(), genre: new Map(), track: new Map(), language: new Map(), vocal: 0 };
|
||||
const budgets = [{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 }];
|
||||
const state = { energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 10, lastArtistIds: [], lastGenreIds: [], context: null };
|
||||
|
||||
@@ -170,6 +425,357 @@ describe('SessionDirector', () => {
|
||||
|
||||
});
|
||||
|
||||
describe('recent session fingerprint penalty', () => {
|
||||
it('softly penalizes a repeated session shape without excluding it', () => {
|
||||
const repeated = sessionSimilarityPenalty(
|
||||
{ artistId: 'artist-1', genreId: 'genre-1' },
|
||||
'comfort',
|
||||
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
|
||||
);
|
||||
const newShape = sessionSimilarityPenalty(
|
||||
{ artistId: 'artist-2', genreId: 'genre-2' },
|
||||
'discovery',
|
||||
[{ artists: ['artist-1'], genres: ['genre-1'], sources: ['comfort'] }],
|
||||
);
|
||||
expect(repeated).toBeGreaterThan(0);
|
||||
expect(repeated).toBeLessThanOrEqual(0.18);
|
||||
expect(newShape).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequence constraints', () => {
|
||||
const slots = Array.from({ length: 10 }, (_, position) => ({ position, role: 'known' }));
|
||||
const budgets = [
|
||||
{ dimension: 'artist', budgetShare: 0.2, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'genre', budgetShare: 0.4, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'language', budgetShare: 0.6, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'instrumental', budgetShare: 0.1, horizonMin: 30, spent: 0 },
|
||||
{ dimension: 'new_artist', budgetShare: 0.15, horizonMin: 60, spent: 0 },
|
||||
{ dimension: 'favorite', budgetShare: 0.25, horizonMin: 60, spent: 0 },
|
||||
];
|
||||
const roleToGeneratorIds = () => ['comfort'];
|
||||
|
||||
it('projects budgets while enforcing artist and album caps across the sequence', () => {
|
||||
const candidates = Array.from({ length: 15 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
|
||||
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
|
||||
artistId: i < 5 ? 'overplayed-artist' : `artist-${i}`,
|
||||
albumId: i < 4 ? 'overplayed-album' : `album-${i}`,
|
||||
genreId: i < 6 ? 'genre-a' : 'genre-b',
|
||||
language: i < 7 ? 'ja' : 'en',
|
||||
instrumental: i === 7,
|
||||
newArtist: i === 8 || i === 9,
|
||||
favorite: i === 10 || i === 11 || i === 12,
|
||||
}]));
|
||||
|
||||
const result = selectConstrainedSequence({ candidates, slots, metadata, budgets, roleToGeneratorIds });
|
||||
expect(result.plan).toHaveLength(10);
|
||||
const ids = result.plan.map(item => item.trackId);
|
||||
expect(ids.filter(id => metadata.get(id)?.artistId === 'overplayed-artist')).toHaveLength(2);
|
||||
expect(ids.filter(id => metadata.get(id)?.albumId === 'overplayed-album').length).toBeLessThanOrEqual(3);
|
||||
expect(ids.filter(id => metadata.get(id)?.instrumental)).toHaveLength(1);
|
||||
expect(ids.filter(id => metadata.get(id)?.newArtist)).toHaveLength(2);
|
||||
expect(ids.filter(id => metadata.get(id)?.favorite)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('corrects the detected dimension directly before relaxing it', () => {
|
||||
const candidates = ['ja-1', 'ja-2', 'en-1', 'en-2'].map(trackId => ({ ...candidate(trackId), generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['ja-1', { artistId: 'a1', albumId: 'x1', language: 'ja' }],
|
||||
['ja-2', { artistId: 'a2', albumId: 'x2', language: 'ja' }],
|
||||
['en-1', { artistId: 'a3', albumId: 'x3', language: 'en' }],
|
||||
['en-2', { artistId: 'a4', albumId: 'x4', language: 'en' }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 2),
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds,
|
||||
loopDimension: 'language',
|
||||
loopedValue: 'ja',
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['en-1', 'en-2']);
|
||||
expect(result.relaxations).toEqual([]);
|
||||
});
|
||||
|
||||
it('records a structured soft relaxation without violating hard album caps', () => {
|
||||
const candidates = Array.from({ length: 5 }, (_, i) => ({ ...candidate(`t${i}`), generatorId: 'comfort' }));
|
||||
const metadata = new Map(candidates.map((item, i) => [item.trackId, {
|
||||
artistId: `artist-${i}`,
|
||||
albumId: i < 4 ? 'single-album' : `album-${i}`,
|
||||
language: 'ja',
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 5), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'language', loopedValue: 'ja',
|
||||
});
|
||||
expect(result.plan.filter(item => metadata.get(item.trackId)?.albumId === 'single-album')).toHaveLength(3);
|
||||
expect(result.relaxations).toContainEqual(expect.objectContaining({ stage: 'soft_budget' }));
|
||||
});
|
||||
|
||||
it('counts the retained queue tail against hard artist caps before selecting replacements', () => {
|
||||
const retained = [candidate('queued-a1'), candidate('queued-a2')];
|
||||
const candidates = [candidate('same-artist'), candidate('other-artist')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['queued-a1', { artistId: 'artist-a', albumId: 'queued-album-1' }],
|
||||
['queued-a2', { artistId: 'artist-a', albumId: 'queued-album-2' }],
|
||||
['same-artist', { artistId: 'artist-a', albumId: 'replacement-album' }],
|
||||
['other-artist', { artistId: 'artist-b', albumId: 'replacement-album-2' }],
|
||||
]);
|
||||
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, retainedPlan: retained,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['other-artist']);
|
||||
});
|
||||
|
||||
it('enforces the three-track album limit across the rolling 40-play history', () => {
|
||||
const candidates = [candidate('same-album'), candidate('new-album')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['same-album', { artistId: 'a4', albumId: 'album-a' }],
|
||||
['new-album', { artistId: 'a5', albumId: 'album-b' }],
|
||||
]);
|
||||
const albumHistory = Array.from({ length: 40 }, (_, index) => ({
|
||||
artistId: `history-${index}`,
|
||||
albumId: index < 3 ? 'album-a' : `history-album-${index}`,
|
||||
}));
|
||||
|
||||
const result = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds, albumHistory,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['new-album']);
|
||||
});
|
||||
|
||||
it('projects budgets over historical counts and the planned horizon with track-consistent denominators', () => {
|
||||
const candidates = [candidate('ja'), candidate('en')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['ja', { artistId: 'a1', albumId: 'x1', genreId: 'j-pop' }],
|
||||
['en', { artistId: 'a2', albumId: 'x2', genreId: 'rock' }],
|
||||
]);
|
||||
const historicalValues = new Map([['j-pop', 4], ['rock', 1]]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 1),
|
||||
metadata,
|
||||
budgets: [{ dimension: 'genre', budgetShare: 0.6, horizonMin: 30, spent: 0.8, historicalTotal: 5, historicalValues }],
|
||||
roleToGeneratorIds,
|
||||
});
|
||||
// 4 / 5 becomes 4 / 6 if rock is selected; a fifth j-pop track would
|
||||
// exceed the 60% cap. The selector must use history + proposal, not only
|
||||
// the one-track replacement queue.
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['en']);
|
||||
});
|
||||
|
||||
it('does not treat unknown instrumentation as a vocal/instrumental budget credit', () => {
|
||||
const candidates = [candidate('unknown'), candidate('instrumental')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['unknown', { artistId: 'a1', albumId: 'x1' }],
|
||||
['instrumental', { artistId: 'a2', albumId: 'x2', instrumental: true }],
|
||||
]);
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: slots.slice(0, 1),
|
||||
metadata,
|
||||
budgets: [{ dimension: 'instrumental', budgetShare: 1, horizonMin: 30, spent: 0, historicalTotal: 0, historicalValues: new Map() }],
|
||||
roleToGeneratorIds,
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual(['instrumental']);
|
||||
});
|
||||
|
||||
it('excludes candidates matching any detected producer or label, not only their first claim', () => {
|
||||
const candidates = [candidate('producer-match'), candidate('label-match'), candidate('safe')].map(item => ({ ...item, generatorId: 'comfort' }));
|
||||
const metadata = new Map([
|
||||
['producer-match', { artistId: 'a1', albumId: 'x1', producerIds: ['other', 'producer-loop'] }],
|
||||
['label-match', { artistId: 'a2', albumId: 'x2', labelIds: ['other', 'label-loop'] }],
|
||||
['safe', { artistId: 'a3', albumId: 'x3', producerIds: ['safe-producer'], labelIds: ['safe-label'] }],
|
||||
]);
|
||||
const producerResult = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'producer', loopedValues: ['producer-loop'],
|
||||
});
|
||||
const labelResult = selectConstrainedSequence({
|
||||
candidates, slots: slots.slice(0, 1), metadata, budgets: [], roleToGeneratorIds,
|
||||
loopDimension: 'label', loopedValues: ['label-loop'],
|
||||
});
|
||||
expect(producerResult.plan.map(item => item.trackId)).not.toContain('producer-match');
|
||||
expect(labelResult.plan.map(item => item.trackId)).not.toContain('label-match');
|
||||
});
|
||||
|
||||
it('keeps stable candidate order while reusing a role preference pool', () => {
|
||||
const candidates = [
|
||||
{ ...candidate('comfort-first'), generatorId: 'comfort' },
|
||||
{ ...candidate('adjacent-first'), generatorId: 'adjacent' },
|
||||
{ ...candidate('comfort-second'), generatorId: 'comfort' },
|
||||
{ ...candidate('adjacent-second'), generatorId: 'adjacent' },
|
||||
];
|
||||
const metadata = new Map(candidates.map((item, index) => [item.trackId, {
|
||||
artistId: `artist-${index}`, albumId: `album-${index}`,
|
||||
}]));
|
||||
const result = selectConstrainedSequence({
|
||||
candidates,
|
||||
slots: Array.from({ length: 4 }, (_, position) => ({ position, role: position % 2 ? 'adjacent' : 'known' })),
|
||||
metadata,
|
||||
budgets: [],
|
||||
roleToGeneratorIds: role => role === 'adjacent' ? ['adjacent'] : ['comfort'],
|
||||
});
|
||||
expect(result.plan.map(item => item.trackId)).toEqual([
|
||||
'comfort-first', 'adjacent-first', 'comfort-second', 'adjacent-second',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anti-loop signals', () => {
|
||||
const variedRecentPlays = Array.from({ length: 4 }, (_, index) => ({
|
||||
trackId: `00000000-0000-0000-0000-00000000000${index + 1}`,
|
||||
artistId: `artist-${index}`,
|
||||
genreId: `genre-${index}`,
|
||||
language: `lang-${index}`,
|
||||
bpm: 80 + index * 30,
|
||||
energy: index / 3,
|
||||
vocal: null,
|
||||
decade: 1980 + index * 10,
|
||||
valence: index % 2,
|
||||
albumId: `album-${index}`,
|
||||
producerIds: [],
|
||||
labelIds: [],
|
||||
}));
|
||||
|
||||
it('returns fused producer lineage from resolved main artists', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({ rows: [{ lineage_id: 'producer-a' }, { lineage_id: 'producer-b' }] });
|
||||
const director = new SessionDirector(db);
|
||||
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
|
||||
expect(signal).toEqual({ dimension: 'producer', values: ['producer-a', 'producer-b'] });
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('claim_fusion cf');
|
||||
expect(sql).toContain("cf.subject_type = 'artist'");
|
||||
expect(sql).toContain('cf.object_id = recent.artist_id');
|
||||
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
|
||||
});
|
||||
|
||||
it('returns label identities after a producer check finds no loop', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any)
|
||||
.mockResolvedValueOnce({ rows: [] })
|
||||
.mockResolvedValueOnce({ rows: [{ lineage_id: 'label-a' }] });
|
||||
const director = new SessionDirector(db);
|
||||
const signal = await director.detectAntiLoop({} as any, {} as any, [], variedRecentPlays);
|
||||
expect(signal).toEqual({ dimension: 'label', values: ['label-a'] });
|
||||
const sql = (db.pgClient.query as any).mock.calls[1][0] as string;
|
||||
expect(sql).toContain("cf.predicate = 'same_label_as'");
|
||||
expect(sql).toContain('cf.subject_id = recent.artist_id OR cf.object_id = recent.artist_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planner metadata and integration boundaries', () => {
|
||||
it('counts every completed play in a budget horizon while only classifying known values', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [{ value: 'rock', cnt: 3 }, { value: null, cnt: 2 }],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const usage = await (director as any).loadBudgetUsage('user-1', 'genre', 30);
|
||||
expect(usage).toMatchObject({ total: 5, spent: 0.6 });
|
||||
expect(usage.values).toEqual(new Map([['rock', 3]]));
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('WITH completed_plays AS');
|
||||
expect(sql).not.toContain('WHERE value IS NOT NULL');
|
||||
});
|
||||
|
||||
it('loads producer and label lineage from fused relationships of the resolved main artist', async () => {
|
||||
const db = makeMockDb();
|
||||
(db.pgClient.query as any).mockResolvedValue({
|
||||
rows: [{
|
||||
track_id: 'track-1', artist_id: 'artist-1', album_id: 'album-1', genre_id: null,
|
||||
language: null, instrumentalness: null, favorite: false, new_artist: false,
|
||||
energy: null, bpm: null, valence: null, release_date: null,
|
||||
producer_ids: ['producer-from-object', 'producer-from-subject'],
|
||||
label_ids: ['label-from-object', 'label-from-subject'],
|
||||
}],
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const metadata = await (director as any).loadConstraintMetadata('user-1', ['track-1']);
|
||||
expect(metadata.get('track-1')).toMatchObject({
|
||||
artistId: 'artist-1',
|
||||
producerIds: ['producer-from-object', 'producer-from-subject'],
|
||||
labelIds: ['label-from-object', 'label-from-subject'],
|
||||
});
|
||||
const sql = (db.pgClient.query as any).mock.calls[0][0] as string;
|
||||
expect(sql).toContain('FROM claim_fusion cf');
|
||||
expect(sql).toContain("cf.predicate = 'produced'");
|
||||
expect(sql).toContain("cf.predicate = 'same_label_as'");
|
||||
expect(sql).toContain('cf.subject_id = artist.artist_id OR cf.object_id = artist.artist_id');
|
||||
expect(sql).toContain('ORDER BY ta.confidence DESC, ta.artist_id');
|
||||
});
|
||||
|
||||
it('carries the retained tail and all 40 album-history plays through replan into constraint selection', async () => {
|
||||
const db = makeMockDb({
|
||||
getVibeSessionTrackIds: vi.fn().mockResolvedValue([]),
|
||||
getListenerBeliefs: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const director = new SessionDirector(db);
|
||||
const history = Array.from({ length: 40 }, (_, index) => ({
|
||||
track_id: `history-${index}`, album_id: index < 3 ? 'history-album' : `old-album-${index}`,
|
||||
artist_id: `history-artist-${index}`, genre_id: null, bpm: null, energy: null,
|
||||
valence: null, instrumentalness: null, language: null, release_date: null,
|
||||
producer_ids: [], label_ids: [],
|
||||
}));
|
||||
(db.pgClient.query as any).mockImplementation((sql: string, params: unknown[] = []) => {
|
||||
if (sql.includes('FROM play_history ph') && sql.includes('LIMIT $2')) return Promise.resolve({ rows: history });
|
||||
if (sql.includes('WHERE t.id = ANY($2::uuid[])')) {
|
||||
const ids = params[1] as string[];
|
||||
return Promise.resolve({ rows: ids.map(trackId => ({
|
||||
track_id: trackId,
|
||||
artist_id: `artist-${trackId}`,
|
||||
album_id: trackId === 'history-album-candidate' ? 'history-album' : `album-${trackId}`,
|
||||
genre_id: null, language: null, instrumentalness: null, favorite: false,
|
||||
new_artist: false, energy: null, bpm: null, valence: null, release_date: null,
|
||||
producer_ids: trackId === 'producer-loop-candidate' ? ['producer-loop'] : [],
|
||||
label_ids: [],
|
||||
})) });
|
||||
}
|
||||
return Promise.resolve({ rows: [] });
|
||||
});
|
||||
vi.spyOn(director, 'buildState').mockResolvedValue({
|
||||
energy: 0.5, noveltyHunger: 0.3, sessionAgeMin: 0, lastArtistIds: [], lastGenreIds: [], context: null,
|
||||
});
|
||||
vi.spyOn(director, 'computeFatigue').mockResolvedValue({
|
||||
artist: new Map(), album: new Map(), genre: new Map(), language: new Map(), track: new Map(), vocal: 0.5,
|
||||
});
|
||||
vi.spyOn(director, 'getBudgets').mockResolvedValue([]);
|
||||
vi.spyOn(director, 'buildRepetitionState').mockResolvedValue({ recentTrackIds: new Set(), recentArtistIds: new Set() });
|
||||
vi.spyOn(director, 'rankCandidates').mockImplementation(async candidates => candidates);
|
||||
vi.spyOn(director, 'detectAntiLoop').mockResolvedValue({ dimension: 'producer', values: ['producer-loop'] });
|
||||
vi.spyOn(director as any, 'loadArtistMap').mockResolvedValue(new Map());
|
||||
|
||||
const originalGenerators = [...ALL_GENERATORS];
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, async () => [
|
||||
{ ...candidate('producer-loop-candidate'), generatorId: 'comfort' },
|
||||
{ ...candidate('history-album-candidate'), generatorId: 'comfort' },
|
||||
...Array.from({ length: 5 }, (_, index) => ({ ...candidate(`safe-candidate-comfort-${index}`), generatorId: 'comfort' })),
|
||||
...Array.from({ length: 4 }, (_, index) => ({ ...candidate(`safe-candidate-adjacent-${index}`), generatorId: 'adjacent' })),
|
||||
...Array.from({ length: 2 }, (_, index) => ({ ...candidate(`safe-candidate-favorite-${index}`), generatorId: 'deep-dive' })),
|
||||
]);
|
||||
let captured: any;
|
||||
vi.spyOn(director as any, 'constrainedSequence').mockImplementation((params: any) => {
|
||||
captured = params;
|
||||
return selectConstrainedSequence(params);
|
||||
});
|
||||
try {
|
||||
const retained = Array.from({ length: 9 }, (_, index) => ({ ...candidate(`queued-${index}`), generatorId: 'comfort' }));
|
||||
const plan = await director.replan('user-1', 'session-1', retained, []);
|
||||
expect(captured.retainedPlan.map((item: { trackId: string }) => item.trackId)).toEqual(retained.map(item => item.trackId));
|
||||
expect(captured.albumHistory).toHaveLength(40);
|
||||
expect(captured.loopDimension).toBe('producer');
|
||||
expect(captured.metadata.get('producer-loop-candidate').producerIds).toEqual(['producer-loop']);
|
||||
expect(plan.map(item => item.trackId)).not.toContain('history-album-candidate');
|
||||
expect(plan.map(item => item.trackId)).toContain('safe-candidate-comfort-0');
|
||||
} finally {
|
||||
ALL_GENERATORS.splice(0, ALL_GENERATORS.length, ...originalGenerators);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildState', () => {
|
||||
it('returns state with default values when no prior session', async () => {
|
||||
const db = makeMockDb();
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DbService } from './db.service.js';
|
||||
import {
|
||||
DEFAULT_VIBE_POLICY_VERSION,
|
||||
VibeSessionCoordinator,
|
||||
VibeSessionLifecycleError,
|
||||
VibePlanNotFoundError,
|
||||
} from './vibe-session-coordinator.service.js';
|
||||
|
||||
const SESSION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
const TRACK_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const EVENT_ID = '33333333-3333-4333-8333-333333333333';
|
||||
|
||||
function session(status: 'active' | 'ended' = 'active') {
|
||||
return {
|
||||
id: SESSION_ID, user_id: 'user-1', status, seed_track_id: null,
|
||||
context: {}, policy_version: DEFAULT_VIBE_POLICY_VERSION,
|
||||
started_at: new Date('2026-01-01T00:00:00.000Z'),
|
||||
last_event_at: new Date('2026-01-01T00:00:00.000Z'), ended_at: status === 'ended' ? new Date() : null,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function plan() {
|
||||
return {
|
||||
id: 'plan-1', session_id: SESSION_ID, version: 1, reason: 'session_started',
|
||||
state_snapshot: { energy: 0.5 }, objective_snapshot: {}, created_at: new Date(),
|
||||
items: [{
|
||||
plan_version_id: 'plan-1', ordinal: 0, track_id: TRACK_ID, slot_role: 'next',
|
||||
candidate_source: 'comfort', score: 0.8, score_breakdown: { relevance: 0.8 },
|
||||
explanation: [], committed: false,
|
||||
}],
|
||||
} as any;
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const db = {
|
||||
createVibeSession: vi.fn().mockResolvedValue(session()),
|
||||
createSessionState: vi.fn().mockResolvedValue(SESSION_ID),
|
||||
recordVibeEvent: vi.fn().mockResolvedValue({ event: { id: 'event-1' }, inserted: true }),
|
||||
persistVibePlan: vi.fn().mockResolvedValue(plan()),
|
||||
publishVibePlan: vi.fn().mockImplementation((input: { version?: number; reason: string }) => Promise.resolve({
|
||||
...plan(), version: input.version ?? 2, reason: input.reason,
|
||||
})),
|
||||
getVibeSession: vi.fn().mockResolvedValue(session()),
|
||||
getVibePlan: vi.fn().mockResolvedValue(plan()),
|
||||
endVibeSession: vi.fn().mockResolvedValue(session('ended')),
|
||||
endVibeSessionWithEvent: vi.fn().mockResolvedValue({ session: session('ended'), ended: true }),
|
||||
resumeVibeSession: vi.fn().mockResolvedValue({ session: session(), resumed: true }),
|
||||
serveNextVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
|
||||
advancePastUnplayableVibePlanItem: vi.fn().mockResolvedValue({ item: plan().items[0], stale: false }),
|
||||
persistNextVibePlan: vi.fn().mockResolvedValue({ ...plan(), version: 2, reason: 'feedback:completed' }),
|
||||
getVibePlanForFeedbackEvent: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as DbService;
|
||||
const director = {
|
||||
buildPlan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
||||
buildState: vi.fn().mockResolvedValue({ energy: 0.5, noveltyHunger: 0.3 }),
|
||||
replan: vi.fn().mockResolvedValue([{ trackId: TRACK_ID, generatorId: 'comfort', relevance: 0.8, explanation: [] }]),
|
||||
} as any;
|
||||
return { db, director, coordinator: new VibeSessionCoordinator(db, director) };
|
||||
}
|
||||
|
||||
describe('VibeSessionCoordinator', () => {
|
||||
it('creates an authoritative session, shadow state, initial plan revision, and ledger events', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
|
||||
const response = await coordinator.start('user-1', {});
|
||||
|
||||
expect(response).toMatchObject({ sessionId: SESSION_ID, planVersion: 1, now: { track_id: TRACK_ID } });
|
||||
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: 'user-1', policyVersion: DEFAULT_VIBE_POLICY_VERSION,
|
||||
}));
|
||||
expect(db.createSessionState).toHaveBeenCalledWith('user-1', undefined, expect.any(Object), SESSION_ID);
|
||||
expect(director.buildPlan).toHaveBeenCalledWith('user-1', SESSION_ID, undefined);
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionId: SESSION_ID, version: 1, reason: 'session_started',
|
||||
items: [expect.objectContaining({ track_id: TRACK_ID, committed: false })],
|
||||
}));
|
||||
expect((db.recordVibeEvent as any).mock.calls.map(([input]: any[]) => input.type))
|
||||
.toEqual(['session_started']);
|
||||
});
|
||||
|
||||
it('persists a director-selected arc role and explainable sequence score', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
(director.buildPlan as any).mockResolvedValueOnce([{
|
||||
trackId: TRACK_ID, generatorId: 'discovery', relevance: 0.7, explanation: [{ predicate: 'near' }],
|
||||
plan: {
|
||||
slotRole: 'surprise', score: 0.82,
|
||||
scoreBreakdown: { relevance: 0.7, transition: 0.9, arcTarget: 0.85 },
|
||||
explanation: { arcRole: 'surprise', surprise: { recoveryRole: 'favorite' } },
|
||||
},
|
||||
}]);
|
||||
|
||||
await coordinator.start('user-1', {});
|
||||
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
items: [expect.objectContaining({
|
||||
slot_role: 'surprise', score: 0.82,
|
||||
score_breakdown: expect.objectContaining({ transition: 0.9 }),
|
||||
explanation: expect.objectContaining({
|
||||
paths: [{ predicate: 'near' }],
|
||||
planner: expect.objectContaining({ arcRole: 'surprise' }),
|
||||
}),
|
||||
})],
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the canonical replacement on an idempotent material-event retry without replanning', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'skipped' }, inserted: false,
|
||||
});
|
||||
(db.getVibePlanForFeedbackEvent as any).mockResolvedValueOnce({ ...plan(), version: 2 });
|
||||
|
||||
const response = await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'skipped', trackId: TRACK_ID,
|
||||
});
|
||||
|
||||
expect(response).toMatchObject({ idempotent: true, planVersion: 2, replanned: false, replanReason: null });
|
||||
expect(db.recordVibeEvent).toHaveBeenCalledWith(expect.objectContaining({ clientEventId: EVENT_ID }));
|
||||
expect(db.persistNextVibePlan).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recovers a material feedback replan when its first persistence attempt failed', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('temporary database failure'));
|
||||
await expect(coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
|
||||
})).rejects.toThrow('temporary database failure');
|
||||
|
||||
(db.recordVibeEvent as any).mockResolvedValueOnce({
|
||||
event: { id: 'event-1', client_event_id: EVENT_ID, type: 'completed' }, inserted: false,
|
||||
});
|
||||
const recovered = await coordinator.appendEvent('user-1', SESSION_ID, {
|
||||
eventId: EVENT_ID, type: 'completed', trackId: TRACK_ID,
|
||||
});
|
||||
|
||||
expect(db.publishVibePlan).toHaveBeenCalledTimes(2);
|
||||
expect(recovered).toMatchObject({ idempotent: true, replanned: true, planVersion: 2 });
|
||||
});
|
||||
|
||||
it('persists a replacement revision for material feedback and returns its preview', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
const response = await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
|
||||
|
||||
expect(director.replan).toHaveBeenCalledWith(
|
||||
'user-1', SESSION_ID, expect.any(Array), [TRACK_ID], TRACK_ID,
|
||||
{ excludedTrackIds: new Set() },
|
||||
);
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({
|
||||
sessionId: SESSION_ID, reason: 'feedback:completed', items: [expect.objectContaining({ committed: false })],
|
||||
}));
|
||||
expect(response).toMatchObject({ replanned: true, replanReason: 'feedback:completed', planVersion: 2 });
|
||||
});
|
||||
|
||||
it('creates a neutral durable profile until listening behaviour provides evidence', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
|
||||
await coordinator.start('user-1', {});
|
||||
|
||||
expect(db.createVibeSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
profile: expect.objectContaining({
|
||||
goals: { type: 'discovery', target: 1, progress: 0 },
|
||||
explorationCoefficient: 0.3,
|
||||
discoveryRadius: 0.38,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('passes the durable unserved callback tail into the retention-aware replan', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValue({
|
||||
...plan(),
|
||||
items: [{
|
||||
...plan().items[0],
|
||||
slot_role: 'favorite',
|
||||
explanation: {
|
||||
paths: [],
|
||||
planner: { arcRole: 'favorite', callback: { id: 'theme:0', phase: 'return' } },
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: 'other-track' });
|
||||
|
||||
expect(director.replan).toHaveBeenCalledWith(
|
||||
'user-1', SESSION_ID,
|
||||
[expect.objectContaining({
|
||||
trackId: TRACK_ID,
|
||||
plan: expect.objectContaining({ slotRole: 'favorite', explanation: expect.objectContaining({ arcRole: 'favorite' }) }),
|
||||
})],
|
||||
['other-track'], 'other-track', { excludedTrackIds: new Set() },
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the durable seed excluded when feedback supplies a different local replan anchor', async () => {
|
||||
const { db, director, coordinator } = setup();
|
||||
const seedTrackId = '44444444-4444-4444-8444-444444444444';
|
||||
(db.getVibeSession as any).mockResolvedValue({ ...session(), seed_track_id: seedTrackId });
|
||||
|
||||
await coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed', trackId: TRACK_ID });
|
||||
|
||||
expect(director.replan).toHaveBeenCalledWith(
|
||||
'user-1',
|
||||
SESSION_ID,
|
||||
expect.any(Array),
|
||||
[TRACK_ID],
|
||||
TRACK_ID,
|
||||
{ excludedTrackIds: new Set([seedTrackId]) },
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes only the caller-owned session and serves a plan item through the durable API', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
const resumed = await coordinator.start('user-1', { resumeSessionId: SESSION_ID });
|
||||
const served = await coordinator.serveNext('user-1', SESSION_ID);
|
||||
|
||||
expect(db.resumeVibeSession).toHaveBeenCalledWith(SESSION_ID, 'user-1');
|
||||
expect(resumed.sessionId).toBe(SESSION_ID);
|
||||
expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1');
|
||||
expect(served.now).toMatchObject({ track_id: TRACK_ID });
|
||||
});
|
||||
|
||||
it('returns a lifecycle conflict when the ledger rejects a new terminal-session event', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.recordVibeEvent as any).mockRejectedValueOnce(new Error('Cannot record a new event for ended Vibe session'));
|
||||
|
||||
await expect(coordinator.appendEvent('user-1', SESSION_ID, { type: 'completed' }))
|
||||
.rejects.toBeInstanceOf(VibeSessionLifecycleError);
|
||||
});
|
||||
|
||||
it('distinguishes a missing requested revision from an empty latest plan', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValueOnce(null);
|
||||
await expect(coordinator.getPlan('user-1', SESSION_ID, 99)).rejects.toBeInstanceOf(VibePlanNotFoundError);
|
||||
});
|
||||
|
||||
it('returns 404-worthy failure when a session has no latest plan', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.getVibePlan as any).mockResolvedValueOnce(null);
|
||||
await expect(coordinator.getPlan('user-1', SESSION_ID)).rejects.toBeInstanceOf(VibePlanNotFoundError);
|
||||
});
|
||||
|
||||
it('does not commit a stale version-aware next request and returns the current preview', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.serveNextVibePlanItem as any).mockResolvedValueOnce({ item: null, stale: true });
|
||||
const result = await coordinator.serveNext('user-1', SESSION_ID, 1);
|
||||
|
||||
expect(db.serveNextVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', 1);
|
||||
expect(result).toMatchObject({ planVersion: 1, now: { track_id: TRACK_ID } });
|
||||
});
|
||||
|
||||
it('advances past an unplayable served item using a distinct idempotent event protocol', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
const planVersionId = '44444444-4444-4444-8444-444444444444';
|
||||
const eventId = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
const result = await coordinator.advancePastUnplayable('user-1', SESSION_ID, {
|
||||
expectedPlanVersion: 1,
|
||||
planVersionId,
|
||||
ordinal: 0,
|
||||
trackId: TRACK_ID,
|
||||
eventId,
|
||||
});
|
||||
|
||||
expect(db.advancePastUnplayableVibePlanItem).toHaveBeenCalledWith(SESSION_ID, 'user-1', {
|
||||
expectedPlanVersion: 1, planVersionId, ordinal: 0, trackId: TRACK_ID, eventId,
|
||||
});
|
||||
expect(result.now).toMatchObject({ track_id: TRACK_ID });
|
||||
});
|
||||
|
||||
it('ends an active session once and preserves its latest persisted plan', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
const response = await coordinator.end('user-1', SESSION_ID);
|
||||
|
||||
expect(db.endVibeSessionWithEvent).toHaveBeenCalledWith(SESSION_ID, 'user-1');
|
||||
expect(response.session.status).toBe('ended');
|
||||
expect(response.planVersion).toBe(1);
|
||||
});
|
||||
|
||||
it('does not publish an initial plan when another start replaced the session while planning', async () => {
|
||||
const { db, coordinator } = setup();
|
||||
(db.publishVibePlan as any).mockRejectedValueOnce(new Error('Cannot publish a plan for replaced Vibe session'));
|
||||
|
||||
await expect(coordinator.start('user-1', {})).rejects.toBeInstanceOf(VibeSessionLifecycleError);
|
||||
expect(db.publishVibePlan).toHaveBeenCalledWith(expect.objectContaining({ version: 1 }));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
import { DbService, VibeEvent, VibePlan, VibeSession } from './db.service.js';
|
||||
import { SessionDirector } from './session-director.service.js';
|
||||
import { Candidate } 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;
|
||||
}
|
||||
|
||||
export interface AppendVibeEventInput {
|
||||
eventId?: string;
|
||||
type: VibeEventType;
|
||||
trackId?: string;
|
||||
occurredAt?: Date;
|
||||
positionMs?: number;
|
||||
durationMs?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
replanned: boolean;
|
||||
replanReason: string | null;
|
||||
}
|
||||
|
||||
export class VibeSessionCoordinator {
|
||||
constructor(
|
||||
private readonly db: DbService,
|
||||
private readonly director: Pick<SessionDirector, 'buildPlan' | 'buildState' | 'replan'>,
|
||||
) {}
|
||||
|
||||
async start(userId: string, input: StartVibeSessionInput): Promise<VibeSessionResponse> {
|
||||
if (input.resumeSessionId) return this.resume(userId, input.resumeSessionId);
|
||||
// Vibe has no reliable device/activity/location signal. Start from neutral
|
||||
// recommendation state and let actual listening behaviour shape the plan.
|
||||
const initialState = {
|
||||
energy: 0.5,
|
||||
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,
|
||||
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,
|
||||
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 },
|
||||
});
|
||||
|
||||
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<VibeSessionResponse> {
|
||||
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<VibeSessionResponse> {
|
||||
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<VibeSessionResponse> {
|
||||
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<VibeSessionResponse & { event: VibeEvent; idempotent: boolean }> {
|
||||
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<DbService>).projectVibeSessionFeedback;
|
||||
if (projectSessionFeedback) await projectSessionFeedback.call(this.db, result.event);
|
||||
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<string>([
|
||||
...(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<VibeSessionResponse> {
|
||||
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<VibeSessionResponse> {
|
||||
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<VibeSession> {
|
||||
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<string, unknown>,
|
||||
): 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, 8) ?? [];
|
||||
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<string, unknown> } : 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<VibeEventType>(['skipped', 'disliked', 'completed', 'kept']);
|
||||
|
||||
function isMaterialFeedback(type: VibeEventType): boolean {
|
||||
return MATERIAL_FEEDBACK_EVENTS.has(type);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
|
||||
const { advancePastUnplayableVibeTrack, reportVibeEvent } = vi.hoisted(() => ({
|
||||
advancePastUnplayableVibeTrack: vi.fn().mockResolvedValue(undefined),
|
||||
reportVibeEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock('../services/vibeSession', () => ({ advancePastUnplayableVibeTrack, reportVibeEvent }));
|
||||
|
||||
import { AudioEngine } from './AudioEngine';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
|
||||
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
describe('AudioEngine', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined);
|
||||
vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined);
|
||||
useVibeStore.getState().reset();
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'session-a', seedTrackId: 'song' });
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: track('song'), queue: [track('song')], currentIndex: 0, isPlaying: false,
|
||||
queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('uses the durable unplayable advancement when a Vibe stream errors after metadata resolved', async () => {
|
||||
const { container } = render(<AudioEngine />);
|
||||
const audio = container.querySelector('audio')!;
|
||||
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
|
||||
await waitFor(() => expect(advancePastUnplayableVibeTrack).toHaveBeenCalledWith('song'));
|
||||
expect(advancePastUnplayableVibeTrack).toHaveBeenCalledTimes(1);
|
||||
expect(reportVibeEvent).not.toHaveBeenCalledWith('skipped', 'song');
|
||||
});
|
||||
});
|
||||
@@ -2,17 +2,9 @@ import { useEffect, useRef } from 'react';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { vibeService } from '../services/vibeService';
|
||||
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// Track ids whose next natural feedback transition should be skipped because
|
||||
// the caller (e.g. Vibe.tsx's dislike button) already recorded feedback for
|
||||
// them explicitly. Consumed once, then cleared.
|
||||
const suppressedFeedbackIds = new Set<string>();
|
||||
export function suppressAutoFeedback(trackId: string): void {
|
||||
suppressedFeedbackIds.add(trackId);
|
||||
}
|
||||
|
||||
// Threshold (seconds) above which a store position change is treated as a user
|
||||
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
|
||||
// effect loop from fighting itself.
|
||||
@@ -48,6 +40,8 @@ export const AudioEngine = () => {
|
||||
const endedNaturallyRef = useRef(false);
|
||||
// Track whether the current track has crossed the completion threshold.
|
||||
const crossedThresholdRef = useRef(false);
|
||||
const lastProgressSecondRef = useRef(-1);
|
||||
const streamErrorTrackIdRef = useRef<string | null>(null);
|
||||
|
||||
// --- DOM -> store: media events -----------------------------------------
|
||||
useEffect(() => {
|
||||
@@ -66,6 +60,13 @@ export const AudioEngine = () => {
|
||||
) {
|
||||
crossedThresholdRef.current = true;
|
||||
}
|
||||
const vibe = useVibeStore.getState();
|
||||
const track = store().currentTrack;
|
||||
const elapsed = Math.floor(audio.currentTime);
|
||||
if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
|
||||
lastProgressSecondRef.current = elapsed;
|
||||
void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
const onLoadedMetadata = () => {
|
||||
if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
|
||||
@@ -83,7 +84,20 @@ export const AudioEngine = () => {
|
||||
// feedback, on the resulting track-change, so completion is recorded
|
||||
// exactly once per track.
|
||||
endedNaturallyRef.current = true;
|
||||
store().next();
|
||||
store().nextWithReason('completed');
|
||||
};
|
||||
const onError = () => {
|
||||
const playback = store();
|
||||
const track = playback.currentTrack;
|
||||
const vibe = useVibeStore.getState();
|
||||
// Metadata can be available while the stream itself is no longer
|
||||
// readable. Vibe must advance that exact durable cursor, not fall back
|
||||
// to ordinary queue navigation or feedback-driven replanning.
|
||||
if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
|
||||
streamErrorTrackIdRef.current = track.id;
|
||||
void advancePastUnplayableVibeTrack(track.id)
|
||||
.catch(() => undefined)
|
||||
.finally(() => { streamErrorTrackIdRef.current = null; });
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
@@ -91,6 +105,7 @@ export const AudioEngine = () => {
|
||||
audio.addEventListener('play', onPlay);
|
||||
audio.addEventListener('pause', onPause);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
audio.addEventListener('error', onError);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
@@ -98,6 +113,7 @@ export const AudioEngine = () => {
|
||||
audio.removeEventListener('play', onPlay);
|
||||
audio.removeEventListener('pause', onPause);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
audio.removeEventListener('error', onError);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -109,28 +125,19 @@ export const AudioEngine = () => {
|
||||
const applyTrack = (id: string | null) => {
|
||||
if (id === loadedIdRef.current) return;
|
||||
|
||||
// The previously loaded track is changing. If it didn't end naturally and
|
||||
// hadn't crossed the completion threshold, record a skip (best-effort).
|
||||
// If it crossed the threshold OR ended naturally, record as completed.
|
||||
// The durable Vibe controller owns normal next/ended navigation. It
|
||||
// records the outcome, receives a new plan revision, then calls the raw
|
||||
// advance method. Do not emit a second event here after that transition.
|
||||
const prevId = loadedIdRef.current;
|
||||
const completed = endedNaturallyRef.current || crossedThresholdRef.current;
|
||||
// Only vibe sessions want this feedback — plain library browsing
|
||||
// shouldn't write skip/completed evidence for tracks merely sampled.
|
||||
const inVibeSession = !!useVibeStore.getState().activeSessionId;
|
||||
if (prevId && inVibeSession) {
|
||||
if (suppressedFeedbackIds.delete(prevId)) {
|
||||
// Caller already recorded explicit feedback (e.g. dislike) for
|
||||
// this track — don't also record the implicit transition.
|
||||
} else {
|
||||
try {
|
||||
void vibeService.feedback(prevId, completed ? 'completed' : 'skipped', useVibeStore.getState().activeSessionId ?? undefined).catch(() => {});
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
const playback = usePlaybackStore.getState();
|
||||
const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
|
||||
if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
|
||||
void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
|
||||
}
|
||||
endedNaturallyRef.current = false;
|
||||
crossedThresholdRef.current = false;
|
||||
lastProgressSecondRef.current = -1;
|
||||
loadedIdRef.current = id;
|
||||
|
||||
if (!id) {
|
||||
@@ -141,6 +148,7 @@ export const AudioEngine = () => {
|
||||
|
||||
audio.src = trackService.getStreamUrl(id);
|
||||
audio.load();
|
||||
if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);
|
||||
if (usePlaybackStore.getState().isPlaying) {
|
||||
void audio.play().catch(() => {});
|
||||
}
|
||||
|
||||
@@ -26,9 +26,11 @@ interface TrackRowProps {
|
||||
showVibe?: boolean;
|
||||
/** Override ordinary queue playback, for contextual actions such as Vibe seed rows. */
|
||||
onSelect?: (track: Track) => void;
|
||||
/** Display-only rows keep their surrounding playback controller authoritative. */
|
||||
playable?: boolean;
|
||||
}
|
||||
|
||||
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect }: TrackRowProps) {
|
||||
export function TrackRow({ track, queue, showActions = true, variant = 'default', showVibe = false, onSelect, playable = true }: TrackRowProps) {
|
||||
const { setQueue, playTrack, play, pause, currentTrack, isPlaying } = usePlaybackStore();
|
||||
const dislikeTrack = useDislikeTrack();
|
||||
const router = useRouter();
|
||||
@@ -36,6 +38,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
const compact = variant === 'compact';
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!playable) return;
|
||||
if (onSelect) {
|
||||
onSelect(track);
|
||||
return;
|
||||
@@ -47,7 +50,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
playTrack(track);
|
||||
};
|
||||
|
||||
const playLabel = isCurrent && isPlaying
|
||||
const playLabel = !playable
|
||||
? `${track.title || 'Track'} is queued by Vibe`
|
||||
: isCurrent && isPlaying
|
||||
? `Pause ${track.title || 'track'}`
|
||||
: `Play ${track.title || 'track'}`;
|
||||
|
||||
@@ -81,8 +86,9 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
disabled={!playable}
|
||||
aria-label={playLabel}
|
||||
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 ${
|
||||
className={`relative flex flex-none items-center justify-center rounded overflow-hidden focus-visible:z-30 disabled:cursor-default disabled:opacity-70 ${
|
||||
compact ? 'h-9 w-9' : 'h-10 w-10'
|
||||
}`}>
|
||||
<Artwork seed={`${track.title} ${track.artist}`} src={track.artwork_id} className="absolute inset-0 w-full h-full" />
|
||||
@@ -98,6 +104,7 @@ export function TrackRow({ track, queue, showActions = true, variant = 'default'
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
disabled={!playable}
|
||||
className={`block max-w-full truncate rounded text-left font-medium hover:underline ${isCurrent ? 'text-accent' : 'text-text'} ${compact ? 'text-sm' : 'text-sm'}`}
|
||||
aria-label={playLabel}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { VibeTimeline } from './VibeTimeline';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album',
|
||||
duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
describe('VibeTimeline', () => {
|
||||
beforeEach(() => {
|
||||
const current = track('current');
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: current, queue: [current, track('upcoming')], currentIndex: 0,
|
||||
isPlaying: true, queueOwner: 'vibe', vibeAdvanceHandler: () => undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders upcoming plan entries as display-only so they cannot hand queue ownership to ordinary playback', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<VibeTimeline currentTrack={track('current')} upcoming={[track('upcoming')]} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
const queued = screen.getAllByRole('button', { name: 'upcoming is queued by Vibe' });
|
||||
expect(queued).toHaveLength(2);
|
||||
expect(queued[0]).toBeDisabled();
|
||||
expect(queued[1]).toBeDisabled();
|
||||
await user.click(queued[0]);
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
queueOwner: 'vibe', currentTrack: track('current'),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export function VibeTimeline({ currentTrack, upcoming }: VibeTimelineProps) {
|
||||
index={0}
|
||||
showActions={false}
|
||||
variant="compact"
|
||||
playable={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+13
-109
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Heart, Loader2, Play, Shuffle, ThumbsDown, Sparkles, Square } from 'lucide-react';
|
||||
import { vibeService, fetchNextBatch } from '../services/vibeService';
|
||||
import { startVibeSession } from '../services/vibeSession';
|
||||
import { advanceVibe, endVibeSession, reportVibeEvent, startVibeSession, vibeErrorMessage } from '../services/vibeSession';
|
||||
import { trackService } from '../services/trackService';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
@@ -10,17 +9,10 @@ import { TrackRow } from '../components/TrackRow';
|
||||
import { PageContainer } from '../components/PageContainer';
|
||||
import type { Track } from '../types';
|
||||
import { VibeTimeline } from '../components/VibeTimeline';
|
||||
import { suppressAutoFeedback } from '../components/AudioEngine';
|
||||
import { toast } from '../store/useToastStore';
|
||||
|
||||
const PREFETCH_THRESHOLD = 3;
|
||||
const PREFETCH_BATCH_SIZE = 3;
|
||||
const SEED_LIST_SIZE = 50;
|
||||
|
||||
function bestEffort(p: Promise<unknown>): void {
|
||||
void p.catch(() => undefined);
|
||||
}
|
||||
|
||||
function sampleTracks(tracks: Track[], count: number): Track[] {
|
||||
const sampled = [...tracks];
|
||||
for (let index = sampled.length - 1; index > 0; index--) {
|
||||
@@ -31,23 +23,17 @@ function sampleTracks(tracks: Track[], count: number): Track[] {
|
||||
}
|
||||
|
||||
export default function Vibe() {
|
||||
const { currentTrack, queue, setQueue, next: playNext, pause, setCurrentTrack } = usePlaybackStore();
|
||||
const { currentTrack } = usePlaybackStore();
|
||||
const {
|
||||
activeSessionId,
|
||||
buffer,
|
||||
initialBatchStatus,
|
||||
setBuffer,
|
||||
appendBuffer,
|
||||
reset,
|
||||
planVersion,
|
||||
} = useVibeStore();
|
||||
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [prefetching, setPrefetching] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [empty, setEmpty] = useState(false);
|
||||
const [refillStatus, setRefillStatus] = useState<'idle' | 'exhausted' | 'failed'>('idle');
|
||||
const [refillAttempt, setRefillAttempt] = useState(0);
|
||||
const prefetchingRef = useRef(false);
|
||||
const startingRef = useRef(false);
|
||||
|
||||
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({
|
||||
@@ -66,17 +52,14 @@ export default function Vibe() {
|
||||
setStarting(true);
|
||||
setError(null);
|
||||
setEmpty(false);
|
||||
setRefillStatus('idle');
|
||||
try {
|
||||
const result = await startVibeSession(seed);
|
||||
if (result.tracks.length === 0) {
|
||||
setEmpty(true);
|
||||
if (result.status === 'failed') {
|
||||
setError('Could not load recommendations for this vibe. Please try another seed.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setError('Could not start a vibe session. Please try again.');
|
||||
} catch (startError) {
|
||||
setError(vibeErrorMessage(startError));
|
||||
} finally {
|
||||
startingRef.current = false;
|
||||
setStarting(false);
|
||||
@@ -95,91 +78,25 @@ export default function Vibe() {
|
||||
void startSession(seed);
|
||||
}, [libraryTracks, startSession]);
|
||||
|
||||
const remaining = currentTrack
|
||||
? queue.length - (queue.findIndex((t) => t.id === currentTrack.id) + 1)
|
||||
: queue.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || prefetchingRef.current) return;
|
||||
if (initialBatchStatus === 'loading') return;
|
||||
if (refillStatus !== 'idle') return;
|
||||
if (remaining > PREFETCH_THRESHOLD) return;
|
||||
|
||||
prefetchingRef.current = true;
|
||||
setPrefetching(true);
|
||||
fetchNextBatch(PREFETCH_BATCH_SIZE, activeSessionId)
|
||||
.then((result) => {
|
||||
if (result.tracks.length > 0) {
|
||||
const current = usePlaybackStore.getState().queue;
|
||||
const currentIds = new Set(current.map((t) => t.id));
|
||||
const fresh = result.tracks.filter((t) => !currentIds.has(t.id));
|
||||
if (fresh.length > 0) {
|
||||
appendBuffer(fresh);
|
||||
setQueue([...current, ...fresh]);
|
||||
}
|
||||
if (result.status === 'exhausted' || fresh.length === 0) {
|
||||
setRefillStatus('exhausted');
|
||||
}
|
||||
} else if (result.status === 'exhausted') {
|
||||
setRefillStatus('exhausted');
|
||||
} else {
|
||||
setRefillStatus('failed');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
prefetchingRef.current = false;
|
||||
setPrefetching(false);
|
||||
});
|
||||
}, [activeSessionId, initialBatchStatus, remaining, appendBuffer, refillAttempt, refillStatus, setQueue]);
|
||||
|
||||
// Trim buffer to prevent unbounded growth — keep only from currentTrack onward.
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !currentTrack || buffer.length === 0) return;
|
||||
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
|
||||
if (idx > 0) {
|
||||
setBuffer(buffer.slice(idx));
|
||||
}
|
||||
}, [activeSessionId, currentTrack, buffer, setBuffer]);
|
||||
|
||||
const handleKeep = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'promoted', activeSessionId ?? undefined));
|
||||
void reportVibeEvent('kept', currentTrack.id).catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
|
||||
toast.success(`Kept "${currentTrack.title}"`);
|
||||
}
|
||||
}, [currentTrack]);
|
||||
|
||||
const handleDislike = useCallback(() => {
|
||||
if (currentTrack) {
|
||||
bestEffort(vibeService.feedback(currentTrack.id, 'disliked', activeSessionId ?? undefined));
|
||||
// AudioEngine would otherwise also record a 'skipped' on the track
|
||||
// change caused by playNext() below — suppress that duplicate.
|
||||
suppressAutoFeedback(currentTrack.id);
|
||||
void advanceVibe('disliked').catch((feedbackError) => setError(vibeErrorMessage(feedbackError)));
|
||||
}
|
||||
playNext();
|
||||
}, [currentTrack, playNext]);
|
||||
}, [currentTrack]);
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
// V2 plan expires via Redis TTL (2h). No explicit end endpoint.
|
||||
pause();
|
||||
setQueue([]);
|
||||
setCurrentTrack(null);
|
||||
reset();
|
||||
void endVibeSession().catch((endError) => setError(vibeErrorMessage(endError)));
|
||||
setEmpty(false);
|
||||
setError(null);
|
||||
setRefillStatus('idle');
|
||||
}, [reset, pause, setQueue, setCurrentTrack]);
|
||||
|
||||
const retryRefill = useCallback(() => {
|
||||
setRefillStatus('idle');
|
||||
setRefillAttempt((attempt) => attempt + 1);
|
||||
}, []);
|
||||
|
||||
const upcoming = currentTrack
|
||||
? (() => {
|
||||
const idx = buffer.findIndex((t) => t.id === currentTrack.id);
|
||||
return idx >= 0 ? buffer.slice(idx + 1) : buffer;
|
||||
})()
|
||||
: buffer;
|
||||
const upcoming = buffer;
|
||||
|
||||
// ---- Start screen (no active session) ----
|
||||
if (!activeSessionId) {
|
||||
@@ -272,7 +189,7 @@ export default function Vibe() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={22} className="text-accent" />
|
||||
<h1 className="text-2xl font-bold text-text">Vibing</h1>
|
||||
{prefetching && <Loader2 size={16} className="animate-spin text-muted" />}
|
||||
{initialBatchStatus === 'loading' && <Loader2 size={16} className="animate-spin text-muted" />}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
@@ -297,20 +214,7 @@ export default function Vibe() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refillStatus === 'exhausted' && !empty && (
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-200">
|
||||
This Vibe has no new recommendations to add. Playback will stop when the current queue ends; start a new Vibe to continue.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refillStatus === 'failed' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-red-500/40 bg-red-500/10 p-3 text-sm text-red-300">
|
||||
<span>Couldn't refresh the Vibe recommendations. Playback will stop when the current queue ends.</span>
|
||||
<button onClick={retryRefill} className="flex-none rounded border border-red-400/50 px-2 py-1 text-xs hover:bg-red-500/10">
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{planVersion && <p className="text-xs text-muted/70">Plan revision {planVersion}; upcoming tracks may change as you listen.</p>}
|
||||
|
||||
{currentTrack && (
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from '../types';
|
||||
import { fetchNextBatch, vibeService } from './vibeService';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
|
||||
album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL',
|
||||
play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
|
||||
vi.mock('./api', () => ({ default: { post, get } }));
|
||||
|
||||
import { vibeService } from './vibeService';
|
||||
|
||||
describe('durable vibe service', () => {
|
||||
it('serves the next item with the caller plan version', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
|
||||
|
||||
await vibeService.next('session', 3);
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', { expectedPlanVersion: 3 });
|
||||
});
|
||||
|
||||
function responseError(status: number, code?: string) {
|
||||
return new AxiosError('request failed', undefined, undefined, undefined, {
|
||||
data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never,
|
||||
});
|
||||
}
|
||||
it('uses an explicit idempotency key to advance a served but unplayable item', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
|
||||
|
||||
describe('fetchNextBatch', () => {
|
||||
it('uses the supplied session id and treats VIBE_PLAN_EXHAUSTED as terminal', async () => {
|
||||
const next = vi.spyOn(vibeService, 'next')
|
||||
.mockResolvedValueOnce({ track: track('one'), explanation: null, planRemaining: 0 })
|
||||
.mockRejectedValueOnce(responseError(409, 'VIBE_PLAN_EXHAUSTED'));
|
||||
|
||||
await expect(fetchNextBatch(3, 'session-a')).resolves.toEqual({ tracks: [track('one')], status: 'exhausted' });
|
||||
expect(next).toHaveBeenCalledWith('session-a');
|
||||
await vibeService.advancePastUnplayable('session', 3, {
|
||||
eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track',
|
||||
});
|
||||
|
||||
it('does not disguise a missing or replaced session as normal exhaustion', async () => {
|
||||
vi.spyOn(vibeService, 'next').mockRejectedValue(responseError(404));
|
||||
await expect(fetchNextBatch(1, 'expired-session')).resolves.toEqual({ tracks: [], status: 'failed' });
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', {
|
||||
expectedPlanVersion: 3,
|
||||
unplayable: { eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track' },
|
||||
});
|
||||
});
|
||||
|
||||
it('sends client event ids to the durable event ledger', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 2, now: null, preview: [] } });
|
||||
|
||||
await vibeService.event('session', {
|
||||
eventId: 'event', type: 'progress', occurredAt: '2026-01-01T00:00:00.000Z', trackId: 'track', positionMs: 30000,
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/events', expect.objectContaining({
|
||||
eventId: 'event', type: 'progress', trackId: 'track', positionMs: 30000,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,88 +1,101 @@
|
||||
import api from './api';
|
||||
import axios from 'axios';
|
||||
import type { Track } from '../types';
|
||||
|
||||
// A candidate from the v2 recommendation plan. The plan is stored server-side
|
||||
// in Redis; the frontend only needs trackId + explanation for display.
|
||||
// These types intentionally mirror the durable session API. Tracks are not
|
||||
// embedded in a plan revision: the client resolves ids through the normal
|
||||
// library endpoint so a deleted/hidden track can never become playable merely
|
||||
// because an older plan mentioned it.
|
||||
export interface VibePlanItem {
|
||||
trackId: string;
|
||||
generatorId: string;
|
||||
explanation: unknown[];
|
||||
relevance: number;
|
||||
plan_version_id: string;
|
||||
ordinal: number;
|
||||
track_id: string;
|
||||
slot_role: string | null;
|
||||
candidate_source: string;
|
||||
score: number;
|
||||
score_breakdown: Record<string, unknown>;
|
||||
explanation: unknown;
|
||||
committed: boolean;
|
||||
}
|
||||
|
||||
export interface VibeStartResponse {
|
||||
export interface DurableVibeSessionResponse {
|
||||
sessionId: string;
|
||||
plan: VibePlanItem[];
|
||||
planVersion: number | null;
|
||||
now: VibePlanItem | null;
|
||||
preview: VibePlanItem[];
|
||||
state: Record<string, unknown>;
|
||||
replanned: boolean;
|
||||
replanReason: string | null;
|
||||
}
|
||||
|
||||
export interface VibeNextResponse {
|
||||
track: Track;
|
||||
explanation: unknown[] | null;
|
||||
planRemaining: number;
|
||||
export type VibeEventType =
|
||||
| 'playback_started'
|
||||
| 'progress'
|
||||
| 'completed'
|
||||
| 'skipped'
|
||||
| 'disliked'
|
||||
| 'kept';
|
||||
|
||||
export interface VibeEventInput {
|
||||
eventId: string;
|
||||
type: VibeEventType;
|
||||
trackId?: string;
|
||||
occurredAt: string;
|
||||
positionMs?: number;
|
||||
durationMs?: number;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type VibeBatchStatus = 'complete' | 'exhausted' | 'failed';
|
||||
|
||||
export interface VibeBatchResult {
|
||||
tracks: Track[];
|
||||
status: VibeBatchStatus;
|
||||
export interface VibeEventResponse extends DurableVibeSessionResponse {
|
||||
event: { id: string; client_event_id: string | null; type: string };
|
||||
idempotent: boolean;
|
||||
}
|
||||
|
||||
export type VibeFeedbackAction = 'completed' | 'skipped' | 'promoted' | 'disliked';
|
||||
/** A durable, idempotent advancement past a plan item the player cannot load. */
|
||||
export interface VibeUnplayableItemInput {
|
||||
eventId: string;
|
||||
planVersionId: string;
|
||||
ordinal: number;
|
||||
trackId: string;
|
||||
}
|
||||
|
||||
// V2 recommendation engine. The backend stores the plan in Redis (2h TTL) and
|
||||
// serves tracks one at a time via GET /next. Feedback triggers replanning.
|
||||
export const vibeService = {
|
||||
// POST /api/v2/vibe/start { seedTrackId? } -> { sessionId, plan }
|
||||
async start(seedTrackId?: string): Promise<VibeStartResponse> {
|
||||
const res = await api.post<VibeStartResponse>('/v2/vibe/start', { seedTrackId });
|
||||
async start(seedTrackId?: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>('/v2/vibe/sessions', { seedTrackId });
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/v2/vibe/next -> { track, explanation, planRemaining }
|
||||
// Returns one track at a time, shifting the server-side plan.
|
||||
// 404 if no active plan — caller should handle gracefully.
|
||||
async next(sessionId: string): Promise<VibeNextResponse> {
|
||||
const res = await api.get<VibeNextResponse>('/v2/vibe/next', { params: { sessionId } });
|
||||
async getPlan(sessionId: string, version?: number): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.get<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/plans`, {
|
||||
params: version === undefined ? undefined : { version },
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// POST /api/v2/vibe/feedback { trackId, action } -> { status, planRemaining }
|
||||
// Action 'promoted' also calls addFavorite; 'disliked' also calls dislikeTrack.
|
||||
// Triggers replan of the remaining plan.
|
||||
async feedback(trackId: string, action: VibeFeedbackAction, sessionId?: string): Promise<{ status: string; planRemaining: number }> {
|
||||
const res = await api.post<{ status: string; planRemaining: number }>('/v2/vibe/feedback', { trackId, action, sessionId });
|
||||
async next(sessionId: string, expectedPlanVersion: number): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/advance`, {
|
||||
expectedPlanVersion,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
// GET /api/v2/vibe/plan -> { sessionId, planRemaining, plan }
|
||||
// Debug endpoint — returns the full remaining plan.
|
||||
async getPlan(): Promise<{ sessionId: string; planRemaining: number; plan: VibePlanItem[] }> {
|
||||
const res = await api.get('/v2/vibe/plan');
|
||||
async advancePastUnplayable(
|
||||
sessionId: string,
|
||||
expectedPlanVersion: number,
|
||||
unplayable: VibeUnplayableItemInput,
|
||||
): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/advance`, {
|
||||
expectedPlanVersion,
|
||||
unplayable,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async event(sessionId: string, event: VibeEventInput): Promise<VibeEventResponse> {
|
||||
const res = await api.post<VibeEventResponse>(`/v2/vibe/sessions/${sessionId}/events`, event);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
async end(sessionId: string): Promise<DurableVibeSessionResponse> {
|
||||
const res = await api.post<DurableVibeSessionResponse>(`/v2/vibe/sessions/${sessionId}/end`);
|
||||
return res.data;
|
||||
},
|
||||
};
|
||||
|
||||
// Fetch N tracks from the v2 plan sequentially. Each call to /next shifts the
|
||||
// server-side plan, so calls must be sequential (not parallel). Stops early on
|
||||
// 409/VIBE_PLAN_EXHAUSTED is a normal terminal condition. A missing/replaced
|
||||
// session is intentionally reported as a failure so callers can preserve the
|
||||
// current playback state rather than pretending the plan completed cleanly.
|
||||
export async function fetchNextBatch(count: number, sessionId: string): Promise<VibeBatchResult> {
|
||||
const tracks: Track[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
try {
|
||||
const { track } = await vibeService.next(sessionId);
|
||||
tracks.push(track);
|
||||
} catch (error) {
|
||||
return {
|
||||
tracks,
|
||||
status: axios.isAxiosError(error) && error.response?.status === 409 &&
|
||||
(error.response.data as { code?: string } | undefined)?.code === 'VIBE_PLAN_EXHAUSTED'
|
||||
? 'exhausted' : 'failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
return { tracks, status: 'complete' };
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { AxiosError } from 'axios';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
|
||||
const { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() }));
|
||||
vi.mock('./vibeService', () => ({
|
||||
vibeService: { start, next },
|
||||
fetchNextBatch: async (count: number, sessionId: string) => {
|
||||
const tracks: Track[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; }
|
||||
}
|
||||
return { tracks, status: 'complete' as const };
|
||||
},
|
||||
const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({
|
||||
start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(),
|
||||
}));
|
||||
vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } }));
|
||||
vi.mock('./trackService', () => ({ trackService: { getTrack } }));
|
||||
|
||||
import { startVibeSession } from './vibeSession';
|
||||
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
|
||||
@@ -23,34 +18,237 @@ const track = (id: string): Track => ({
|
||||
play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
describe('startVibeSession', () => {
|
||||
const item = (track_id: string, committed = false, ordinal = 0) => ({
|
||||
plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test',
|
||||
score: 1, score_breakdown: {}, explanation: [], committed,
|
||||
});
|
||||
|
||||
const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({
|
||||
sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null,
|
||||
});
|
||||
|
||||
describe('durable Vibe session client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useVibeStore.getState().reset();
|
||||
usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false });
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
|
||||
});
|
||||
getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
|
||||
});
|
||||
|
||||
it('does not replace a working Vibe when the new plan cannot hydrate', async () => {
|
||||
const old = track('old');
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id });
|
||||
usePlaybackStore.getState().setQueue([old]);
|
||||
usePlaybackStore.getState().playTrack(old);
|
||||
start.mockResolvedValue({ sessionId: 'new-session', plan: [] });
|
||||
next.mockRejectedValue(new Error('missing session'));
|
||||
it('starts by version-serving and hydrating the first durable plan item', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ tracks: [], status: 'failed' });
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] });
|
||||
|
||||
expect(next).toHaveBeenCalledWith('session-a', 1);
|
||||
expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] });
|
||||
expect(usePlaybackStore.getState().currentTrack).toEqual(track('one'));
|
||||
});
|
||||
|
||||
it('serializes rapid starts and hydrates only one session', async () => {
|
||||
const recommended = track('recommended');
|
||||
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
|
||||
next.mockResolvedValue({ track: recommended });
|
||||
it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('one', true), [item('stale')]))
|
||||
.mockResolvedValueOnce(response(2, item('two', true), [item('three')]));
|
||||
event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith('session-a');
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
|
||||
await advanceVibe('skipped');
|
||||
|
||||
expect(next).toHaveBeenLastCalledWith('session-a', 2);
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']);
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale');
|
||||
expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) }));
|
||||
});
|
||||
|
||||
it('replaces only the future when a keep event replans', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false });
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('one');
|
||||
expect(useVibeStore.getState().planVersion).toBe(2);
|
||||
});
|
||||
|
||||
it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
// The first response was lost after it published revision 2. Retrying the
|
||||
// same client event returns that revision with replanned=false.
|
||||
event.mockResolvedValue({
|
||||
...response(2, item('fresh'), [item('fresh'), item('later')]),
|
||||
replanned: false,
|
||||
event: {},
|
||||
idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await reportVibeEvent('kept', 'one');
|
||||
|
||||
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] });
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']);
|
||||
});
|
||||
|
||||
it('cleans up local playback when the durable session is gone', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('stale')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('stale')]));
|
||||
event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, {
|
||||
data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never,
|
||||
}));
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone');
|
||||
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false });
|
||||
});
|
||||
|
||||
it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
const ordinary = track('ordinary');
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setQueue([ordinary, track('ordinary-next')]);
|
||||
playback.playTrack(ordinary);
|
||||
playback.nextWithReason('completed');
|
||||
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null,
|
||||
});
|
||||
expect(event).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serializes material events and ignores an older plan revision', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('old')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('old')]));
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
let resolveFirst!: (value: ReturnType<typeof response> & { event: object; idempotent: boolean }) => void;
|
||||
event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; }));
|
||||
event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false });
|
||||
|
||||
const first = reportVibeEvent('kept', 'one');
|
||||
const second = reportVibeEvent('completed', 'one');
|
||||
await Promise.resolve();
|
||||
expect(event).toHaveBeenCalledTimes(1);
|
||||
resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false });
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(event).toHaveBeenCalledTimes(2);
|
||||
expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] });
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']);
|
||||
});
|
||||
|
||||
it('retries a failed event with the same idempotency key until it is acknowledged', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({
|
||||
...response(1), event: {}, idempotent: true,
|
||||
});
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await reportVibeEvent('progress', 'one', 30000, 180000);
|
||||
|
||||
expect(event).toHaveBeenCalledTimes(2);
|
||||
expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId);
|
||||
});
|
||||
|
||||
it('skips a hidden plan item and starts from the next playable item', async () => {
|
||||
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
|
||||
next
|
||||
.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
|
||||
advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
|
||||
getTrack.mockImplementation((id: string) => id === 'hidden'
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] });
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
|
||||
planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String),
|
||||
}));
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
|
||||
expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden');
|
||||
});
|
||||
|
||||
it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => {
|
||||
start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)]));
|
||||
next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)]));
|
||||
advancePastUnplayable
|
||||
.mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)]))
|
||||
.mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)]));
|
||||
getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id)
|
||||
? Promise.resolve({ ...track(id), state: 'HIDDEN' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await expect(startVibeSession(track('seed'))).resolves.toMatchObject({
|
||||
status: 'complete', tracks: [track('good'), track('later')],
|
||||
});
|
||||
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
|
||||
expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId]))
|
||||
.toEqual([[0, 'hidden-one'], [1, 'hidden-two']]);
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('good');
|
||||
});
|
||||
|
||||
it('retries an unplayable advancement with its original event id after a lost response', async () => {
|
||||
start.mockResolvedValue(response(1, item('hidden'), [item('good')]));
|
||||
next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')]));
|
||||
advancePastUnplayable
|
||||
.mockRejectedValueOnce(new Error('response dropped'))
|
||||
.mockResolvedValueOnce(response(1, item('good', true), [item('later')]));
|
||||
getTrack.mockImplementation((id: string) => id === 'hidden'
|
||||
? Promise.resolve({ ...track(id), state: 'MISSING' })
|
||||
: Promise.resolve(track(id)));
|
||||
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
expect(advancePastUnplayable).toHaveBeenCalledTimes(2);
|
||||
expect(advancePastUnplayable.mock.calls[0][2].eventId)
|
||||
.toBe(advancePastUnplayable.mock.calls[1][2].eventId);
|
||||
});
|
||||
|
||||
it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)]));
|
||||
next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)]));
|
||||
advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)]));
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await advancePastUnplayableVibeTrack('one');
|
||||
|
||||
expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({
|
||||
planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String),
|
||||
}));
|
||||
expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' }));
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('two');
|
||||
expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 });
|
||||
});
|
||||
|
||||
it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => {
|
||||
start.mockResolvedValue(response(1, item('one'), [item('two')]));
|
||||
next.mockResolvedValue(response(1, item('one', true), [item('two')]));
|
||||
end.mockResolvedValue(response(1));
|
||||
await startVibeSession(track('seed'));
|
||||
|
||||
await endVibeSession();
|
||||
|
||||
expect(end).toHaveBeenCalledWith('session-a');
|
||||
expect(useVibeStore.getState().activeSessionId).toBeNull();
|
||||
expect(usePlaybackStore.getState()).toMatchObject({
|
||||
queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,417 @@
|
||||
import axios from 'axios';
|
||||
import type { Track } from '../types';
|
||||
import { usePlaybackStore } from '../store/usePlaybackStore';
|
||||
import { usePlaybackStore, type VibeAdvanceReason } from '../store/usePlaybackStore';
|
||||
import { useVibeStore } from '../store/useVibeStore';
|
||||
import { fetchNextBatch, vibeService, type VibeBatchStatus } from './vibeService';
|
||||
|
||||
export const INITIAL_VIBE_BATCH_SIZE = 5;
|
||||
import {
|
||||
vibeService,
|
||||
type DurableVibeSessionResponse,
|
||||
type VibeEventType,
|
||||
type VibePlanItem,
|
||||
} from './vibeService';
|
||||
import { trackService } from './trackService';
|
||||
|
||||
export interface StartedVibeSession {
|
||||
status: VibeBatchStatus;
|
||||
status: 'complete' | 'exhausted' | 'failed';
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
let startInFlight: Promise<StartedVibeSession> | null = null;
|
||||
let advanceInFlight: Promise<void> | null = null;
|
||||
let materialTail: Promise<void> = Promise.resolve();
|
||||
|
||||
interface PendingEvent {
|
||||
sessionId: string;
|
||||
input: Parameters<typeof vibeService.event>[1];
|
||||
retried: boolean;
|
||||
settled: boolean;
|
||||
resolve: (response: DurableVibeSessionResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
// The event ledger deduplicates client_event_id. Keep an event in this ordered
|
||||
// outbox until the server acknowledges it so a transient failure never turns a
|
||||
// retry into a second listener action.
|
||||
const eventOutbox: PendingEvent[] = [];
|
||||
let flushingOutbox = false;
|
||||
|
||||
function serializeMaterial<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = materialTail.then(operation, operation);
|
||||
materialTail = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
|
||||
function newEventId(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
// UUID v4-shaped fallback for older embedded webviews. The server only uses
|
||||
// this as an idempotency key, not as a source of entropy.
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (letter) => {
|
||||
const value = Math.floor(Math.random() * 16);
|
||||
return (letter === 'x' ? value : (value & 0x3) | 0x8).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function isPlayable(track: Track): boolean {
|
||||
return !['HIDDEN', 'MISSING', 'DELETED'].includes(track.state);
|
||||
}
|
||||
|
||||
async function hydrateItem(item: VibePlanItem | null): Promise<Track | null> {
|
||||
if (!item) return null;
|
||||
try {
|
||||
const track = await trackService.getTrack(item.track_id);
|
||||
return isPlayable(track) ? track : null;
|
||||
} catch {
|
||||
// A plan can outlive a hidden/deleted file. Never substitute another item
|
||||
// for this ordinal: keeping the remaining order is safer than a mismatch.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function hydratePreview(items: VibePlanItem[]): Promise<Track[]> {
|
||||
const uniqueIds = [...new Set(items.map((item) => item.track_id))];
|
||||
const loaded = await Promise.all(uniqueIds.map(async (id) => {
|
||||
try {
|
||||
const track = await trackService.getTrack(id);
|
||||
return [id, isPlayable(track) ? track : null] as const;
|
||||
} catch {
|
||||
return [id, null] as const;
|
||||
}
|
||||
}));
|
||||
const byId = new Map(loaded.filter((entry): entry is readonly [string, Track] => entry[1] !== null));
|
||||
return items.flatMap((item) => {
|
||||
const track = byId.get(item.track_id);
|
||||
return track ? [track] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace only the queue after the currently playing Vibe track. */
|
||||
function replaceUnplayedQueue(preview: Track[]): void {
|
||||
const playback = usePlaybackStore.getState();
|
||||
const current = playback.currentTrack;
|
||||
const queueIndex = current
|
||||
? (playback.currentIndex >= 0 && playback.queue[playback.currentIndex]?.id === current.id
|
||||
? playback.currentIndex
|
||||
: playback.queue.findIndex((track) => track.id === current.id))
|
||||
: -1;
|
||||
const history = queueIndex >= 0
|
||||
? playback.queue.slice(0, queueIndex + 1)
|
||||
: current ? [current] : [];
|
||||
const seen = new Set(history.map((track) => track.id));
|
||||
const future = preview.filter((track) => !seen.has(track.id));
|
||||
playback.setVibeQueue([...history, ...future]);
|
||||
}
|
||||
|
||||
function isCurrentVibeOwner(sessionId: string): boolean {
|
||||
return useVibeStore.getState().activeSessionId === sessionId
|
||||
&& usePlaybackStore.getState().queueOwner === 'vibe';
|
||||
}
|
||||
|
||||
async function reconcilePreview(sessionId: string, response: DurableVibeSessionResponse): Promise<Track[]> {
|
||||
if (!isCurrentVibeOwner(sessionId)) return [];
|
||||
const preview = await hydratePreview(response.preview);
|
||||
if (!isCurrentVibeOwner(sessionId) || !useVibeStore.getState().setPlan(response.planVersion, preview)) return [];
|
||||
replaceUnplayedQueue(preview);
|
||||
return preview;
|
||||
}
|
||||
|
||||
async function serveNextCurrent(sessionId: string, version: number): Promise<DurableVibeSessionResponse> {
|
||||
// A concurrent device or a feedback replan can make a version stale between
|
||||
// the event response and /next. A stale response has an uncommitted `now`;
|
||||
// refresh once with its latest version before admitting a track to playback.
|
||||
let response = await vibeService.next(sessionId, version);
|
||||
if (response.now?.committed) return response;
|
||||
if (!response.planVersion || response.planVersion === version) return response;
|
||||
response = await vibeService.next(sessionId, response.planVersion);
|
||||
return response;
|
||||
}
|
||||
|
||||
async function serveNextPlayable(
|
||||
sessionId: string,
|
||||
version: number,
|
||||
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
|
||||
return resolvePlayableResponse(sessionId, await serveNextCurrent(sessionId, version));
|
||||
}
|
||||
|
||||
async function advanceResponsePastUnplayable(
|
||||
sessionId: string,
|
||||
response: DurableVibeSessionResponse,
|
||||
): Promise<DurableVibeSessionResponse> {
|
||||
if (!response.now?.committed || !response.planVersion) return response;
|
||||
const unplayable = {
|
||||
eventId: newEventId(),
|
||||
planVersionId: response.now.plan_version_id,
|
||||
ordinal: response.now.ordinal,
|
||||
trackId: response.now.track_id,
|
||||
};
|
||||
try {
|
||||
return await vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable);
|
||||
} catch (error) {
|
||||
// A response may have been lost after the server committed the advance.
|
||||
// Retry the same event id so it returns the same replacement rather than
|
||||
// consuming another future item.
|
||||
if (isSessionTerminalError(error)) throw error;
|
||||
return vibeService.advancePastUnplayable(sessionId, response.planVersion, unplayable);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolvePlayableResponse(
|
||||
sessionId: string,
|
||||
initialResponse: DurableVibeSessionResponse,
|
||||
): Promise<{ response: DurableVibeSessionResponse; now: Track; preview: Track[] } | null> {
|
||||
let response = initialResponse;
|
||||
// A durable plan can reference a file which has since become hidden. Commit
|
||||
// past such entries but never load one into the player. An unplayable
|
||||
// advancement already returns and commits its replacement, so process that
|
||||
// response directly: asking ordinary /next again would replay the original
|
||||
// served cursor rather than advancing through consecutive hidden entries.
|
||||
for (let attempts = 0; attempts < 20; attempts++) {
|
||||
if (!response.now?.committed || !response.planVersion) {
|
||||
if (!response.planVersion) return null;
|
||||
response = await serveNextCurrent(sessionId, response.planVersion);
|
||||
continue;
|
||||
}
|
||||
let now = await hydrateItem(response.now);
|
||||
let preview = await hydratePreview(response.preview);
|
||||
if (now) return { response, now, preview };
|
||||
const advanced = await advanceResponsePastUnplayable(sessionId, response);
|
||||
if (!advanced.planVersion) return null;
|
||||
// The unplayable transition may itself race a feedback replan. Its stale
|
||||
// response did not advance the old revision, so version-serve the current
|
||||
// revision normally rather than treating a preview item as committed.
|
||||
if (!advanced.now?.committed) {
|
||||
response = await serveNextCurrent(sessionId, advanced.planVersion);
|
||||
continue;
|
||||
}
|
||||
response = advanced;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deactivateBrokenSession(): void {
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
playback.pause();
|
||||
playback.setQueue([]);
|
||||
playback.setCurrentTrack(null);
|
||||
}
|
||||
|
||||
function isSessionTerminalError(error: unknown): boolean {
|
||||
return axios.isAxiosError(error) && [401, 404, 409].includes(error.response?.status ?? 0);
|
||||
}
|
||||
|
||||
export function vibeErrorMessage(error: unknown): string {
|
||||
if (!axios.isAxiosError(error)) return 'Could not refresh this Vibe. Please try again.';
|
||||
switch (error.response?.status) {
|
||||
case 400: return 'Vibe needs a valid user identity.';
|
||||
case 404: return 'This Vibe session is no longer available.';
|
||||
case 409: return 'This Vibe session has already ended or was replaced.';
|
||||
default: return 'Could not refresh this Vibe. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEvent(
|
||||
type: VibeEventType,
|
||||
trackId?: string,
|
||||
positionMs?: number,
|
||||
durationMs?: number,
|
||||
): Promise<DurableVibeSessionResponse | null> {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
if (!sessionId) return null;
|
||||
const input = {
|
||||
eventId: newEventId(),
|
||||
type,
|
||||
trackId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
positionMs,
|
||||
durationMs,
|
||||
};
|
||||
return new Promise<DurableVibeSessionResponse>((resolve, reject) => {
|
||||
eventOutbox.push({ sessionId, input, retried: false, settled: false, resolve, reject });
|
||||
void flushEventOutbox();
|
||||
});
|
||||
}
|
||||
|
||||
function retryableEventError(error: unknown): boolean {
|
||||
return !isSessionTerminalError(error);
|
||||
}
|
||||
|
||||
async function flushEventOutbox(): Promise<void> {
|
||||
if (flushingOutbox) return;
|
||||
flushingOutbox = true;
|
||||
try {
|
||||
while (eventOutbox.length > 0) {
|
||||
const entry = eventOutbox[0];
|
||||
try {
|
||||
const response = await vibeService.event(entry.sessionId, entry.input);
|
||||
eventOutbox.shift();
|
||||
entry.settled = true;
|
||||
entry.resolve(response);
|
||||
} catch (error) {
|
||||
// Retry once immediately using the exact same client event id. After
|
||||
// that leave it at the head for a later retry, rather than discarding
|
||||
// the idempotency key or allowing newer material events to overtake it.
|
||||
if (!entry.retried && retryableEventError(error)) {
|
||||
entry.retried = true;
|
||||
continue;
|
||||
}
|
||||
// A session that is gone/ended can never acknowledge this event. Do
|
||||
// not let an irrecoverable old-session entry block a later session.
|
||||
if (isSessionTerminalError(error)) eventOutbox.shift();
|
||||
if (!entry.settled) {
|
||||
entry.settled = true;
|
||||
entry.reject(error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushingOutbox = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a V2 plan and immediately hand its first recommendations to playback.
|
||||
* Keeping this in one place prevents entry points from accidentally replacing a
|
||||
* generated Vibe queue with a normal browse queue.
|
||||
* Send a non-navigation event. Material feedback reconciles the future before
|
||||
* it resolves, so no old prefetch remains after Keep or an implicit update.
|
||||
*/
|
||||
export async function reportVibeEvent(
|
||||
type: VibeEventType,
|
||||
trackId?: string,
|
||||
positionMs?: number,
|
||||
durationMs?: number,
|
||||
): Promise<void> {
|
||||
return serializeMaterial(async () => {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
if (!sessionId || !isCurrentVibeOwner(sessionId)) return;
|
||||
try {
|
||||
const response = await sendEvent(type, trackId, positionMs, durationMs);
|
||||
// Even a duplicate material event can acknowledge a canonical
|
||||
// replacement revision (replanned=false). Reconcile every valid
|
||||
// revision so a response lost after its original replan cannot leave a
|
||||
// stale locally-prefetched future behind.
|
||||
if (response?.planVersion !== null && response?.planVersion !== undefined) {
|
||||
await reconcilePreview(sessionId, response);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isSessionTerminalError(error)) deactivateBrokenSession();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Advance only after the prior track's durable outcome has produced a new plan. */
|
||||
export function advanceVibe(reason: VibeAdvanceReason): Promise<void> {
|
||||
if (advanceInFlight) return advanceInFlight;
|
||||
advanceInFlight = serializeMaterial(async () => {
|
||||
const vibe = useVibeStore.getState();
|
||||
const current = usePlaybackStore.getState().currentTrack;
|
||||
if (!vibe.activeSessionId || !current || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
|
||||
try {
|
||||
const feedback = await sendEvent(reason, current.id);
|
||||
if (!feedback?.planVersion) {
|
||||
usePlaybackStore.getState().pause();
|
||||
return;
|
||||
}
|
||||
const served = await serveNextPlayable(vibe.activeSessionId, feedback.planVersion);
|
||||
if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) {
|
||||
// Never play an uncommitted or unresolvable plan item. The user can
|
||||
// retry from the page after the director publishes another revision.
|
||||
replaceUnplayedQueue([]);
|
||||
usePlaybackStore.getState().pause();
|
||||
return;
|
||||
}
|
||||
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
||||
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
||||
replaceUnplayedQueue([served.now, ...served.preview]);
|
||||
usePlaybackStore.getState().advance();
|
||||
} catch (error) {
|
||||
// Clearing the future is deliberate: carrying on with stale prefetches
|
||||
// after a rejected feedback/replan would violate the plan boundary.
|
||||
replaceUnplayedQueue([]);
|
||||
if (isSessionTerminalError(error)) deactivateBrokenSession();
|
||||
else usePlaybackStore.getState().pause();
|
||||
throw error;
|
||||
}
|
||||
}).finally(() => { advanceInFlight = null; });
|
||||
return advanceInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stream can fail after its track metadata was successfully hydrated. This
|
||||
* advances the exact durable cursor through the explicit unplayable protocol,
|
||||
* rather than treating it as ordinary feedback and allowing a replan to hide
|
||||
* the failure.
|
||||
*/
|
||||
export function advancePastUnplayableVibeTrack(trackId: string): Promise<void> {
|
||||
if (advanceInFlight) return advanceInFlight;
|
||||
advanceInFlight = serializeMaterial(async () => {
|
||||
const vibe = useVibeStore.getState();
|
||||
const playback = usePlaybackStore.getState();
|
||||
const currentItem = vibe.currentPlanItem;
|
||||
if (!vibe.activeSessionId || !currentItem || currentItem.track_id !== trackId || !isCurrentVibeOwner(vibe.activeSessionId)) return;
|
||||
|
||||
try {
|
||||
const advanced = await advanceResponsePastUnplayable(vibe.activeSessionId, {
|
||||
sessionId: vibe.activeSessionId,
|
||||
planVersion: vibe.planVersion,
|
||||
now: currentItem,
|
||||
preview: [],
|
||||
state: {},
|
||||
replanned: false,
|
||||
replanReason: null,
|
||||
});
|
||||
const served = await resolvePlayableResponse(vibe.activeSessionId, advanced);
|
||||
if (!served || served.response.sessionId !== vibe.activeSessionId || !isCurrentVibeOwner(vibe.activeSessionId)) {
|
||||
replaceUnplayedQueue([]);
|
||||
playback.pause();
|
||||
return;
|
||||
}
|
||||
if (!useVibeStore.getState().setPlan(served.response.planVersion, served.preview)) return;
|
||||
useVibeStore.getState().setCurrentPlanItem(served.response.now);
|
||||
replaceUnplayedQueue([served.now, ...served.preview]);
|
||||
playback.advance();
|
||||
} catch (error) {
|
||||
replaceUnplayedQueue([]);
|
||||
if (isSessionTerminalError(error)) deactivateBrokenSession();
|
||||
else playback.pause();
|
||||
throw error;
|
||||
}
|
||||
}).finally(() => { advanceInFlight = null; });
|
||||
return advanceInFlight;
|
||||
}
|
||||
|
||||
function installVibeAdvanceHandler(): void {
|
||||
usePlaybackStore.getState().setVibeAdvanceHandler((reason) => {
|
||||
void advanceVibe(reason).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/** Start, version-serve and hydrate the first durable Vibe track. */
|
||||
export async function startVibeSession(seed: Track): Promise<StartedVibeSession> {
|
||||
if (startInFlight) return startInFlight;
|
||||
startInFlight = beginVibeSession(seed);
|
||||
startInFlight = serializeMaterial<StartedVibeSession>(async () => {
|
||||
const started = await vibeService.start(seed.id);
|
||||
if (!started.planVersion) return { status: 'exhausted', tracks: [] };
|
||||
const served = await serveNextPlayable(started.sessionId, started.planVersion);
|
||||
if (!served) return { status: 'exhausted', tracks: [] };
|
||||
|
||||
const vibe = useVibeStore.getState();
|
||||
// A newly started session has its own revision sequence. Drop the old
|
||||
// local revision before admitting revision 1 from this new session.
|
||||
vibe.reset();
|
||||
vibe.setInitialBatchStatus('loading');
|
||||
vibe.setActiveSession({ sessionId: started.sessionId, seedTrackId: seed.id });
|
||||
vibe.setCenterTrack(seed);
|
||||
vibe.setPlan(served.response.planVersion, served.preview);
|
||||
vibe.setCurrentPlanItem(served.response.now);
|
||||
vibe.setInitialBatchStatus('idle');
|
||||
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setVibeQueue([served.now, ...served.preview]);
|
||||
playback.playTrack(served.now);
|
||||
installVibeAdvanceHandler();
|
||||
return { status: 'complete', tracks: [served.now, ...served.preview] };
|
||||
});
|
||||
try {
|
||||
return await startInFlight;
|
||||
} finally {
|
||||
@@ -27,25 +419,18 @@ export async function startVibeSession(seed: Track): Promise<StartedVibeSession>
|
||||
}
|
||||
}
|
||||
|
||||
async function beginVibeSession(seed: Track): Promise<StartedVibeSession> {
|
||||
const { sessionId } = await vibeService.start(seed.id);
|
||||
const vibe = useVibeStore.getState();
|
||||
// Do not replace a working Vibe until the new session has produced a usable
|
||||
// initial batch. This also keeps the page prefetcher attached to the old
|
||||
// session while this request is in flight.
|
||||
const result = await fetchNextBatch(INITIAL_VIBE_BATCH_SIZE, sessionId);
|
||||
if (result.tracks.length === 0) return result;
|
||||
|
||||
vibe.setInitialBatchStatus('loading');
|
||||
vibe.setActiveSession({ sessionId, seedTrackId: seed.id });
|
||||
vibe.setSeedTrackId(seed.id);
|
||||
vibe.setCenterTrack(seed);
|
||||
vibe.setBuffer(result.tracks);
|
||||
vibe.setInitialBatchStatus('idle');
|
||||
|
||||
export async function endVibeSession(): Promise<void> {
|
||||
return serializeMaterial(async () => {
|
||||
const sessionId = useVibeStore.getState().activeSessionId;
|
||||
try {
|
||||
if (sessionId) await vibeService.end(sessionId);
|
||||
} finally {
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setQueue(result.tracks);
|
||||
playback.playTrack(result.tracks[0]);
|
||||
|
||||
return result;
|
||||
playback.setVibeAdvanceHandler(null);
|
||||
useVibeStore.getState().reset();
|
||||
playback.pause();
|
||||
playback.setQueue([]);
|
||||
playback.setCurrentTrack(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { create } from 'zustand';
|
||||
import type { Track } from '../types';
|
||||
|
||||
export type RepeatMode = 'none' | 'all' | 'one';
|
||||
export type VibeAdvanceReason = 'skipped' | 'completed' | 'disliked';
|
||||
export type PlaybackOwner = 'ordinary' | 'vibe';
|
||||
|
||||
/**
|
||||
* How many already-played tracks to keep behind the cursor. Bounds queue growth
|
||||
@@ -22,12 +24,22 @@ interface PlaybackState {
|
||||
repeat: RepeatMode;
|
||||
/** Ids already played this shuffle "lap" (repeat-all), to avoid bouncing between the same few tracks. */
|
||||
shufflePlayed: Set<string>;
|
||||
/** Installed only while a durable Vibe session owns the queue. */
|
||||
vibeAdvanceHandler: ((reason: VibeAdvanceReason) => void) | null;
|
||||
/** Vibe must opt in explicitly; ordinary browsing always owns itself. */
|
||||
queueOwner: PlaybackOwner;
|
||||
|
||||
setQueue: (queue: Track[]) => void;
|
||||
/** Vibe-only queue replacement. Do not use for library browsing. */
|
||||
setVibeQueue: (queue: Track[]) => void;
|
||||
playTrack: (track: Track) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
next: () => void;
|
||||
nextWithReason: (reason: VibeAdvanceReason) => void;
|
||||
/** Bypass the Vibe controller after it has prepared the next committed track. */
|
||||
advance: () => void;
|
||||
setVibeAdvanceHandler: (handler: ((reason: VibeAdvanceReason) => void) | null) => void;
|
||||
prev: () => void;
|
||||
setPosition: (position: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
@@ -71,6 +83,8 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
shuffle: false,
|
||||
repeat: 'none',
|
||||
shufflePlayed: new Set<string>(),
|
||||
vibeAdvanceHandler: null,
|
||||
queueOwner: 'ordinary',
|
||||
|
||||
setQueue: (queue) =>
|
||||
set((state) => ({
|
||||
@@ -78,6 +92,18 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
// Keep the cursor pointing at whatever is playing, if it is still queued.
|
||||
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
|
||||
shufflePlayed: new Set(),
|
||||
// Every ordinary queue operation is an explicit ownership handoff. This
|
||||
// prevents a stale Vibe session from intercepting browser/UI next.
|
||||
queueOwner: 'ordinary',
|
||||
vibeAdvanceHandler: null,
|
||||
})),
|
||||
|
||||
setVibeQueue: (queue) =>
|
||||
set((state) => ({
|
||||
queue,
|
||||
currentIndex: state.currentTrack ? queue.findIndex((t) => t.id === state.currentTrack!.id) : -1,
|
||||
shufflePlayed: new Set(),
|
||||
queueOwner: 'vibe',
|
||||
})),
|
||||
|
||||
playTrack: (track) =>
|
||||
@@ -94,6 +120,19 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
pause: () => set({ isPlaying: false }),
|
||||
|
||||
next: () => {
|
||||
get().nextWithReason('skipped');
|
||||
},
|
||||
|
||||
nextWithReason: (reason) => {
|
||||
const { vibeAdvanceHandler: handler, queueOwner } = get();
|
||||
if (queueOwner === 'vibe' && handler) {
|
||||
handler(reason);
|
||||
return;
|
||||
}
|
||||
get().advance();
|
||||
},
|
||||
|
||||
advance: () => {
|
||||
const { queue, currentTrack, shuffle, repeat, shufflePlayed } = get();
|
||||
if (queue.length === 0) {
|
||||
set({ isPlaying: false, position: 0 });
|
||||
@@ -169,6 +208,11 @@ export const usePlaybackStore = create<PlaybackState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setVibeAdvanceHandler: (vibeAdvanceHandler) => set((state) => ({
|
||||
vibeAdvanceHandler,
|
||||
queueOwner: vibeAdvanceHandler ? 'vibe' : state.queueOwner,
|
||||
})),
|
||||
|
||||
prev: () => {
|
||||
const { queue, currentTrack, currentIndex } = get();
|
||||
if (queue.length === 0) return;
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Track, VibeSession } from '../types';
|
||||
import type { VibePlanItem } from '../services/vibeService';
|
||||
|
||||
// V2 recommendation session state. The backend stores the plan in Redis
|
||||
// (keyed by sessionId) and serves tracks one at a time via GET /v2/vibe/next.
|
||||
// We keep a lookahead buffer of upcoming Track[] to feed playback.
|
||||
// The durable plan is authoritative. `buffer` is only its currently
|
||||
// uncommitted, hydrated preview; it may be replaced at any feedback boundary.
|
||||
interface VibeState {
|
||||
activeSessionId: string | null;
|
||||
seedTrackId: string | null;
|
||||
planVersion: number | null;
|
||||
/** Durable cursor for the track currently in Vibe playback. */
|
||||
currentPlanItem: VibePlanItem | null;
|
||||
centerTrack: Track | null;
|
||||
buffer: Track[]; // lookahead buffer of upcoming recommended tracks
|
||||
/** Outcome of the first V2 batch, including sessions initiated from Discover. */
|
||||
buffer: Track[];
|
||||
initialBatchStatus: 'idle' | 'loading' | 'exhausted' | 'failed';
|
||||
|
||||
setActiveSession: (session: VibeSession | null) => void;
|
||||
setSeedTrackId: (seedTrackId: string | null) => void;
|
||||
setCenterTrack: (track: Track | null) => void;
|
||||
setBuffer: (buffer: Track[]) => void;
|
||||
/** Returns false when a response belongs to an older plan revision. */
|
||||
setPlan: (planVersion: number | null, preview: Track[]) => boolean;
|
||||
setCurrentPlanItem: (item: VibePlanItem | null) => void;
|
||||
setInitialBatchStatus: (status: VibeState['initialBatchStatus']) => void;
|
||||
appendBuffer: (tracks: Track[]) => void;
|
||||
shiftBuffer: () => Track | undefined;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
activeSessionId: null as string | null,
|
||||
seedTrackId: null as string | null,
|
||||
planVersion: null as number | null,
|
||||
currentPlanItem: null as VibePlanItem | null,
|
||||
centerTrack: null as Track | null,
|
||||
buffer: [] as Track[],
|
||||
initialBatchStatus: 'idle' as const,
|
||||
@@ -33,26 +37,20 @@ const initialState = {
|
||||
export const useVibeStore = create<VibeState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setActiveSession: (session) =>
|
||||
set(
|
||||
setActiveSession: (session) => set(
|
||||
session
|
||||
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
|
||||
: { activeSessionId: null, seedTrackId: null }
|
||||
: { activeSessionId: null, seedTrackId: null },
|
||||
),
|
||||
|
||||
setSeedTrackId: (seedTrackId) => set({ seedTrackId }),
|
||||
setCenterTrack: (centerTrack) => set({ centerTrack }),
|
||||
setBuffer: (buffer) => set({ buffer }),
|
||||
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
|
||||
appendBuffer: (tracks) => set((state) => ({ buffer: [...state.buffer, ...tracks] })),
|
||||
|
||||
shiftBuffer: () => {
|
||||
const { buffer } = get();
|
||||
if (buffer.length === 0) return undefined;
|
||||
const [head, ...rest] = buffer;
|
||||
set({ buffer: rest });
|
||||
return head;
|
||||
setPlan: (planVersion, buffer) => {
|
||||
const current = get().planVersion;
|
||||
if (planVersion === null || (current !== null && planVersion < current)) return false;
|
||||
set({ planVersion, buffer });
|
||||
return true;
|
||||
},
|
||||
|
||||
setCurrentPlanItem: (currentPlanItem) => set({ currentPlanItem }),
|
||||
setInitialBatchStatus: (initialBatchStatus) => set({ initialBatchStatus }),
|
||||
reset: () => set({ ...initialState }),
|
||||
}));
|
||||
|
||||
@@ -75,8 +75,8 @@ export interface HealthResponse {
|
||||
redis: 'ok' | 'error' | 'unknown';
|
||||
}
|
||||
|
||||
// Active v2 recommendation session. The sessionId comes from
|
||||
// POST /api/v2/vibe/start and identifies the Redis-stored plan.
|
||||
// Active durable recommendation session. The sessionId comes from
|
||||
// POST /api/v2/vibe/sessions and identifies its persisted plan.
|
||||
export interface VibeSession {
|
||||
sessionId: string;
|
||||
seedTrackId: string | null;
|
||||
|
||||
+926
@@ -0,0 +1,926 @@
|
||||
# Vibe v2 — Session Director Specification
|
||||
|
||||
## Status and purpose
|
||||
|
||||
This is the target specification for Vibe v2. It turns Vibe from a
|
||||
recommendation queue into a session director: an autonomous system that
|
||||
continuously composes the listener's next hour of music.
|
||||
|
||||
The unit of optimisation is not the next track. It is a listening experience
|
||||
that remains coherent, fresh, and rewarding over hours and continues to feel
|
||||
new tomorrow.
|
||||
|
||||
This document supplements docs/architecture/09-recommendation-and-identity-v2.md.
|
||||
Where the two documents conflict, this document controls the Session Director
|
||||
and Vibe playback contract.
|
||||
|
||||
## Product contract
|
||||
|
||||
Vibe must feel like a thoughtful radio DJ:
|
||||
|
||||
- It plays familiar music, adjacent music, and worthwhile discoveries in a
|
||||
deliberate balance.
|
||||
- It responds to a skip, full listen, or explicit action quickly enough that
|
||||
the listener can perceive the change.
|
||||
- It does not collapse into a few favourite artists, genres, languages,
|
||||
decades, labels, producers, or audio-feature bands.
|
||||
- It has a direction: energy, novelty, and intensity can evolve, but should
|
||||
not jump without a reason.
|
||||
- It returns to ideas introduced earlier (callbacks) and makes room for
|
||||
surprises.
|
||||
- It remembers prior sessions sufficiently to avoid replaying yesterday's
|
||||
shape, not merely yesterday's tracks.
|
||||
|
||||
durable history + live feedback + context
|
||||
|
|
||||
v
|
||||
listener state
|
||||
|
|
||||
v
|
||||
candidate retrieval and expansion
|
||||
|
|
||||
v
|
||||
sequence planner / constraint engine
|
||||
|
|
||||
v
|
||||
revisable next 20–50 tracks
|
||||
|
|
||||
v
|
||||
playback
|
||||
|
|
||||
+------------ feedback and replan ------------+
|
||||
|
||||
Vibe is not a saved playlist. A client may display a small, revisable preview
|
||||
of the future, but the server owns the authoritative plan and may rewrite every
|
||||
unplayed item at any time.
|
||||
|
||||
## Goals and non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
1. Maximise expected session reward, not click-through rate or immediate
|
||||
predicted enjoyment alone.
|
||||
2. Make recommendations conditional on current state, listening context, and
|
||||
the already-played portion of the session.
|
||||
3. Balance comfort, discovery, diversity, coherence, freshness, and long-term
|
||||
learning while penalising fatigue, repetition, and predictability.
|
||||
4. Support a self-hosted local library first. Probation tracks are candidates
|
||||
only when their audio is available locally and acquisition policy permits it.
|
||||
5. Remain useful with sparse metadata. Missing attributes reduce confidence;
|
||||
they must never silently become a hard negative.
|
||||
6. Be explainable: every selected item has provenance, a reason for its slot,
|
||||
and the constraints that affected it.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Do not acquire copyrighted audio or bypass existing acquisition gates.
|
||||
- Do not infer sensitive personal attributes. Context is opt-in and coarse
|
||||
(for example home, work, walking), never precise location.
|
||||
- Do not treat a permanently fixed exploration ratio, genre quota, or score
|
||||
weight as the final design. Defaults are bootstraps, not truth.
|
||||
- Do not expose internal goals in a way that makes the experience feel
|
||||
manipulative. Explanations remain human-scale.
|
||||
|
||||
---
|
||||
|
||||
## 1. System model
|
||||
|
||||
### 1.1 Inputs
|
||||
|
||||
| Input | Examples | Role |
|
||||
|---|---|---|
|
||||
| Permanent taste | favourite artists, genre affinity, negative feedback | Establishes the comfort zone |
|
||||
| Multi-horizon memory | 30 minutes, 7 days, 3 months, lifetime | Separates current obsession from durable taste |
|
||||
| Current session | played/queued tracks, skips, callbacks, budget spend | Determines what fits now |
|
||||
| Context | hour, weekday, device, activity, coarse location, optional weather | Changes interpretation of taste |
|
||||
| Music knowledge | graph claims, metadata, audio features, embeddings, quality | Retrieves and describes candidates |
|
||||
|
||||
### 1.2 Optimisation objective
|
||||
|
||||
For sequence q = [t1, …, tn], optimise discounted sequence reward rather than
|
||||
independently sorting tracks:
|
||||
|
||||
J(q | state) = sum over i of gamma^i × (
|
||||
enjoyment(ti)
|
||||
+ discovery_value(ti)
|
||||
+ transition_quality(ti-1, ti)
|
||||
+ freshness(ti)
|
||||
+ diversity_gain(q through i)
|
||||
+ goal_progress(ti)
|
||||
- fatigue(ti)
|
||||
- repetition(ti, q through i)
|
||||
- predictability(q through i)
|
||||
- disruption(ti-1, ti)
|
||||
)
|
||||
|
||||
Hard safety and availability constraints apply before optimisation. Gamma
|
||||
discounts distant slots so the director is decisive about the next few tracks
|
||||
without pretending it knows the exact state 40 tracks later.
|
||||
|
||||
Initial weights may be hand-tuned, but must be versioned policy configuration.
|
||||
They become learnable only after sufficient reliable events exist.
|
||||
|
||||
### 1.3 Planning horizon
|
||||
|
||||
- Keep an internal horizon of 20–50 tracks, chosen from track duration and
|
||||
session conditions.
|
||||
- Publish only 3–8 tracks to the client as a mutable preview.
|
||||
- Treat only the immediate next track as committed.
|
||||
- Replan after every material event and before the preview falls below three
|
||||
playable tracks.
|
||||
- Do not send duplicate tracks in one Vibe session unless repeat-one was
|
||||
explicitly requested.
|
||||
|
||||
---
|
||||
|
||||
## 2. Durable session model
|
||||
|
||||
### 2.1 Session identity
|
||||
|
||||
Every Vibe request, playback event, plan version, and feedback event MUST be
|
||||
keyed by session_id and user_id. play_history.batch_id is not a session ID and
|
||||
must not be used as one.
|
||||
|
||||
Sessions end explicitly, after configurable inactivity, or when a new Vibe
|
||||
session replaces the current one. A session can resume in a short grace period
|
||||
without losing state or goals.
|
||||
|
||||
### 2.2 Session context
|
||||
|
||||
Context is optional, versioned, and privacy-preserving.
|
||||
|
||||
interface VibeContext {
|
||||
timeZone?: string;
|
||||
localHour?: number; // normally server-derived
|
||||
weekday?: number; // normally server-derived
|
||||
dayKind?: 'weekday' | 'weekend' | 'holiday';
|
||||
device?: 'desktop' | 'phone' | 'speaker' | 'car' | 'headphones';
|
||||
activity?: 'focus' | 'relax' | 'walking' | 'workout' | 'social' | 'unknown';
|
||||
locationCategory?: 'home' | 'work' | 'gym' | 'travel' | 'unknown';
|
||||
weather?: 'clear' | 'rain' | 'snow' | 'hot' | 'cold' | 'unknown';
|
||||
source?: 'current_track' | 'artist' | 'genre' | 'surprise' | 'resume';
|
||||
}
|
||||
|
||||
The frontend must provide an unobtrusive activity/context selector, beginning
|
||||
with activity and device. Browser context is a hint and is never required.
|
||||
|
||||
### 2.3 Event ledger
|
||||
|
||||
Store immutable events before updating derived state. This makes feedback
|
||||
auditable, supports offline evaluation, and prevents listener state being the
|
||||
only record of why a plan changed.
|
||||
|
||||
CREATE TABLE vibe_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN
|
||||
('active','paused','ended','expired','replaced')),
|
||||
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
policy_version TEXT NOT NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ended_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE vibe_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_event_id UUID,
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL,
|
||||
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
||||
type TEXT NOT NULL,
|
||||
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
position_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
UNIQUE (session_id, client_event_id)
|
||||
);
|
||||
|
||||
CREATE TABLE vibe_plan_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
||||
version INTEGER NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
state_snapshot JSONB NOT NULL,
|
||||
objective_snapshot JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (session_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE vibe_plan_items (
|
||||
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
|
||||
ordinal INTEGER NOT NULL,
|
||||
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||
slot_role TEXT,
|
||||
candidate_source TEXT NOT NULL,
|
||||
score REAL NOT NULL,
|
||||
score_breakdown JSONB NOT NULL,
|
||||
explanation JSONB NOT NULL,
|
||||
committed BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (plan_version_id, ordinal),
|
||||
UNIQUE (plan_version_id, track_id)
|
||||
);
|
||||
|
||||
Create indexes on vibe_sessions(user_id, last_event_at DESC),
|
||||
vibe_events(session_id, occurred_at), and vibe_events(user_id, occurred_at DESC).
|
||||
|
||||
Initial event types:
|
||||
|
||||
session_started, session_resumed, session_ended, context_changed,
|
||||
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
|
||||
|
||||
Event writes must be idempotent. The client supplies client_event_id for any
|
||||
action that might be retried after a network failure.
|
||||
|
||||
### 2.4 Derived listener state
|
||||
|
||||
session_state is a cache derived from events and recent history, not the sole
|
||||
source of truth. It must be rebuildable.
|
||||
|
||||
interface ListenerSessionState {
|
||||
sessionId: string;
|
||||
updatedAt: string;
|
||||
ageMin: number;
|
||||
|
||||
energy: number;
|
||||
valence: number | null;
|
||||
focus: number | null;
|
||||
attention: number | null;
|
||||
cognitiveLoad: number | null;
|
||||
emotionalIntensity: number | null;
|
||||
noveltyHunger: number;
|
||||
noveltyTolerance: number;
|
||||
danceabilityTarget: number | null;
|
||||
acousticnessTarget: number | null;
|
||||
instrumentalnessTarget: number | null;
|
||||
vocalPreference: 'vocal' | 'instrumental' | 'mixed';
|
||||
tempoTarget: number | null;
|
||||
moodTags: Array<{ tag: string; weight: number }>;
|
||||
|
||||
explorationCoefficient: number;
|
||||
discoveryRadius: number;
|
||||
entropyTarget: number;
|
||||
currentArc: ArcInstance;
|
||||
activeGoals: SessionGoal[];
|
||||
callbackLedger: CallbackToken[];
|
||||
fatigue: FatigueSnapshot;
|
||||
budgetSpend: BudgetSpendSnapshot;
|
||||
recent: RecentSessionSummary;
|
||||
}
|
||||
|
||||
Every inferred value carries confidence and source. Low confidence causes
|
||||
broader, safer choices; it never means the value is zero.
|
||||
|
||||
### 2.5 State update rules
|
||||
|
||||
- Completion shifts continuous state modestly toward reliable track features,
|
||||
weighted by listen ratio and feature confidence.
|
||||
- A quick skip is negative evidence for the track and recommendation route; it
|
||||
is not automatically a dislike of every artist/genre/feature on the track.
|
||||
- A saved discovery or intentional replay is strong evidence for exploration at
|
||||
that distance and source.
|
||||
- Repeated unfamiliar skips shrink exploration temporarily; repeated unfamiliar
|
||||
completions expand it gradually.
|
||||
- Context changes may reset desired trajectory while retaining fatigue and
|
||||
long-term memory.
|
||||
- Updates MUST be ordered per session. Concurrent feedback must be serialized
|
||||
or use a session-version compare-and-swap.
|
||||
|
||||
---
|
||||
|
||||
## 3. Memory horizons and listener model
|
||||
|
||||
The listener has independent memories, each with its own decay and purpose.
|
||||
|
||||
| Horizon | Window | Captures | Primary effect |
|
||||
|---|---:|---|---|
|
||||
| Immediate | 30 minutes | current direction, skips, active fatigue | transitions and next slots |
|
||||
| Session | session lifetime | arc, budgets, callbacks, served tracks | sequence planning |
|
||||
| Daily | 24 hours | today’s exposure and shape | fatigue and fresh starts |
|
||||
| Weekly | 7 days | recent obsessions and routines | variety across days |
|
||||
| Medium | 3 months | stable recent taste | affinity and discovery neighbourhood |
|
||||
| Lifetime | slow decay | durable favourites and familiar anchors | comfort candidates |
|
||||
|
||||
Existing long-term, obsession, discovery, negative, forgotten, and contextual
|
||||
belief profiles remain useful. Vibe adds a session layer; it does not flatten
|
||||
all profiles into one taste score.
|
||||
|
||||
### 3.1 Session similarity memory
|
||||
|
||||
After each session, generate a compact fingerprint:
|
||||
|
||||
interface SessionFingerprint {
|
||||
artistDistribution: Record<string, number>;
|
||||
genreDistribution: Record<string, number>;
|
||||
languageDistribution: Record<string, number>;
|
||||
decadeDistribution: Record<string, number>;
|
||||
audioTrajectory: Array<{ energy: number; tempo?: number; valence?: number }>;
|
||||
discoveryRate: number;
|
||||
generatorDistribution: Record<string, number>;
|
||||
acceptedDiscoveries: string[];
|
||||
durationMin: number;
|
||||
}
|
||||
|
||||
At the next session start, softly penalise similarity to recent fingerprints,
|
||||
especially one or two days ago. An explicit artist/album/genre seed may
|
||||
intentionally override this penalty.
|
||||
|
||||
---
|
||||
|
||||
## 4. Music representation and candidate retrieval
|
||||
|
||||
### 4.1 Feature completeness
|
||||
|
||||
Each playable track should expose, when available:
|
||||
|
||||
- main/featured artists, album, label, producers, composers, scenes;
|
||||
- genres and tags with confidence;
|
||||
- release year/decade, language, vocal/instrumental classification;
|
||||
- BPM, key, energy, valence, danceability, acousticness, instrumentalness,
|
||||
liveness, loudness, duration;
|
||||
- live version, cover, remix, soundtrack, and side-project relationships;
|
||||
- quality, availability, and feature-completeness indicators.
|
||||
|
||||
Missing audio analysis is unknown, never low energy or instrumental.
|
||||
|
||||
### 4.2 Embeddings and discovery radius
|
||||
|
||||
The long-term target is a shared semantic space for tracks, artists, albums,
|
||||
genres, sessions, and listener/context representation. It may begin with
|
||||
feature-derived vectors and later use learned embeddings. Recommended
|
||||
dimensions are 768 or 1024. Musical, lyrical, collaborative, and contextual
|
||||
vectors can be blended at retrieval time.
|
||||
|
||||
| Distance | Interpretation |
|
||||
|---:|---|
|
||||
| 0.00 | already familiar/favourite |
|
||||
| 0.15 | same artist or tightly associated artist |
|
||||
| 0.30 | strongly similar artist/sound |
|
||||
| 0.45 | different artist in a known style |
|
||||
| 0.65 | adjacent genre or scene |
|
||||
| 0.90 | surprising but explainable |
|
||||
|
||||
discoveryRadius is a range, not a switch. Sequences normally move through
|
||||
adjacent distances and return to an anchor. Large jumps need a surprise slot or
|
||||
listener reinforcement.
|
||||
|
||||
### 4.3 Candidate pools
|
||||
|
||||
Generate independently, retain all provenance, deduplicate by track and
|
||||
canonical identity, then rank.
|
||||
|
||||
| Pool | Default share | Purpose |
|
||||
|---|---:|---|
|
||||
| Familiar/favourites | 30% | comfort and callbacks |
|
||||
| Similar tracks/artists | 20% | continuity |
|
||||
| User-niche recent/trending | 15% | timely relevant discovery |
|
||||
| Long-tail discovery | 10% | avoid popularity collapse |
|
||||
| Contextual candidates | 10% | fit activity/time |
|
||||
| Artist/graph traversal | 10% | explainable adjacency |
|
||||
| Controlled serendipity | 5% | bounded surprise |
|
||||
|
||||
These are retrieval targets, not mandatory final-plan shares. Allocation adapts
|
||||
to exploration, candidate availability, fatigue, and the active arc.
|
||||
|
||||
Required generator classes:
|
||||
|
||||
1. Comfort: favourites and trusted artists with fatigue suppression.
|
||||
2. Adjacent: graph/ANN neighbours of recent successes/current trajectory.
|
||||
3. Discovery: unfamiliar artists at a safe distance with credible graph path.
|
||||
4. Revival: forgotten favourites, old obsessions, accepted tracks after rest.
|
||||
5. Deep dive: album/obsession exploration limited by album/artist budgets.
|
||||
6. Contextual: candidates with comparable activity, hour, or device evidence.
|
||||
7. Freshness: new releases/niche trends fitting the listener graph.
|
||||
8. Serendipity: deliberately bounded unexplored route, never random catalogue.
|
||||
|
||||
Candidate provenance retains every nominating path, not just the winning source.
|
||||
It is required for explanations and source-level learning.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fatigue, repetition, and diversity
|
||||
|
||||
### 5.1 Fatigue principle
|
||||
|
||||
Everything can fatigue; everything recovers. Fatigue is decayed exposure, not
|
||||
a permanent ban.
|
||||
|
||||
fatigue(dimension, entity, now) =
|
||||
min(1, sum of exposure_weight(event) × exp(-event_age / tau_dimension))
|
||||
|
||||
Exposure weight is higher for completion, lower for a short sample, and zero
|
||||
for playback failure. Tau is configurable per dimension and may be adapted from
|
||||
observed tolerance.
|
||||
|
||||
Track fatigue must blend multiple windows: today, yesterday, week, and month.
|
||||
It must not be a single recent-history query.
|
||||
|
||||
### 5.2 Required fatigue dimensions
|
||||
|
||||
| Dimension | Example | Planner action |
|
||||
|---|---|---|
|
||||
| Track | heard today/replayed yesterday | strong suppression unless requested |
|
||||
| Artist/canonical identity | 12 tracks by one artist | widen artist pool |
|
||||
| Album | half an album played | spread across hours/days |
|
||||
| Genre/scene | 18 metal tracks | move to adjacent style |
|
||||
| Language | 35 Japanese tracks | mix another language/instrumental |
|
||||
| Vocal/instrumentation | all vocal, same vocal type | alternate texture |
|
||||
| Tempo/energy/valence | narrow BPM/mood band | controlled transition |
|
||||
| Producer/label | repeated creative lineage | force new route |
|
||||
| Decade | all recent releases | reintroduce another era |
|
||||
| Recommendation route | same graph edge repeatedly | diversify path |
|
||||
|
||||
Unknown metadata may not claim to satisfy a specific quota.
|
||||
|
||||
### 5.3 Repetition rules
|
||||
|
||||
Hard rules, unless explicitly overridden:
|
||||
|
||||
- A served track never reappears in one Vibe session.
|
||||
- A track in its configured recent window is ineligible.
|
||||
- No more than two tracks by a canonical artist in 20 tracks.
|
||||
- No more than three tracks from an album in 40 tracks.
|
||||
- Skipped/disliked tracks are ineligible for the rest of the session.
|
||||
|
||||
Soft, adaptive minimum distances apply to artist, album, genre, language,
|
||||
producer, and energy band. If the pool is too small, relax the least important
|
||||
soft constraint, record it, and never silently relax hard track exclusion.
|
||||
|
||||
### 5.4 Diversity budgets
|
||||
|
||||
Budgets are planner resources, not passive analytics. Initial defaults:
|
||||
|
||||
| Dimension | Target | Horizon |
|
||||
|---|---:|---:|
|
||||
| Any artist | at most 20% | 30 min |
|
||||
| Any genre | at most 40% | 30 min |
|
||||
| Any language | at most 60% | 30 min |
|
||||
| Instrumental | at least 10% if inventory permits | 30 min |
|
||||
| New artists | about 15% | 60 min |
|
||||
| Familiar favourites | about 25% | 60 min |
|
||||
|
||||
Budgets may be upper bounds, lower bounds, or target ranges. The planner
|
||||
projects spend across the proposed sequence, not only completed history.
|
||||
|
||||
Explicit user intent may temporarily override soft targets. Starting an album
|
||||
or repeatedly selecting an artist narrows the session intentionally; diversity
|
||||
prevents accidental loops, not explicit choice.
|
||||
|
||||
### 5.5 Anti-loop detector
|
||||
|
||||
Run after every state update and every plan proposal. Detect concentration across
|
||||
artist/canonical identity, album, genre, scene, label, producer, language,
|
||||
decade, BPM, energy, valence, vocal type, candidate source, and graph path.
|
||||
|
||||
Use HHI and Shannon entropy accurately; HHI must not be called entropy.
|
||||
Correct the detected dimension directly:
|
||||
|
||||
| Loop | Required correction |
|
||||
|---|---|
|
||||
| Artist/album | retrieve other artists; reserve only later callback |
|
||||
| Genre/scene | retrieve adjacent genres at compatible energy |
|
||||
| Language/vocal | reserve next eligible alternate slot |
|
||||
| Tempo/energy | alter next arc target gradually |
|
||||
| Producer/label/route | exclude repeated relationship from retrieval |
|
||||
| Low route diversity | require another generator/path |
|
||||
|
||||
Do not merely boost an already available experimental candidate if it does not
|
||||
fix the detected loop.
|
||||
|
||||
---
|
||||
|
||||
## 6. Arcs, callbacks, surprise, and long-term rhythm
|
||||
|
||||
### 6.1 Arc templates
|
||||
|
||||
An arc is desired trajectory plus slot roles and transition tolerance, not just
|
||||
a generator list.
|
||||
|
||||
| Arc | Example trajectory |
|
||||
|---|---|
|
||||
| Comfort | known → known → adjacent → favourite |
|
||||
| Discovery | favourite → similar → new → familiar callback |
|
||||
| Energetic | medium → high → peak → cooldown |
|
||||
| Late-night | soft → ambient → acoustic → slow electronic |
|
||||
| Focus | instrumental/low-vocal → steady complexity → gentle reset |
|
||||
| Album exploration | familiar anchor → album chapter → relief → callback |
|
||||
|
||||
Slots define target ranges/deltas for energy, tempo, valence, acousticness,
|
||||
instrumentality, novelty distance, and familiarity. Missing features reduce
|
||||
confidence rather than reject a candidate.
|
||||
|
||||
Arc selection depends on state, context, age, explicit intent, and weekly
|
||||
schedule. It changes only when feedback/context makes the current arc
|
||||
implausible.
|
||||
|
||||
### 6.2 Transition model
|
||||
|
||||
For every pair, compute transition quality from:
|
||||
|
||||
- tempo delta and beat compatibility where reliable;
|
||||
- harmonic/key compatibility where available;
|
||||
- energy, valence, acousticness, danceability, instrumentality deltas;
|
||||
- genre/scene continuity or an explainable bridge;
|
||||
- artist/album separation;
|
||||
- language and vocal contrast when fatigue calls for it;
|
||||
- novelty-distance progression; and
|
||||
- current arc fit.
|
||||
|
||||
A good transition is not always similarity. A cooldown after a peak or a
|
||||
surprise after an anchor is good if it fits the arc.
|
||||
|
||||
### 6.3 Callbacks
|
||||
|
||||
A callback token is created when an artist, genre, energy peak, theme, or
|
||||
favourite is worth revisiting. It has minimum/maximum separation and cannot
|
||||
violate fatigue/repetition rules.
|
||||
|
||||
favourite → new artist → adjacent artist → callback to favourite
|
||||
heavy → soft bridge → different heavy track
|
||||
forgotten favourite → side project → return to old era
|
||||
|
||||
Callbacks are optional and must never become repetition.
|
||||
|
||||
### 6.4 Surprise budget
|
||||
|
||||
Every sustained session needs bounded surprise. Attempt at least one eligible,
|
||||
explainable surprise per hour when inventory permits. Types include forgotten
|
||||
favourite, live/acoustic/cover/remix, producer/side project, old obsession,
|
||||
soundtrack connection, and novel graph route.
|
||||
|
||||
A surprise is paired with a recovery anchor. A quick skip lowers propensity for
|
||||
that surprise type, not the listener’s whole taste profile.
|
||||
|
||||
### 6.5 Invisible goals
|
||||
|
||||
Goals are durable, bounded, and never override hard constraints or explicit
|
||||
direction. Examples:
|
||||
|
||||
- complete an album over several days without a block;
|
||||
- introduce a promising artist gradually;
|
||||
- revisit a favourite monthly after recovery;
|
||||
- balance languages/decades over a week;
|
||||
- rotate producer/scene routes;
|
||||
- evaluate a probation discovery;
|
||||
- preserve a surprise opportunity per hour.
|
||||
|
||||
CREATE TABLE vibe_goals (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
entity_type TEXT,
|
||||
entity_id UUID,
|
||||
state JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
priority REAL NOT NULL DEFAULT 0.5,
|
||||
status TEXT NOT NULL CHECK (status IN ('active','paused','complete','expired')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
due_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
### 6.6 Weekly rhythm
|
||||
|
||||
Vibe may use an opt-in weak weekly prior: Monday discoveries, Tuesday album
|
||||
exploration, Wednesday comfort, Thursday forgotten favourites, Friday high
|
||||
energy, weekend broad/context-led mix. It must never override explicit intent
|
||||
or current context.
|
||||
|
||||
---
|
||||
|
||||
## 7. Candidate ranking and sequence planning
|
||||
|
||||
### 7.1 Candidate proposal score
|
||||
|
||||
candidate_score =
|
||||
w_affinity × personal_affinity
|
||||
+ w_session × session_state_fit
|
||||
+ w_transition × transition_fit_from_previous
|
||||
+ w_discovery × discovery_value
|
||||
+ w_freshness × freshness
|
||||
+ w_quality × track_quality
|
||||
+ w_goal × goal_progress
|
||||
- w_fatigue × fatigue_penalty
|
||||
- w_repeat × repetition_penalty
|
||||
- w_saturation × dimension_saturation
|
||||
- w_risk × unsupported_metadata_or_route_risk
|
||||
|
||||
This is a proposal score, not final selection. Persist normalised,
|
||||
policy-versioned score breakdown with every selected plan item.
|
||||
|
||||
### 7.2 Constraint engine
|
||||
|
||||
Apply constraints during sequence construction:
|
||||
|
||||
1. Exclude unavailable, hidden, retired, served, and blocked tracks.
|
||||
2. Enforce hard repetition/safety constraints.
|
||||
3. Satisfy arc slot requirements.
|
||||
4. Project budgets across the horizon.
|
||||
5. Penalise session similarity and path concentration.
|
||||
6. Reserve callback, discovery, and surprise opportunities.
|
||||
7. Select the highest-value feasible sequence.
|
||||
|
||||
If no feasible sequence exists, return a degraded plan with structured
|
||||
constraint_relaxations. Relax soft diversity targets, then arc precision, then
|
||||
freshness. Never reintroduce an explicitly disliked or served track simply to
|
||||
fill the plan.
|
||||
|
||||
### 7.3 Planner algorithm
|
||||
|
||||
Initial implementation is constrained beam search:
|
||||
|
||||
- retrieve about 500–2,000 deduplicated candidates;
|
||||
- keep beam width 20–50 with incremental objective and projected state;
|
||||
- expand only compatible candidates per slot;
|
||||
- plan 20–50 slots; commit first; publish preview only;
|
||||
- replan from current state after feedback while preserving committed track and
|
||||
still-valid callback/goal tokens.
|
||||
|
||||
Later options include MCTS, transformer decoding, or model-predictive control.
|
||||
The planner interface must stay algorithm-agnostic.
|
||||
|
||||
### 7.4 Controlled unpredictability
|
||||
|
||||
Measure predictability over recent and proposed sequence using artist, genre,
|
||||
source/path, novelty distance, and feature distributions.
|
||||
|
||||
- Too predictable: allocate adjacent/surprise candidate and protect a later
|
||||
comfort callback.
|
||||
- Too chaotic: shrink discovery radius and inject a familiar anchor.
|
||||
|
||||
The target is intentional surprise, neither randomness nor maximum familiarity.
|
||||
|
||||
---
|
||||
|
||||
## 8. Feedback and learning
|
||||
|
||||
### 8.1 Feedback interpretation
|
||||
|
||||
| Signal | Interpretation | Immediate action |
|
||||
|---|---|---|
|
||||
| Skip under 5 sec | strong mismatch | remove route/track, reduce local risk, replan |
|
||||
| Skip under 20 sec | mismatch | penalise candidate/path, replan |
|
||||
| Late skip | weak negative/transition issue | modest penalty |
|
||||
| Completion | weak positive | update state/affinity modestly |
|
||||
| Replay | strong positive | reinforce affinity and comfort |
|
||||
| Keep/favourite | strong positive | reinforce entities and route |
|
||||
| Playlist add/share | very strong positive | reinforce strongly |
|
||||
| Mute/hide artist/genre | strong negative | exclude or suppress |
|
||||
| Manual search/open album | intent evidence | bias session appropriately |
|
||||
| Volume change | weak/noisy evidence | aggregate with other signals |
|
||||
| Session abandonment | delayed negative reward | evaluate preceding sequence |
|
||||
|
||||
Completion uses actual position and duration. Long intros must not be treated
|
||||
exactly like short tracks.
|
||||
|
||||
### 8.2 Dynamic exploration
|
||||
|
||||
Maintain exploration coefficient E in range 0–1 and discovery radius.
|
||||
|
||||
quick skip of unfamiliar track E -= 0.05
|
||||
completion of unfamiliar track E += 0.03
|
||||
save/favourite unknown artist E += 0.12
|
||||
repeated known completion E -= 0.01 only if comfort over budget
|
||||
explicit more-discovery control bounded immediate increase
|
||||
|
||||
Clamp and smooth updates so one event does not whiplash a session. Persist the
|
||||
evidence. play_of_never_seen or equivalent MUST be emitted by production
|
||||
playback; a read-only schema capability is not a feature.
|
||||
|
||||
### 8.3 Learning rollout
|
||||
|
||||
1. Ship deterministic rules with complete event and plan logging.
|
||||
2. Replay historical sessions for offline evaluation.
|
||||
3. Use contextual bandits for calibrated immediate/short-horizon weights and
|
||||
generator routing under safety limits.
|
||||
4. Consider offline RL/model-based sequence policy only after reliable
|
||||
off-policy evaluation exists.
|
||||
|
||||
Retain deterministic fallback and a kill switch for learned policy.
|
||||
|
||||
---
|
||||
|
||||
## 9. API and frontend contract
|
||||
|
||||
### 9.1 Start/resume
|
||||
|
||||
POST /api/v2/vibe/sessions
|
||||
|
||||
{
|
||||
"seedTrackId": "uuid or optional",
|
||||
"context": { "activity": "focus", "device": "headphones" },
|
||||
"intent": "optional mode",
|
||||
"resumeSessionId": "uuid or optional"
|
||||
}
|
||||
|
||||
Response includes sessionId, planVersion, now, revisable preview, and concise
|
||||
state summary. User identity comes from authenticated/trusted request context,
|
||||
not a silent shared default UUID.
|
||||
|
||||
### 9.2 Playback event
|
||||
|
||||
POST /api/v2/vibe/sessions/:sessionId/events
|
||||
|
||||
{
|
||||
"eventId": "client UUID",
|
||||
"type": "progress | completed | skipped | kept | disliked | ...",
|
||||
"trackId": "uuid",
|
||||
"positionMs": 12500,
|
||||
"durationMs": 203000,
|
||||
"payload": {}
|
||||
}
|
||||
|
||||
Response includes canonical planVersion, replacement preview, state summary,
|
||||
and replan reason.
|
||||
|
||||
The client MUST reconcile its unplayed Vibe buffer whenever replacement preview
|
||||
is returned. It must not keep playing stale prefetched items only because they
|
||||
were fetched before feedback. The currently loaded track is not interrupted
|
||||
unless the listener explicitly skips it.
|
||||
|
||||
### 9.3 Serve next
|
||||
|
||||
POST /api/v2/vibe/sessions/:sessionId/next
|
||||
{ "expectedPlanVersion": 4 }
|
||||
|
||||
The response is idempotent for request/plan version and marks the item served.
|
||||
If a newer plan exists, return that preview instead of a stale track.
|
||||
|
||||
### 9.4 UI requirements
|
||||
|
||||
- Present Vibe as an evolving session, not static playlist.
|
||||
- Show concise direction such as gentle discovery or late-night cooldown.
|
||||
- Preview only revisable next tracks and label them adaptive.
|
||||
- Offer Keep, Dislike, Skip, End, plus implicit progress tracking.
|
||||
- Offer lightweight context and more-familiar/more-discovery controls.
|
||||
- Explain a track on demand with provenance and human-readable reason.
|
||||
- Do not let ordinary library actions accidentally write Vibe feedback.
|
||||
- Preserve session identity through navigation and recover after transient
|
||||
network failure.
|
||||
|
||||
---
|
||||
|
||||
## 10. Operational requirements
|
||||
|
||||
### 10.1 Consistency and concurrency
|
||||
|
||||
- Serialize plan mutation per session.
|
||||
- Use monotonic planVersion and client optimistic concurrency.
|
||||
- Redis may cache active plans; Postgres event/plan records are authoritative.
|
||||
Redis expiry must never erase only session history.
|
||||
- Stale-session reaper ends inactive sessions and creates final fingerprint; it
|
||||
never deletes listener history.
|
||||
- Revalidate track state and availability immediately before serving.
|
||||
|
||||
### 10.2 Performance
|
||||
|
||||
- Candidate retrieval p95 under 250 ms for warm local catalogue.
|
||||
- Replan p95 under 750 ms for a 20-track baseline horizon.
|
||||
- Serve-next p95 under 150 ms when valid plan exists.
|
||||
- If ANN, external metadata, or context is unavailable, fall back to local
|
||||
graph/metadata/favourites and record degraded source state.
|
||||
|
||||
### 10.3 Observability
|
||||
|
||||
Log/measure per policy version and session:
|
||||
|
||||
- candidate counts/rejection reasons per generator;
|
||||
- metadata coverage;
|
||||
- constraint violations and relaxations;
|
||||
- replan latency/reason;
|
||||
- client stale-preview replacement success;
|
||||
- fatigue/budget/entropy trajectories;
|
||||
- discovery source/distance acceptance;
|
||||
- session outcome metrics.
|
||||
|
||||
Never log raw precise location or unnecessary personal context.
|
||||
|
||||
---
|
||||
|
||||
## 11. Success metrics
|
||||
|
||||
Primary metrics:
|
||||
|
||||
- average uninterrupted listening duration;
|
||||
- completed-session duration and return probability;
|
||||
- discovery acceptance by distance/source;
|
||||
- new artists saved/favourited;
|
||||
- playlist additions and intentional replays;
|
||||
- perceived freshness;
|
||||
- artist/genre/language/path diversity and repetition rate;
|
||||
- sessions with a successful surprise and healthy comfort anchor.
|
||||
|
||||
Secondary diagnostics:
|
||||
|
||||
- quick/medium skips, hides, abandonments;
|
||||
- plan replacement latency;
|
||||
- no-eligible-candidate rate;
|
||||
- metadata coverage/fallback frequency;
|
||||
- hard-constraint satisfaction;
|
||||
- similarity to recent sessions.
|
||||
|
||||
CTR is diagnostic only. It must not become the objective that causes
|
||||
favourite-artist loops.
|
||||
|
||||
---
|
||||
|
||||
## 12. Delivery phases and acceptance criteria
|
||||
|
||||
### Phase 0 — Correct session contract
|
||||
|
||||
Deliver durable sessions/events/versioned plans, session IDs on all playback
|
||||
events, and client replacement of unplayed preview after replan.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- A quick skip changes unplayed preview within one successful event round trip.
|
||||
- Completion/skip cannot be attributed to another session.
|
||||
- Backend restart does not lose event history or plan audit.
|
||||
|
||||
### Phase 1 — Enforced fatigue and diversity
|
||||
|
||||
Wire every budget/fatigue dimension used by policy into construction. Add album,
|
||||
language, vocal/instrumental, and route controls.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Fixtures prove artist, album, genre, language, and track constraints.
|
||||
- Changing a budget changes the plan, not only debug output.
|
||||
- Anti-loop response corrects the detected dimension.
|
||||
|
||||
### Phase 2 — Musical arcs and sequence planner
|
||||
|
||||
Add feature access, transition scoring, callbacks, surprise tokens, constrained
|
||||
beam search.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Energetic arc rises then cools within tolerance when features exist.
|
||||
- Discovery arc anchors a discovery between familiar items.
|
||||
- Chosen plans beat greedy order on offline fixture objective.
|
||||
|
||||
### Phase 3 — Context, long-term goals, session memory
|
||||
|
||||
Add context capture, fingerprints, weekly priors, and scheduling.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Context affects ranking only with comparable evidence; otherwise fallback is safe.
|
||||
- Consecutive unseeded sessions are less similar than baseline without reducing
|
||||
completion rate.
|
||||
- Album/artist goals spread exposure across sessions.
|
||||
|
||||
### Phase 4 — Dynamic exploration and learning
|
||||
|
||||
Emit complete feedback, calibrate radius, add offline evaluation, then guarded
|
||||
online learning.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Unknown-track completion/skip changes exploration in expected bounded direction.
|
||||
- Learned decisions have policy version, feature log, and fallback.
|
||||
- No rollout proceeds without offline and guardrail metrics.
|
||||
|
||||
### Phase 5 — Embeddings and world model
|
||||
|
||||
Introduce ANN retrieval and, only when data quality warrants it, learn:
|
||||
|
||||
listener_state(t) + selected_track -> listener_state(t + 1)
|
||||
|
||||
The controller can then select tracks partly for the state they create, not
|
||||
only immediate affinity. This phase is optional and never blocks the
|
||||
deterministic director.
|
||||
|
||||
---
|
||||
|
||||
## 13. Test matrix
|
||||
|
||||
| Scenario | Required assertion |
|
||||
|---|---|
|
||||
| Fast skip of unknown | preview replaced; radius shrinks; track excluded |
|
||||
| Accepted discovery | radius grows; source receives positive attribution |
|
||||
| Artist loop | cap respected; compatible bridge used |
|
||||
| Language fatigue | alternate language/instrumental appears if available |
|
||||
| Album deep dive | tracks spread; no accidental completion block |
|
||||
| Low entropy | explainable diversity plus familiar anchor |
|
||||
| High entropy | comfort inserted without favourite collapse |
|
||||
| Context change | new preview without losing fatigue history |
|
||||
| Redis loss | durable event/plan restores coherent preview |
|
||||
| Sparse metadata | safe plan and reduced-confidence record |
|
||||
| Explicit artist intent | soft diversity may yield; served tracks never repeat |
|
||||
| Long session | no served duplicate; callbacks/surprise/budgets bounded |
|
||||
|
||||
Use deterministic catalogue fixtures with known artists, albums, languages,
|
||||
audio features, and graph routes. Test resulting sequences, not merely whether
|
||||
a candidate list was sorted.
|
||||
|
||||
## Final product definition
|
||||
|
||||
Vibe succeeds when a listener can spend six hours with it and feel it
|
||||
understood both their taste and their moment: it mixed comfort with meaningful
|
||||
discovery, maintained a coherent evolving arc, avoided fatigue and obvious
|
||||
loops, made memorable returns, and still left tomorrow feeling fresh.
|
||||
|
||||
Reference in New Issue
Block a user