Feat/vibe v2 session director #4
@@ -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) => ![
|
||||
|
kami
commented
why? why?
|
||||
'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));
|
||||
|
kami
commented
regexep should be a const. regexep should be a const.
|
||||
}
|
||||
|
||||
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 {
|
||||
|
kami
commented
vibe is not blocked for one user only. but vibe is unique for each user. vibe is not blocked for one user only. but vibe is unique for each user.
|
||||
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 });
|
||||
|
kami
commented
that's too much checks and error codes in one function, don't you think? that's too much checks and error codes in one function, don't you think?
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
|
kami
commented
elaborate. elaborate.
kami
commented
again. too much error catching in one function. again. too much error catching in one function.
|
||||
|
||||
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
|
||||
|
kami
commented
not sure if it's right to provide and support two /next paths — for normal Playlist and for the Vibe. not sure if it's right to provide and support two /next paths — for normal Playlist and for the Vibe.
|
||||
? 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>
|
||||
|
||||
+14
-110
@@ -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.');
|
||||
}
|
||||
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 } }));
|
||||
|
||||
function responseError(status: number, code?: string) {
|
||||
return new AxiosError('request failed', undefined, undefined, undefined, {
|
||||
data: code ? { code } : {}, status, statusText: 'error', headers: {}, config: {} as never,
|
||||
});
|
||||
}
|
||||
import { vibeService } from './vibeService';
|
||||
|
||||
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'));
|
||||
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 expect(fetchNextBatch(3, 'session-a')).resolves.toEqual({ tracks: [track('one')], status: 'exhausted' });
|
||||
expect(next).toHaveBeenCalledWith('session-a');
|
||||
await vibeService.next('session', 3);
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/v2/vibe/sessions/session/advance', { expectedPlanVersion: 3 });
|
||||
});
|
||||
|
||||
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' });
|
||||
it('uses an explicit idempotency key to advance a served but unplayable item', async () => {
|
||||
post.mockResolvedValue({ data: { sessionId: 'session', planVersion: 3, now: null, preview: [] } });
|
||||
|
||||
await vibeService.advancePastUnplayable('session', 3, {
|
||||
eventId: 'event', planVersionId: 'plan', ordinal: 4, trackId: 'track',
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
const playback = usePlaybackStore.getState();
|
||||
playback.setQueue(result.tracks);
|
||||
playback.playTrack(result.tracks[0]);
|
||||
|
||||
return result;
|
||||
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.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(
|
||||
session
|
||||
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
|
||||
: { activeSessionId: null, seedTrackId: null }
|
||||
),
|
||||
|
||||
setActiveSession: (session) => set(
|
||||
session
|
||||
? { activeSessionId: session.sessionId, seedTrackId: session.seedTrackId }
|
||||
: { 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
seems rather useless or even redundant when we talk multi-user.