diff --git a/src/client/trace-tree/span-detail/span-detail.types.ts b/src/client/trace-tree/span-detail/span-detail.types.ts
index 04ca3cc..5697489 100644
--- a/src/client/trace-tree/span-detail/span-detail.types.ts
+++ b/src/client/trace-tree/span-detail/span-detail.types.ts
@@ -33,21 +33,19 @@ export type SpanAttributes =
export type SpanDetailModel = Readonly<{
span: SpanResponse;
durationMs: number;
- startOffsetSec: number;
- endOffsetSec: number;
+ // Offsets on the audio timeline (seconds from recording t=0), from the span's
+ // server-derived `audio_offset_ms`. Null when the replay has no anchor and the
+ // span can't be placed — the panel renders "—" rather than a misleading 0:00.
+ startOffsetSec: number | null;
+ endOffsetSec: number | null;
parentName: string | null;
attributes: SpanAttributes;
usage: readonly ModelUsageResponse[];
toolCalls: readonly ToolCallResponse[];
}>;
-/**
- * The slices of a `ReplayDetailResponse` needed to resolve a span detail.
- * `replayStartIso` lets the panel show start/end as a clock offset from
- * replay start — the same reading the waveform and the trace playhead use.
- */
+/** The slices of a `ReplayDetailResponse` needed to resolve a span detail. */
export type SpanDetailSource = Readonly<{
- replayStartIso: string;
spans: readonly SpanResponse[];
modelUsage: readonly ModelUsageResponse[];
toolCalls: readonly ToolCallResponse[];
diff --git a/src/client/trace-tree/trace-tree.test.tsx b/src/client/trace-tree/trace-tree.test.tsx
index a26ae8c..fb4caa3 100644
--- a/src/client/trace-tree/trace-tree.test.tsx
+++ b/src/client/trace-tree/trace-tree.test.tsx
@@ -54,6 +54,7 @@ function span(
started_at: new Date(REPLAY_START_MS + offsetStartMs).toISOString(),
ended_at: new Date(REPLAY_START_MS + offsetEndMs).toISOString(),
attributes_json: "{}",
+ audio_offset_ms: offsetStartMs,
};
}
@@ -61,7 +62,7 @@ describe("TraceTree", () => {
it("renders the empty state when there are no spans", () => {
render(
-
+
,
);
expect(screen.getByText(/No spans recorded/i)).toBeTruthy();
@@ -73,7 +74,6 @@ describe("TraceTree", () => {
,
@@ -89,7 +89,6 @@ describe("TraceTree", () => {
,
@@ -118,7 +117,6 @@ describe("TraceTree", () => {
,
@@ -136,7 +134,6 @@ describe("TraceTree", () => {
,
@@ -157,7 +154,6 @@ describe("TraceTree", () => {
,
@@ -171,7 +167,6 @@ describe("TraceTree", () => {
,
@@ -194,7 +189,6 @@ describe("TraceTree selection", () => {
@@ -263,7 +257,7 @@ describe("TracePlayhead", () => {
render(
-
+
,
);
return (state: PlayheadState) => act(() => publish(state));
@@ -272,7 +266,7 @@ describe("TracePlayhead", () => {
it("renders no cursor when the player is not ready (no audio)", () => {
render(
-
+
,
);
expect(screen.queryByTestId("trace-playhead")).toBeNull();
diff --git a/src/client/trace-tree/trace-tree.tsx b/src/client/trace-tree/trace-tree.tsx
index 1c6ca05..6e8d904 100644
--- a/src/client/trace-tree/trace-tree.tsx
+++ b/src/client/trace-tree/trace-tree.tsx
@@ -24,7 +24,6 @@ const ZOOM_STEP = 1.5;
interface TraceTreeProps {
turns: readonly ReplayTurnResponse[];
spans: readonly SpanResponse[];
- replayStartIso: string;
zoom: number;
}
@@ -32,12 +31,12 @@ type RenderState =
| { kind: "empty" }
| { kind: "ready"; rows: readonly TreeRow[]; scale: TraceScale };
-export function TraceTree({ turns, spans, replayStartIso, zoom }: TraceTreeProps) {
+export function TraceTree({ turns, spans, zoom }: TraceTreeProps) {
const state = useMemo
(() => {
- const { rows, scale } = buildTree(turns, spans, replayStartIso);
+ const { rows, scale } = buildTree(turns, spans);
if (rows.length === 0) return { kind: "empty" };
return { kind: "ready", rows, scale };
- }, [turns, spans, replayStartIso]);
+ }, [turns, spans]);
return match(state)
.with({ kind: "empty" }, () => )
diff --git a/src/server/assertions/assertions.evaluator.test.ts b/src/server/assertions/assertions.evaluator.test.ts
index e0b4959..99a1348 100644
--- a/src/server/assertions/assertions.evaluator.test.ts
+++ b/src/server/assertions/assertions.evaluator.test.ts
@@ -238,6 +238,40 @@ describe("evaluateAssertion — max_ttft_ms", () => {
});
});
+describe("evaluateAssertion — no recording anchor", () => {
+ // Without recording_started_at the server can't map span timestamps onto
+ // the audio timeline, so span-attributed assertions can't be evaluated —
+ // they must `error`, never silently pass/fail. Text/latency assertions are
+ // unaffected (they don't depend on the timeline).
+ it("errors tool_called / tool_not_called / tool_args_match / max_ttft_ms", () => {
+ const ctx = makeAssertionContext({
+ hasRecordingAnchor: false,
+ toolCalls: [makeToolCallRow({ name: "lookup" })],
+ ttftMs: 100,
+ });
+ for (const assertion of [
+ { kind: "tool_called", name: "lookup" },
+ { kind: "tool_not_called", name: "lookup" },
+ { kind: "tool_args_match", name: "lookup", args: {} },
+ { kind: "max_ttft_ms", max_ms: 500 },
+ ] as const) {
+ expect(evaluateAssertion(assertion, ctx).status).toBe("errored");
+ }
+ });
+
+ it("does NOT error text or latency assertions (timeline-independent)", () => {
+ const ctx = makeAssertionContext({
+ hasRecordingAnchor: false,
+ transcript: "hello world",
+ agentResponseMs: 1200,
+ });
+ expect(
+ evaluateAssertion({ kind: "contains", text: "hello", case_insensitive: true }, ctx).status,
+ ).toBe("passed");
+ expect(evaluateAssertion({ kind: "max_latency_ms", max_ms: 2000 }, ctx).status).toBe("passed");
+ });
+});
+
describe("deepPartialMatch", () => {
it("matches nested objects partially", () => {
expect(deepPartialMatch({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 }, e: 99 })).toBe(true);
diff --git a/src/server/assertions/assertions.evaluator.ts b/src/server/assertions/assertions.evaluator.ts
index 7c25305..aafae7b 100644
--- a/src/server/assertions/assertions.evaluator.ts
+++ b/src/server/assertions/assertions.evaluator.ts
@@ -3,6 +3,23 @@ import { match } from "ts-pattern";
import type { Assertion, AssertionContext, AssertionOutcome } from "./assertions.types.ts";
const NO_TRANSCRIPT_MESSAGE = "no transcript available for this turn";
+const NO_ANCHOR_MESSAGE =
+ "no recording anchor — cannot attribute spans to turns (upload omitted X-Recording-Started-At)";
+
+/**
+ * Assertion kinds whose verdict depends on span→turn attribution, which needs
+ * the recording anchor. Classified once here rather than guarded ad hoc in each
+ * arm: without the anchor these all map to `errored` (not a misleading
+ * pass/fail), and a new timeline-dependent kind inherits that behaviour by
+ * being added to this set — the omission is visible in one place instead of
+ * hiding behind a forgotten per-arm guard.
+ */
+const TIMELINE_DEPENDENT_KINDS: ReadonlySet = new Set([
+ "tool_called",
+ "tool_not_called",
+ "tool_args_match",
+ "max_ttft_ms",
+]);
const passed: AssertionOutcome = { status: "passed", message: null };
function failed(message: string): AssertionOutcome {
@@ -25,6 +42,9 @@ function errored(message: string): AssertionOutcome {
* misleading "no match" verdict that's actually a transcription failure.
*/
export function evaluateAssertion(assertion: Assertion, ctx: AssertionContext): AssertionOutcome {
+ if (!ctx.hasRecordingAnchor && TIMELINE_DEPENDENT_KINDS.has(assertion.kind)) {
+ return errored(NO_ANCHOR_MESSAGE);
+ }
return match(assertion)
.with({ kind: "contains" }, (a) => {
if (ctx.transcript === null) return errored(NO_TRANSCRIPT_MESSAGE);
@@ -102,7 +122,15 @@ export function evaluateAssertion(assertion: Assertion, ctx: AssertionContext):
})
.with({ kind: "max_ttft_ms" }, (a) => {
if (ctx.metrics.ttftMs === null) {
- return errored("no ttft_ms metric — no gen_ai.client.* span attributed to this turn");
+ // Distinguish the two failure modes so the dev knows whether the
+ // problem is "no LLM call landed in this turn" (mistiming /
+ // attribution) vs "the calls that did land don't emit TTFT"
+ // (instrumentation gap) — they need different fixes.
+ return errored(
+ ctx.modelUsage.length === 0
+ ? "no ttft_ms — no model call landed in this turn's window"
+ : "no ttft_ms — no in-window model call carried gen_ai.response.time_to_first_chunk",
+ );
}
return ctx.metrics.ttftMs <= a.max_ms
? passed
diff --git a/src/server/assertions/assertions.test-utils.ts b/src/server/assertions/assertions.test-utils.ts
index 95214bb..146325b 100644
--- a/src/server/assertions/assertions.test-utils.ts
+++ b/src/server/assertions/assertions.test-utils.ts
@@ -6,6 +6,7 @@ export interface MakeAssertionContextOverrides {
turnIdx?: number;
turnRole?: "user" | "agent";
transcript?: string | null;
+ hasRecordingAnchor?: boolean;
toolCalls?: readonly ToolCallRow[];
modelUsage?: readonly ModelUsageRow[];
agentResponseMs?: number | null;
@@ -19,6 +20,10 @@ export function makeAssertionContext(
turnIdx: overrides.turnIdx ?? 0,
turnRole: overrides.turnRole ?? "agent",
transcript: overrides.transcript === undefined ? "" : overrides.transcript,
+ // Default true: most assertion tests aren't exercising the no-anchor
+ // path, and a false default would silently flip every tool test to
+ // `errored`.
+ hasRecordingAnchor: overrides.hasRecordingAnchor ?? true,
toolCalls: overrides.toolCalls ?? [],
modelUsage: overrides.modelUsage ?? [],
metrics: {
@@ -37,7 +42,6 @@ export function makeToolCallRow(overrides: Partial = {}): ToolCallR
return {
id: overrides.id ?? toolCallId,
replayId: overrides.replayId ?? "00000000-0000-0000-0000-000000000001",
- turnIdx: overrides.turnIdx ?? 0,
spanId: overrides.spanId ?? `span-${toolCallId}`,
name: overrides.name ?? "lookup",
argsJson: overrides.argsJson ?? null,
diff --git a/src/server/assertions/assertions.types.ts b/src/server/assertions/assertions.types.ts
index 0f42474..6acd5a8 100644
--- a/src/server/assertions/assertions.types.ts
+++ b/src/server/assertions/assertions.types.ts
@@ -98,13 +98,24 @@ export const AssertionsArraySchema = v.pipe(
* — every text-based assertion variant maps that to `status: "errored"`
* with a stable message, rather than asserting against an empty string.
*
- * `toolCalls` / `modelUsage` arrive pre-filtered to this turn by the
- * caller — the evaluator does not re-filter by `turn_idx`.
+ * `toolCalls` / `modelUsage` arrive pre-filtered to this turn by the caller
+ * — membership is the audio-timeline window `[turnStartMs, turnEndMs)`
+ * computed in the evaluate-replay stage (see `src/server/replays/timeline.ts`),
+ * not a stored `turn_idx`.
+ *
+ * `hasRecordingAnchor` is false when the replay has no `recording_started_at`
+ * (older SDK). Without it, span `started_at` can't be mapped onto the audio
+ * timeline, so tool-presence and ttft assertions can't be attributed to a
+ * turn — they map to `errored`, never a misleading pass/fail.
+ *
+ * `metrics.ttftMs` is the earliest in-window model call's
+ * `model_usage.ttft_ms` (or null), NOT a per-turn aggregate.
*/
export interface AssertionContext {
readonly turnIdx: number;
readonly turnRole: TurnRole;
readonly transcript: string | null;
+ readonly hasRecordingAnchor: boolean;
readonly toolCalls: readonly ToolCallRow[];
readonly modelUsage: readonly ModelUsageRow[];
readonly metrics: {
diff --git a/src/server/audio/audio.errors.test.ts b/src/server/audio/audio.errors.test.ts
index 7abec9e..45e33c7 100644
--- a/src/server/audio/audio.errors.test.ts
+++ b/src/server/audio/audio.errors.test.ts
@@ -7,6 +7,7 @@ import {
AudioTurnsInvariantError,
InvalidAudioExtensionError,
InvalidAudioPathError,
+ InvalidRecordingStartedAtError,
UnsupportedAudioContentTypeError,
} from "./audio.errors.ts";
import { describe, expect, it } from "bun:test";
@@ -29,6 +30,23 @@ describe("audio errors", () => {
expect(e.issues).toBe(issues);
});
+ it("InvalidRecordingStartedAtError is an AudioError with issues", () => {
+ const issues = [
+ {
+ kind: "schema",
+ type: "iso_timestamp",
+ input: "nope",
+ expected: "ISO timestamp",
+ received: "nope",
+ message: "m",
+ },
+ ] as const;
+ const e = new InvalidRecordingStartedAtError(issues);
+ expect(e).toBeInstanceOf(AudioError);
+ expect(e.name).toBe("InvalidRecordingStartedAtError");
+ expect(e.issues).toBe(issues);
+ });
+
it("UnsupportedAudioContentTypeError keeps the offending content type", () => {
const e = new UnsupportedAudioContentTypeError("application/json");
expect(e.name).toBe("UnsupportedAudioContentTypeError");
diff --git a/src/server/audio/audio.errors.ts b/src/server/audio/audio.errors.ts
index f65174d..515a3c7 100644
--- a/src/server/audio/audio.errors.ts
+++ b/src/server/audio/audio.errors.ts
@@ -19,6 +19,17 @@ export class InvalidAudioPathError extends AudioError {
}
}
+/** `X-Recording-Started-At` header was present but not a valid ISO timestamp. */
+export class InvalidRecordingStartedAtError extends AudioError {
+ readonly issues: readonly BaseIssue[];
+
+ constructor(issues: readonly BaseIssue[]) {
+ super("Invalid X-Recording-Started-At header");
+ this.name = "InvalidRecordingStartedAtError";
+ this.issues = issues;
+ }
+}
+
export class UnsupportedAudioContentTypeError extends AudioError {
readonly contentType: string | null;
diff --git a/src/server/audio/audio.router.test.ts b/src/server/audio/audio.router.test.ts
index 08cb6a9..f8e85f6 100644
--- a/src/server/audio/audio.router.test.ts
+++ b/src/server/audio/audio.router.test.ts
@@ -1,8 +1,10 @@
+import { eq } from "drizzle-orm";
import * as v from "valibot";
import { makeFakeJobRunner } from "@/server/jobs/jobs.test-utils.ts";
import { makeReplayEvents } from "@/server/replays/replays.events.ts";
import { createApp } from "@/server/server.ts";
+import { replays } from "@/server/store/schema.ts";
import type { Store } from "@/server/store/store.ts";
import { makeTempStore } from "@/server/store/test-utils.ts";
import { makeFakeTtsProvider } from "@/server/tts/tts.test-utils.ts";
@@ -71,6 +73,56 @@ describe("POST /v1/replays/:id/audio — happy path", () => {
});
});
+describe("POST /v1/replays/:id/audio — X-Recording-Started-At header", () => {
+ async function replayRowAfterUpload(headers: Record) {
+ const { replayId } = await seedReplayForAudio(store);
+ const res = await app.request(replayAudioUrl(replayId), {
+ method: "POST",
+ headers: { "Content-Type": "audio/wav", ...headers },
+ body: fakeAudioBytes(),
+ });
+ const row = store.db.select().from(replays).where(eq(replays.id, replayId)).get();
+ return { res, row };
+ }
+
+ it("persists the anchor on the replay row", async () => {
+ const anchor = "2026-05-26T14:31:31.023Z";
+ const { res, row } = await replayRowAfterUpload({ "X-Recording-Started-At": anchor });
+ expect(res.status).toBe(200);
+ expect(row?.recordingStartedAt).toBe(anchor);
+ });
+
+ it("stores null when the header is absent (older SDK)", async () => {
+ const { res, row } = await replayRowAfterUpload({});
+ expect(res.status).toBe(200);
+ expect(row?.recordingStartedAt).toBeNull();
+ });
+
+ it("rejects a malformed anchor with 400 — never silently drops a provided value", async () => {
+ const { res, row } = await replayRowAfterUpload({ "X-Recording-Started-At": "yesterday-ish" });
+ expect(res.status).toBe(400);
+ const body = v.parse(
+ v.object({ error: v.literal("invalid_recording_started_at") }),
+ await res.json(),
+ );
+ expect(body.error).toBe("invalid_recording_started_at");
+ expect(row?.recordingStartedAt).toBeNull();
+ });
+
+ // These pass valibot's ISO regex but Date.parse() returns NaN for them, so
+ // storing them would make every downstream offset null while the row looks
+ // anchored — tool/ttft assertions would then report misleading fail/pass
+ // instead of `errored`. Reject at the boundary instead.
+ it.each([
+ "2026-05-26T14:31:31+02",
+ "2026-05-26T14:31:31 +02:00",
+ ])("rejects an ISO-shaped but unparseable anchor (%s) with 400", async (anchor) => {
+ const { res, row } = await replayRowAfterUpload({ "X-Recording-Started-At": anchor });
+ expect(res.status).toBe(400);
+ expect(row?.recordingStartedAt).toBeNull();
+ });
+});
+
describe("POST /v1/replays/:id/audio — rejections", () => {
it("rejects an unsupported content type with 415", async () => {
const { replayId } = await seedReplayForAudio(store);
@@ -130,8 +182,6 @@ describe("POST /v1/replays/:id/audio — rejections", () => {
describe("POST /v1/replays/:id/audio — lifecycle 409", () => {
it("returns 409 when the replay is `analyzing`", async () => {
const { replayId } = await seedReplayForAudio(store);
- const { replays } = await import("@/server/store/schema.ts");
- const { eq } = await import("drizzle-orm");
store.db
.update(replays)
.set({ lifecycleState: "analyzing", analysisStep: "vad", jobId: "j-1" })
@@ -147,8 +197,6 @@ describe("POST /v1/replays/:id/audio — lifecycle 409", () => {
it("returns 409 when the replay is `completed`", async () => {
const { replayId } = await seedReplayForAudio(store);
- const { replays } = await import("@/server/store/schema.ts");
- const { eq } = await import("drizzle-orm");
store.db
.update(replays)
.set({ lifecycleState: "completed" })
diff --git a/src/server/audio/audio.router.ts b/src/server/audio/audio.router.ts
index 462df9f..981f893 100644
--- a/src/server/audio/audio.router.ts
+++ b/src/server/audio/audio.router.ts
@@ -23,6 +23,7 @@ import {
AudioReplayNotFoundError,
InvalidAudioExtensionError,
InvalidAudioPathError,
+ InvalidRecordingStartedAtError,
ReplayUploadStateError,
UnsupportedAudioContentTypeError,
} from "./audio.errors.ts";
@@ -33,6 +34,7 @@ import {
CONTENT_TYPE_TO_EXTENSION,
EXTENSION_TO_RESPONSE_CONTENT_TYPE,
MAX_AUDIO_BYTES,
+ RecordingStartedAtSchema,
UploadAudioResponseSchema,
} from "./audio.types.ts";
@@ -61,6 +63,14 @@ export function createAudioRouter(store: Store, audioRoot: string): Hono {
required: true,
schema: openApiSchemaFromValibot(ReplayIdSchema),
},
+ {
+ in: "header",
+ name: "X-Recording-Started-At",
+ required: false,
+ description:
+ "Wall-clock (UTC ISO-8601) of audio sample 0 — the driver's earliest turn `started_at`. The sole origin for mapping span timestamps onto the audio timeline. Omit only from older SDKs; span→turn attribution is skipped when absent.",
+ schema: openApiSchemaFromValibot(RecordingStartedAtSchema),
+ },
],
requestBody: { required: true, content: UPLOAD_CONTENT_MAP },
responses: {
@@ -71,7 +81,8 @@ export function createAudioRouter(store: Store, audioRoot: string): Hono {
},
},
"400": {
- description: "Path failed validation.",
+ description:
+ "Replay id failed validation (`invalid_audio_path`), or the X-Recording-Started-At header was present but not a parseable ISO-8601 timestamp (`invalid_recording_started_at`).",
content: {
"application/json": { schema: openApiSchemaFromValibot(ValidationErrorResponseSchema) },
},
@@ -114,12 +125,14 @@ export function createAudioRouter(store: Store, audioRoot: string): Hono {
async (c) => {
const replayId = parseReplayIdParam(c.req.param("id"));
const contentType = parseAudioContentType(c.req.header("content-type"));
+ const recordingStartedAt = parseRecordingStartedAt(c.req.header("x-recording-started-at"));
const buffer = await c.req.arrayBuffer();
const bytes = new Uint8Array(buffer);
const audioPath = await uploadReplayAudio(store, audioRoot, {
replayId,
contentType,
bytes,
+ recordingStartedAt,
});
return c.json({ ok: true, audio_path: audioPath });
},
@@ -177,6 +190,9 @@ export function createAudioRouter(store: Store, audioRoot: string): Hono {
.with(P.instanceOf(InvalidAudioPathError), (e) =>
c.json({ error: "invalid_audio_path", issues: sanitizeIssues(e.issues) }, 400),
)
+ .with(P.instanceOf(InvalidRecordingStartedAtError), (e) =>
+ c.json({ error: "invalid_recording_started_at", issues: sanitizeIssues(e.issues) }, 400),
+ )
.with(P.instanceOf(UnsupportedAudioContentTypeError), (e) =>
c.json({ error: "unsupported_content_type", content_type: e.contentType }, 415),
)
@@ -231,3 +247,13 @@ function parseAudioContentType(header: string | undefined): AudioContentType {
if (!result.success) throw new UnsupportedAudioContentTypeError(stripped);
return result.output;
}
+
+// Optional: absent header ⇒ null (older SDK; offsets undefined, attribution
+// skipped). Present-but-malformed ⇒ 400 — a provided anchor we can't parse is
+// a client bug we surface loudly, never silently drop.
+function parseRecordingStartedAt(header: string | undefined): string | null {
+ if (header === undefined) return null;
+ const result = v.safeParse(RecordingStartedAtSchema, header.trim());
+ if (!result.success) throw new InvalidRecordingStartedAtError(result.issues);
+ return result.output;
+}
diff --git a/src/server/audio/audio.service.test.ts b/src/server/audio/audio.service.test.ts
index 60a327e..eda8f72 100644
--- a/src/server/audio/audio.service.test.ts
+++ b/src/server/audio/audio.service.test.ts
@@ -36,6 +36,7 @@ describe("uploadReplayAudio / readReplayAudio", () => {
const rel = await uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes,
});
expect(rel).toBe(join(replayId, "replay.wav"));
@@ -49,12 +50,14 @@ describe("uploadReplayAudio / readReplayAudio", () => {
await uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(1),
});
const second = fakeAudioBytes(2);
await uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: second,
});
const result = await readReplayAudio(store, audio.path, replayId);
@@ -80,6 +83,7 @@ describe("uploadReplayAudio / readReplayAudio", () => {
uploadReplayAudio(store, audio.path, {
replayId: "00000000-0000-0000-0000-000000000099",
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(),
}),
).rejects.toBeInstanceOf(AudioReplayNotFoundError);
@@ -98,6 +102,7 @@ describe("uploadReplayAudio — lifecycle guard", () => {
uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(),
}),
).resolves.toBeString();
@@ -108,6 +113,7 @@ describe("uploadReplayAudio — lifecycle guard", () => {
await uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(1),
});
// Now in recording_uploaded — re-upload should still work (e.g.
@@ -116,6 +122,7 @@ describe("uploadReplayAudio — lifecycle guard", () => {
uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(2),
}),
).resolves.toBeString();
@@ -132,6 +139,7 @@ describe("uploadReplayAudio — lifecycle guard", () => {
uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(),
}),
);
@@ -152,6 +160,7 @@ describe("uploadReplayAudio — lifecycle guard", () => {
uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(),
}),
).rejects.toBeInstanceOf(ReplayUploadStateError);
@@ -168,6 +177,7 @@ describe("uploadReplayAudio — lifecycle guard", () => {
uploadReplayAudio(store, audio.path, {
replayId,
contentType: "audio/wav",
+ recordingStartedAt: null,
bytes: fakeAudioBytes(),
}),
).rejects.toBeInstanceOf(ReplayUploadStateError);
diff --git a/src/server/audio/audio.service.ts b/src/server/audio/audio.service.ts
index b3439fc..4f1de64 100644
--- a/src/server/audio/audio.service.ts
+++ b/src/server/audio/audio.service.ts
@@ -119,9 +119,19 @@ export async function readConversationTurnAudio(
export async function uploadReplayAudio(
store: Store,
audioRoot: string,
- params: { replayId: string; contentType: AudioContentType; bytes: Uint8Array },
+ params: {
+ replayId: string;
+ contentType: AudioContentType;
+ bytes: Uint8Array;
+ /** Wall-clock of audio sample 0 (driver's `min(segment.started_at)`),
+ * from the `X-Recording-Started-At` header. `null` when the SDK doesn't
+ * send it — span→audio attribution is then skipped (spec 0001). Required
+ * (not optional) so a caller can never silently persist a null anchor by
+ * forgetting the field; passing `null` is an explicit choice. */
+ recordingStartedAt: string | null;
+ },
): Promise {
- const { replayId, contentType, bytes } = params;
+ const { replayId, contentType, bytes, recordingStartedAt } = params;
const existing = findReplay(store, replayId);
if (existing === undefined) {
throw new AudioReplayNotFoundError(replayId);
@@ -140,7 +150,7 @@ export async function uploadReplayAudio(
store.db
.update(replays)
- .set({ audioPath: relativePath, lifecycleState: "recording_uploaded" })
+ .set({ audioPath: relativePath, lifecycleState: "recording_uploaded", recordingStartedAt })
.where(eq(replays.id, replayId))
.run();
diff --git a/src/server/audio/audio.types.ts b/src/server/audio/audio.types.ts
index 9b49fc2..c5f3199 100644
--- a/src/server/audio/audio.types.ts
+++ b/src/server/audio/audio.types.ts
@@ -48,6 +48,23 @@ export const UploadAudioResponseSchema = v.object({
});
export type UploadAudioResponse = v.InferOutput;
+// Wall-clock (UTC ISO-8601) of audio sample 0, sent as the
+// `X-Recording-Started-At` request header on POST /audio. Optional — older
+// SDKs omit it, in which case span→audio offsets are undefined and attribution
+// is skipped (see spec 0001). When present it must be a valid ISO timestamp.
+//
+// The extra Date.parse gate is load-bearing: valibot's `isoTimestamp()` accepts
+// forms `Date.parse` returns NaN for (e.g. an hour-only offset `…+02`, a space
+// before the offset). Storing one of those would leave the replay looking
+// anchored while every derived `audio_offset_ms` is null — tool/ttft assertions
+// would then report a misleading pass/fail instead of `errored`. Since every
+// consumer maps the anchor via `Date.parse`, reject anything it can't parse.
+export const RecordingStartedAtSchema = v.pipe(
+ v.string(),
+ v.isoTimestamp(),
+ v.check((s) => Number.isFinite(Date.parse(s)), "must be a Date.parse-able ISO-8601 timestamp"),
+);
+
export interface AudioStream {
readonly stream: ReadableStream;
readonly contentLength: number;
diff --git a/src/server/jobs/analyze-replay/analyze-replay.processor.test.ts b/src/server/jobs/analyze-replay/analyze-replay.processor.test.ts
index 15fd902..91a7939 100644
--- a/src/server/jobs/analyze-replay/analyze-replay.processor.test.ts
+++ b/src/server/jobs/analyze-replay/analyze-replay.processor.test.ts
@@ -8,14 +8,7 @@ import type { StereoWav } from "@/server/audio/audio.types.ts";
import { writeStereoWav } from "@/server/audio/audio.wav.ts";
import { makeFakeJobRunner } from "@/server/jobs/jobs.test-utils.ts";
import { makeReplayEvents } from "@/server/replays/replays.events.ts";
-import {
- modelUsage,
- replays,
- replayTurns,
- speechSegments,
- toolCalls,
- turnTranscripts,
-} from "@/server/store/schema.ts";
+import { replays, replayTurns, speechSegments, turnTranscripts } from "@/server/store/schema.ts";
import type { Store } from "@/server/store/store.ts";
import { makeTempStore } from "@/server/store/test-utils.ts";
import { makeFakeTranscriptionProvider } from "@/server/transcription/transcription.test-utils.ts";
@@ -152,58 +145,6 @@ describe("analyze-replay processor", () => {
expect(runner.enqueued).toEqual([{ name: "calculate-metrics", payload: { replayId } }]);
});
- it("backfills tool_calls.turn_idx by timestamp window inside the VAD transaction", async () => {
- const { replayId } = await seedReplayForAudio(store);
- const wav = makeStereo({
- userBlocks: [
- { durationMs: 100, voiced: false },
- { durationMs: 300, voiced: true },
- { durationMs: 100, voiced: false },
- ],
- agentBlocks: [
- { durationMs: 500, voiced: false },
- { durationMs: 300, voiced: true },
- ],
- });
- const wavBytes = writeStereoWav(wav);
- const relPath = `${replayId}/replay.wav`;
- const absPath = join(audio.path, relPath);
- await mkdir(dirname(absPath), { recursive: true });
- await writeFile(absPath, wavBytes);
- store.db
- .update(replays)
- .set({ audioPath: relPath, lifecycleState: "analyzing", analysisStep: "vad" })
- .where(eq(replays.id, replayId))
- .run();
-
- const replayRow = store.db.select().from(replays).where(eq(replays.id, replayId)).get();
- if (replayRow === undefined) throw new Error("replay row vanished");
- // Tool call recorded mid-agent-turn (agent voice starts at ~500ms).
- // 600ms after the replay's start should land in the agent turn.
- const replayStartMs = Date.parse(replayRow.startedAt);
- store.db
- .insert(toolCalls)
- .values({
- replayId,
- turnIdx: null,
- spanId: "s1",
- name: "lookup",
- argsJson: null,
- resultJson: null,
- startedAt: new Date(replayStartMs + 600).toISOString(),
- endedAt: new Date(replayStartMs + 700).toISOString(),
- latencyMs: 100,
- })
- .run();
-
- const { processor } = makeProcessor();
- await processor({ replayId });
-
- const tcRows = store.db.select().from(toolCalls).where(eq(toolCalls.replayId, replayId)).all();
- expect(tcRows.length).toBe(1);
- expect(tcRows[0]?.turnIdx).not.toBeNull();
- });
-
it("stamps failed + failure_reason='transcription_failed' when the provider errors", async () => {
const { replayId } = await seedReplayForAudio(store);
const wav = makeStereo({
@@ -284,7 +225,3 @@ describe("analyze-replay processor", () => {
);
});
});
-
-// Silence the unused-import lint on modelUsage — kept for parity with the
-// implementation under test (turn_idx is backfilled on both tables).
-void modelUsage;
diff --git a/src/server/jobs/analyze-replay/analyze-replay.processor.ts b/src/server/jobs/analyze-replay/analyze-replay.processor.ts
index a238f60..a804791 100644
--- a/src/server/jobs/analyze-replay/analyze-replay.processor.ts
+++ b/src/server/jobs/analyze-replay/analyze-replay.processor.ts
@@ -1,7 +1,7 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
-import { and, eq, isNull } from "drizzle-orm";
+import { eq } from "drizzle-orm";
import { sliceTurnAudio } from "@/server/audio/audio.slice.ts";
import { deriveTurns } from "@/server/audio/audio.turns.ts";
@@ -10,14 +10,7 @@ import { runVadOnChannel } from "@/server/audio/audio.vad.ts";
import { readStereoWav, resamplePcm } from "@/server/audio/audio.wav.ts";
import type { ReplayEvents } from "@/server/replays/replays.events.ts";
import { findReplay, markReplayFailed } from "@/server/replays/replays.service.ts";
-import {
- modelUsage,
- replays,
- replayTurns,
- speechSegments,
- toolCalls,
- turnTranscripts,
-} from "@/server/store/schema.ts";
+import { replays, replayTurns, speechSegments, turnTranscripts } from "@/server/store/schema.ts";
import type { Store } from "@/server/store/store.ts";
import { MissingProviderCredentialError } from "@/server/transcription/transcription.errors.ts";
import type { TranscriptionProvider } from "@/server/transcription/transcription.types.ts";
@@ -43,11 +36,10 @@ const VAD_SAMPLE_RATE = 16_000;
* all in two commits inside the same processor invocation so a partial
* transcription failure leaves the VAD output intact for debugging.
*
- * Backfills `tool_calls.turn_idx` and `model_usage.turn_idx` from the
- * derived turn boundaries inside the VAD transaction. The OTLP receiver
- * writes those rows with `turn_idx=null`; turn attribution is server-
- * authoritative based on `replay_turns.voice_start_ms..voice_end_ms`,
- * not on driver-emitted span baggage.
+ * Tool/model spans carry no stored turn idx — they're attributed to turns
+ * at read/eval time by mapping their wall-clock `started_at` onto the audio
+ * timeline (see `src/server/replays/timeline.ts`), so this stage no longer
+ * touches `tool_calls` / `model_usage`.
*
* On success: enqueues `calculate-metrics` for the same replay id and
* leaves the row in `analyzing` (analysis_step transitions vad →
@@ -136,8 +128,6 @@ export function makeAnalyzeProcessor(
.run();
}
- backfillTurnIdx(tx, replayId, replay.startedAt, turns);
-
tx.update(replays).set({ analysisStep: "transcribe" }).where(eq(replays.id, replayId)).run();
return true;
@@ -276,76 +266,3 @@ async function runTranscriptionStage(
return rows.length;
}
-
-type TxHandle = Parameters[0]>[0];
-
-interface TurnWindow {
- readonly idx: number;
- readonly voiceStartMs: number;
- readonly voiceEndMs: number;
-}
-
-/**
- * Set `turn_idx` on `tool_calls` / `model_usage` rows whose `started_at`
- * falls inside one of the derived turns' voice window. The OTLP receiver
- * writes these rows before VAD has a chance to compute turn boundaries —
- * this UPDATE is the single point of attribution.
- *
- * `started_at` is the wall-clock ISO timestamp the span carried;
- * `replays.started_at` is the wall-clock at the start of the run.
- * Convert both to ms-since-epoch and subtract to get an offset in the
- * same coordinate space as `voice_start_ms / voice_end_ms` (ms since the
- * recording's t=0). Rows with no `started_at` stay null — there's
- * nothing to attribute them to.
- *
- * Done in JS rather than SQL because SQLite's ISO-8601 parsing is
- * sub-millisecond-lossy and the join logic is small enough to stay
- * legible inline.
- */
-function backfillTurnIdx(
- tx: TxHandle,
- replayId: string,
- replayStartedAtIso: string,
- turns: readonly TurnWindow[],
-): void {
- if (turns.length === 0) return;
- const replayStartMs = Date.parse(replayStartedAtIso);
- if (!Number.isFinite(replayStartMs)) return;
-
- const toolRows = tx
- .select({ id: toolCalls.id, startedAt: toolCalls.startedAt })
- .from(toolCalls)
- .where(and(eq(toolCalls.replayId, replayId), isNull(toolCalls.turnIdx)))
- .all();
- for (const row of toolRows) {
- const idx = turnIdxForStartedAt(row.startedAt, replayStartMs, turns);
- if (idx === null) continue;
- tx.update(toolCalls).set({ turnIdx: idx }).where(eq(toolCalls.id, row.id)).run();
- }
-
- const usageRows = tx
- .select({ id: modelUsage.id, startedAt: modelUsage.startedAt })
- .from(modelUsage)
- .where(and(eq(modelUsage.replayId, replayId), isNull(modelUsage.turnIdx)))
- .all();
- for (const row of usageRows) {
- const idx = turnIdxForStartedAt(row.startedAt, replayStartMs, turns);
- if (idx === null) continue;
- tx.update(modelUsage).set({ turnIdx: idx }).where(eq(modelUsage.id, row.id)).run();
- }
-}
-
-function turnIdxForStartedAt(
- startedAtIso: string | null,
- replayStartMs: number,
- turns: readonly TurnWindow[],
-): number | null {
- if (startedAtIso === null) return null;
- const spanStartMs = Date.parse(startedAtIso);
- if (!Number.isFinite(spanStartMs)) return null;
- const offsetMs = spanStartMs - replayStartMs;
- for (const t of turns) {
- if (offsetMs >= t.voiceStartMs && offsetMs < t.voiceEndMs) return t.idx;
- }
- return null;
-}
diff --git a/src/server/jobs/calculate-metrics/calculate-metrics.processor.test.ts b/src/server/jobs/calculate-metrics/calculate-metrics.processor.test.ts
index a445841..e14750e 100644
--- a/src/server/jobs/calculate-metrics/calculate-metrics.processor.test.ts
+++ b/src/server/jobs/calculate-metrics/calculate-metrics.processor.test.ts
@@ -4,16 +4,9 @@ import { seedConversation } from "@/server/conversations/conversations.test-util
import { makeFakeJobRunner } from "@/server/jobs/jobs.test-utils.ts";
import { makeReplayEvents } from "@/server/replays/replays.events.ts";
import { createReplay } from "@/server/replays/replays.service.ts";
-import {
- replayEvaluations,
- replayMetrics,
- replays,
- replayTurns,
- spans,
- speechSegments,
-} from "@/server/store/schema.ts";
+import { replayEvaluations, replayMetrics, replays, replayTurns } from "@/server/store/schema.ts";
import { makeTempStore } from "@/server/store/test-utils.ts";
-import type { ReplayTurnRow, SpanRow, SpeechSegmentRow } from "@/server/store/types.ts";
+import type { ReplayTurnRow, SpeechSegmentRow } from "@/server/store/types.ts";
import { computeMetrics, makeCalculateMetricsProcessor } from "./calculate-metrics.processor.ts";
import { describe, expect, it } from "bun:test";
@@ -59,85 +52,11 @@ describe("computeMetrics (pure)", () => {
voiceEndMs: 2500,
},
];
- const rows = computeMetrics("r", turns, [], [], 0);
+ const rows = computeMetrics("r", turns, []);
expect(rows[1]?.agentResponseMs).toBe(300);
expect(rows[0]?.agentResponseMs).toBeNull();
});
- it("computes positive ttftMs from the earliest gen_ai span in [turnStartMs, voiceStartMs)", () => {
- const turns: ReplayTurnRow[] = [
- {
- replayId: "r",
- idx: 0,
- role: "agent",
- turnStartMs: 800,
- turnEndMs: 2000,
- voiceStartMs: 1500,
- voiceEndMs: 2000,
- },
- ];
- const replayStartMs = Date.parse("2026-05-18T12:00:00.000Z");
- const ttftSpans: SpanRow[] = [
- {
- id: 1,
- replayId: "r",
- traceId: "t",
- spanId: "s",
- parentSpanId: null,
- name: "chat",
- vocabulary: "gen_ai",
- startedAt: new Date(replayStartMs + 1000).toISOString(),
- endedAt: new Date(replayStartMs + 1400).toISOString(),
- attributesJson: "{}",
- },
- {
- id: 2,
- replayId: "r",
- traceId: "t",
- spanId: "s2",
- parentSpanId: null,
- name: "chat2",
- vocabulary: "gen_ai",
- startedAt: new Date(replayStartMs + 1200).toISOString(),
- endedAt: new Date(replayStartMs + 1450).toISOString(),
- attributesJson: "{}",
- },
- ];
- const rows = computeMetrics("r", turns, [], ttftSpans, replayStartMs);
- expect(rows[0]?.ttftMs).toBe(500);
- });
-
- it("returns null when no gen_ai span lands in the LLM-call attribution window", () => {
- const turns: ReplayTurnRow[] = [
- {
- replayId: "r",
- idx: 0,
- role: "agent",
- turnStartMs: 1000,
- turnEndMs: 2000,
- voiceStartMs: 1500,
- voiceEndMs: 2000,
- },
- ];
- const replayStartMs = Date.parse("2026-05-18T12:00:00.000Z");
- const ttftSpans: SpanRow[] = [
- {
- id: 1,
- replayId: "r",
- traceId: "t",
- spanId: "s",
- parentSpanId: null,
- name: "chat",
- vocabulary: "gen_ai",
- startedAt: new Date(replayStartMs + 1600).toISOString(),
- endedAt: new Date(replayStartMs + 1700).toISOString(),
- attributesJson: "{}",
- },
- ];
- const rows = computeMetrics("r", turns, [], ttftSpans, replayStartMs);
- expect(rows[0]?.ttftMs).toBeNull();
- });
-
it("flags interrupted=true when opposite channel starts a segment inside the turn", () => {
const turns: ReplayTurnRow[] = [
{
@@ -153,7 +72,7 @@ describe("computeMetrics (pure)", () => {
const segments: SpeechSegmentRow[] = [
{ id: 1, replayId: "r", channel: "user", startMs: 1200, endMs: 1500 },
];
- const rows = computeMetrics("r", turns, segments, [], 0);
+ const rows = computeMetrics("r", turns, segments);
expect(rows[0]?.interrupted).toBe(true);
expect(rows[0]?.interruptionStartMs).toBe(1200);
});
@@ -173,7 +92,7 @@ describe("computeMetrics (pure)", () => {
const segments: SpeechSegmentRow[] = [
{ id: 1, replayId: "r", channel: "agent", startMs: 600, endMs: 1500 },
];
- const rows = computeMetrics("r", turns, segments, [], 0);
+ const rows = computeMetrics("r", turns, segments);
expect(rows[0]?.interrupted).toBe(false);
});
});
@@ -224,9 +143,6 @@ describe("makeCalculateMetricsProcessor", () => {
it("stamps failed + failure_reason='metrics_failed' on internal error", async () => {
const { store, replayId } = await setupReplay();
- // Force a unique-key collision on insert by pre-populating a row with
- // the same composite PK — the second insert inside the processor
- // will throw.
store.db
.insert(replayTurns)
.values({
@@ -239,15 +155,8 @@ describe("makeCalculateMetricsProcessor", () => {
voiceEndMs: 1,
})
.run();
- // Pre-populate replay_metrics so the delete-then-insert path works
- // but break it by inserting an invalid row beforehand via raw SQL? No
- // — easier: stub the runner to throw on enqueue (after the write
- // commits) → markReplayFailed runs in the catch. Hmm, that's not quite
- // "metrics stage failed" though. Instead, drive the failure by giving
- // the replay a malformed startedAt. Date.parse returns NaN, ttft stays
- // null, but the processor doesn't throw on that. So instead: pre-write
- // a metrics row outside the transaction to fight the delete? Not
- // reliable. Easier: spy on the runner to throw.
+ // A throwing enqueue is the cheapest in-stage failure that reaches the
+ // processor's catch — the pure compute path has no injectable fault.
const runner = makeFakeJobRunner();
const throwingRunner = {
...runner,
@@ -337,7 +246,3 @@ describe("makeCalculateMetricsProcessor", () => {
store.close();
});
});
-
-// Silence the unused-import lint — `spans` is referenced through SpanRow.
-void spans;
-void speechSegments;
diff --git a/src/server/jobs/calculate-metrics/calculate-metrics.processor.ts b/src/server/jobs/calculate-metrics/calculate-metrics.processor.ts
index c970dee..7878828 100644
--- a/src/server/jobs/calculate-metrics/calculate-metrics.processor.ts
+++ b/src/server/jobs/calculate-metrics/calculate-metrics.processor.ts
@@ -1,4 +1,4 @@
-import { and, asc, eq } from "drizzle-orm";
+import { asc, eq } from "drizzle-orm";
import { getConversationSpec } from "@/server/conversations/conversations.service.ts";
import type { ReplayEvents } from "@/server/replays/replays.events.ts";
@@ -10,11 +10,10 @@ import {
replayMetrics,
replays,
replayTurns,
- spans,
speechSegments,
} from "@/server/store/schema.ts";
import type { Store } from "@/server/store/store.ts";
-import type { ReplayTurnRow, SpanRow, SpeechSegmentRow } from "@/server/store/types.ts";
+import type { ReplayTurnRow, SpeechSegmentRow } from "@/server/store/types.ts";
import type { JobRunner } from "../jobs.bunqueue.ts";
import { JobProcessingError } from "../jobs.errors.ts";
@@ -36,17 +35,17 @@ export type CalculateMetricsProcessor = (payload: JobPayload) => Promise {
const sorted = [...turns].sort((a, b) => a.idx - b.idx);
return sorted.map((turn, i) => {
const agentResponseMs = turn.role === "agent" ? agentResponseFor(turn, sorted, i) : null;
- const ttftMs = turn.role === "agent" ? ttftFor(turn, ttftSpans, replayStartMs) : null;
const { interrupted, interruptionStartMs } = interruptionFor(turn, segments);
return {
replayId,
turnIdx: turn.idx,
agentResponseMs,
- ttftMs,
interrupted,
interruptionStartMs,
};
@@ -228,25 +216,6 @@ function agentResponseFor(
return null;
}
-function ttftFor(
- turn: ReplayTurnRow,
- ttftSpans: readonly SpanRow[],
- replayStartMs: number,
-): number | null {
- if (!Number.isFinite(replayStartMs)) return null;
- let earliestOffsetMs: number | null = null;
- for (const span of ttftSpans) {
- const startMs = Date.parse(span.startedAt) - replayStartMs;
- if (!Number.isFinite(startMs)) continue;
- if (startMs < turn.turnStartMs || startMs >= turn.voiceStartMs) continue;
- if (earliestOffsetMs === null || startMs < earliestOffsetMs) {
- earliestOffsetMs = startMs;
- }
- }
- if (earliestOffsetMs === null) return null;
- return turn.voiceStartMs - earliestOffsetMs;
-}
-
function interruptionFor(
turn: ReplayTurnRow,
segments: readonly SpeechSegmentRow[],
diff --git a/src/server/jobs/evaluate-replay/evaluate-replay.processor.test.ts b/src/server/jobs/evaluate-replay/evaluate-replay.processor.test.ts
index 22b64ee..03ca42c 100644
--- a/src/server/jobs/evaluate-replay/evaluate-replay.processor.test.ts
+++ b/src/server/jobs/evaluate-replay/evaluate-replay.processor.test.ts
@@ -13,10 +13,12 @@ import { createReplay } from "@/server/replays/replays.service.ts";
import {
assertionResults,
judgeResults,
+ modelUsage,
replayEvaluations,
replayMetrics,
replays,
replayTurns,
+ toolCalls,
turnTranscripts,
} from "@/server/store/schema.ts";
import { makeTempStore } from "@/server/store/test-utils.ts";
@@ -41,7 +43,6 @@ interface VadFixture {
voiceStartMs?: number;
voiceEndMs?: number;
agentResponseMs?: number | null;
- ttftMs?: number | null;
}
async function setupReplay(opts: {
@@ -93,7 +94,6 @@ async function setupReplay(opts: {
voiceStartMs: 1300,
voiceEndMs: 2500,
agentResponseMs: 300,
- ttftMs: 100,
},
];
@@ -133,7 +133,6 @@ async function setupReplay(opts: {
replayId: detail.id,
turnIdx: t.idx,
agentResponseMs: t.role === "agent" ? (t.agentResponseMs ?? 300) : null,
- ttftMs: t.role === "agent" ? (t.ttftMs ?? 100) : null,
interrupted: false,
interruptionStartMs: null,
})),
@@ -458,4 +457,196 @@ describe("evaluate-replay processor", () => {
expect(result.passed).toBe(true);
});
});
+
+ describe("tool attribution by audio timeline", () => {
+ // The agent turn's window is [turnStartMs=1000, turnEndMs=2500). A tool
+ // call is attributed to it iff (started_at − recording_started_at) lands
+ // in that window. recording_started_at is the SOLE origin — never
+ // replays.started_at (row-creation time). See spec 0001.
+ const RECORDING_T0 = "2026-05-26T14:31:31.023Z";
+ const atOffset = (ms: number) => new Date(Date.parse(RECORDING_T0) + ms).toISOString();
+
+ type ToolCtx = Awaited>;
+
+ async function setupToolReplay(recordingStartedAt: string | null): Promise {
+ const ctx = await setupReplay({
+ turns: [
+ { role: "user", assertions: [] },
+ { role: "agent", assertions: [{ kind: "tool_called", name: "lookup" }] },
+ ],
+ judges: [],
+ });
+ ctx.store.db
+ .update(replays)
+ .set({ recordingStartedAt })
+ .where(eq(replays.id, ctx.replayId))
+ .run();
+ return ctx;
+ }
+
+ function insertTool(ctx: ToolCtx, startedAt: string): void {
+ ctx.store.db
+ .insert(toolCalls)
+ .values({
+ replayId: ctx.replayId,
+ spanId: "s1",
+ name: "lookup",
+ argsJson: null,
+ resultJson: null,
+ startedAt,
+ endedAt: startedAt,
+ latencyMs: 0,
+ })
+ .run();
+ }
+
+ async function runAndGetToolStatus(ctx: ToolCtx): Promise {
+ await makeEvaluateReplayProcessor(
+ ctx.store,
+ makeReplayEvents(),
+ fakeJudge(),
+ )({
+ replayId: ctx.replayId,
+ });
+ return ctx.store.db
+ .select()
+ .from(assertionResults)
+ .where(eq(assertionResults.replayId, ctx.replayId))
+ .all()
+ .find((r) => r.kind === "tool_called")?.status;
+ }
+
+ it("passes tool_called when the call lands inside the agent turn window", async () => {
+ const ctx = await setupToolReplay(RECORDING_T0);
+ insertTool(ctx, atOffset(1500)); // offset 1500 ∈ [1000, 2500)
+ expect(await runAndGetToolStatus(ctx)).toBe("passed");
+ });
+
+ it("flags a mistimed call: speculative-during-user-turn is NOT in the agent turn", async () => {
+ const ctx = await setupToolReplay(RECORDING_T0);
+ insertTool(ctx, atOffset(500)); // offset 500 ∈ user turn [0,1000), not agent's
+ expect(await runAndGetToolStatus(ctx)).toBe("failed");
+ });
+
+ it("errors tool_called when the replay has no recording anchor", async () => {
+ const ctx = await setupToolReplay(null);
+ insertTool(ctx, atOffset(1500));
+ expect(await runAndGetToolStatus(ctx)).toBe("errored");
+ });
+ });
+
+ describe("ttft attribution by audio timeline", () => {
+ // Same agent turn window [1000, 2500) as the tool block. max_ttft_ms
+ // sources ttftMs from the earliest in-window model_usage row that
+ // carries one. recording_started_at is the sole origin. See spec 0001.
+ const RECORDING_T0 = "2026-05-26T14:31:31.023Z";
+ const atOffset = (ms: number) => new Date(Date.parse(RECORDING_T0) + ms).toISOString();
+ type Ctx = Awaited>;
+
+ async function setupTtftReplay(): Promise {
+ const ctx = await setupReplay({
+ turns: [
+ { role: "user", assertions: [] },
+ { role: "agent", assertions: [{ kind: "max_ttft_ms", max_ms: 500 }] },
+ ],
+ judges: [],
+ });
+ ctx.store.db
+ .update(replays)
+ .set({ recordingStartedAt: RECORDING_T0 })
+ .where(eq(replays.id, ctx.replayId))
+ .run();
+ return ctx;
+ }
+
+ function insertUsage(ctx: Ctx, startedAt: string, ttftMs: number | null): void {
+ ctx.store.db
+ .insert(modelUsage)
+ .values({
+ replayId: ctx.replayId,
+ spanId: `u-${startedAt}`,
+ provider: "openai",
+ model: "gpt-4o",
+ inputTokens: null,
+ outputTokens: null,
+ totalTokens: null,
+ ttftMs,
+ startedAt,
+ endedAt: startedAt,
+ latencyMs: 0,
+ })
+ .run();
+ }
+
+ async function runAndGetTtft(
+ ctx: Ctx,
+ ): Promise<{ status: string | undefined; message: string | null | undefined }> {
+ await makeEvaluateReplayProcessor(
+ ctx.store,
+ makeReplayEvents(),
+ fakeJudge(),
+ )({
+ replayId: ctx.replayId,
+ });
+ const row = ctx.store.db
+ .select()
+ .from(assertionResults)
+ .where(eq(assertionResults.replayId, ctx.replayId))
+ .all()
+ .find((r) => r.kind === "max_ttft_ms");
+ return { status: row?.status, message: row?.message };
+ }
+
+ it("passes when the earliest in-window model call's ttft is under the limit", async () => {
+ const ctx = await setupTtftReplay();
+ insertUsage(ctx, atOffset(1400), 300); // offset 1400 ∈ [1000, 2500)
+ expect((await runAndGetTtft(ctx)).status).toBe("passed");
+ });
+
+ it("does NOT let a leading null-ttft call mask a later call that carries one", async () => {
+ // Regression: a first in-window call without the TTFT attribute
+ // (ttft=null) must not shadow a later call with a real measurement.
+ const ctx = await setupTtftReplay();
+ insertUsage(ctx, atOffset(1100), null); // earliest, no measurement
+ insertUsage(ctx, atOffset(1400), 300); // later, carries TTFT under limit
+ expect((await runAndGetTtft(ctx)).status).toBe("passed");
+ });
+
+ it("fails when the earliest measured ttft exceeds the limit", async () => {
+ const ctx = await setupTtftReplay();
+ insertUsage(ctx, atOffset(1200), 4000);
+ const { status, message } = await runAndGetTtft(ctx);
+ expect(status).toBe("failed");
+ expect(message).toContain("4000ms");
+ });
+
+ it("errors with a 'no model call in window' message when no usage row lands in the turn", async () => {
+ const ctx = await setupTtftReplay();
+ insertUsage(ctx, atOffset(500), 300); // offset 500 ∈ user turn, not agent's
+ const { status, message } = await runAndGetTtft(ctx);
+ expect(status).toBe("errored");
+ expect(message).toContain("no model call landed");
+ });
+
+ it("errors with an 'attribute missing' message when in-window calls carry no ttft", async () => {
+ const ctx = await setupTtftReplay();
+ insertUsage(ctx, atOffset(1400), null);
+ const { status, message } = await runAndGetTtft(ctx);
+ expect(status).toBe("errored");
+ expect(message).toContain("time_to_first_chunk");
+ });
+
+ it("errors when the replay has no recording anchor", async () => {
+ const ctx = await setupTtftReplay();
+ ctx.store.db
+ .update(replays)
+ .set({ recordingStartedAt: null })
+ .where(eq(replays.id, ctx.replayId))
+ .run();
+ insertUsage(ctx, atOffset(1400), 300);
+ const { status, message } = await runAndGetTtft(ctx);
+ expect(status).toBe("errored");
+ expect(message).toContain("no recording anchor");
+ });
+ });
});
diff --git a/src/server/jobs/evaluate-replay/evaluate-replay.processor.ts b/src/server/jobs/evaluate-replay/evaluate-replay.processor.ts
index 1521371..a7ae79d 100644
--- a/src/server/jobs/evaluate-replay/evaluate-replay.processor.ts
+++ b/src/server/jobs/evaluate-replay/evaluate-replay.processor.ts
@@ -16,6 +16,8 @@ import type {
JudgeOutcomeResponse,
ReplayResult,
} from "@/server/replays/replays.types.ts";
+import type { TurnWindow } from "@/server/replays/timeline.ts";
+import { audioOffsetMs, clampedTurnWindows, rowsInTurnWindow } from "@/server/replays/timeline.ts";
import { projectTurnMetrics } from "@/server/replays/turn-metrics.ts";
import {
assertionResults,
@@ -130,6 +132,20 @@ export function makeEvaluateReplayProcessor(
.where(eq(modelUsage.replayId, replayId))
.all();
+ // Tiling attribution windows for every VAD turn, in idx order, built
+ // from each turn's voiceEndMs — the voice-active boundary the spec
+ // attributes against (0001 §3.4), not turnEndMs (equal today, but
+ // stored separately for future overlap handling). Built once (the
+ // cursor accumulates across all turns, including extra VAD turns the
+ // spec doesn't assert) and looked up per matched turn.
+ const windows = clampedTurnWindows(turnRows.map((t) => t.voiceEndMs));
+ const windowByIdx = new Map();
+ for (let t = 0; t < turnRows.length; t++) {
+ const row = turnRows[t];
+ const window = windows[t];
+ if (row !== undefined && window !== undefined) windowByIdx.set(row.idx, window);
+ }
+
const evaluatedAt = new Date().toISOString();
const assertionRows: AssertionResultInsert[] = [];
const assertionOutcomes: AssertionOutcomeResponse[] = [];
@@ -161,7 +177,15 @@ export function makeEvaluateReplayProcessor(
const assertions = turn.assertions ?? [];
if (assertions.length === 0) continue;
- const ctx = buildAssertionContext(matched, transcripts, metricRows, toolRows, usageRows);
+ const ctx = buildAssertionContext(
+ matched,
+ windowByIdx.get(matched.idx),
+ transcripts,
+ metricRows,
+ toolRows,
+ usageRows,
+ replay.recordingStartedAt,
+ );
for (let j = 0; j < assertions.length; j++) {
const assertion = assertions[j];
if (assertion === undefined) continue;
@@ -305,37 +329,77 @@ export function makeEvaluateReplayProcessor(
/**
* Build the per-assertion context against a specific VAD-derived turn.
*
- * All downstream lookups (`transcripts`, `metrics`, `tool_calls`,
- * `model_usage`) are keyed on the VAD-row's `idx`, NOT the spec's turn
- * position. The analyze-replay stage wrote those tables using VAD-derived
- * indexes, and the calculate-metrics + OTLP backfill stages followed the
- * same convention. Pairing here must use the matched VAD idx so the
- * spec's `assertions[i]` sees the matched VAD turn's transcript, not the
- * spec position's transcript (which doesn't exist when spec/VAD diverge).
+ * `transcripts` + `metrics` are keyed on the VAD row's `idx`. `tool_calls` /
+ * `model_usage` carry no stored turn idx — membership is the tiling attribution
+ * `window` (`clampedTurnWindows`, see `src/server/replays/timeline.ts`) applied
+ * to each row's wall-clock `started_at` against the replay's
+ * `recording_started_at`. A row whose call fired before the user stopped
+ * (speculative) or after the agent finished lands in a neighbouring turn's tile
+ * and is excluded — `tool_called` then flags the mistiming.
+ *
+ * `ttftMs` is the earliest in-window model call's `model_usage.ttft_ms` — a
+ * span-level, same-clock delta, not a per-turn aggregate.
+ *
+ * When `recordingStartedAt` is null (or no window exists for this turn) the rows
+ * can't be placed on the timeline; `hasRecordingAnchor` is false and the
+ * evaluator maps tool/ttft assertions to `errored` rather than a misleading
+ * pass/fail.
*/
function buildAssertionContext(
matched: ReplayTurnRow,
+ window: TurnWindow | undefined,
transcripts: readonly TurnTranscriptRow[],
metrics: readonly ReplayMetricRow[],
toolRows: readonly ToolCallRow[],
usageRows: readonly ModelUsageRow[],
+ recordingStartedAt: string | null,
): AssertionContext {
const vadIdx = matched.idx;
const transcript = transcripts.find((t) => t.turnIdx === vadIdx)?.text ?? null;
const metric = metrics.find((m) => m.turnIdx === vadIdx);
+ const usageInWindow =
+ window === undefined ? [] : rowsInTurnWindow(usageRows, window, recordingStartedAt);
return {
turnIdx: vadIdx,
turnRole: matched.role,
transcript,
- toolCalls: toolRows.filter((tc) => tc.turnIdx === vadIdx),
- modelUsage: usageRows.filter((mu) => mu.turnIdx === vadIdx),
+ hasRecordingAnchor: recordingStartedAt !== null && window !== undefined,
+ toolCalls: window === undefined ? [] : rowsInTurnWindow(toolRows, window, recordingStartedAt),
+ modelUsage: usageInWindow,
metrics: {
agentResponseMs: metric?.agentResponseMs ?? null,
- ttftMs: metric?.ttftMs ?? null,
+ ttftMs: earliestTtftMs(usageInWindow, recordingStartedAt),
},
};
}
+/**
+ * TTFT of the earliest (by audio offset) in-window model call that actually
+ * carries one, or null when none does. We pick the earliest call's measurement
+ * (spec §3.4: "the first LLM call's perceived first-chunk latency"), but skip
+ * rows whose `ttftMs` is null — a leading call that didn't emit
+ * `gen_ai.response.time_to_first_chunk` (e.g. a Langfuse-vocabulary span, which
+ * never carries it) must not mask a later call that did, which would make
+ * `max_ttft_ms` falsely report "no call carried TTFT".
+ */
+function earliestTtftMs(
+ usageInWindow: readonly ModelUsageRow[],
+ recordingStartedAt: string | null,
+): number | null {
+ let earliestTtft: number | null = null;
+ let earliestOffset = Number.POSITIVE_INFINITY;
+ for (const row of usageInWindow) {
+ if (row.ttftMs === null) continue;
+ const offset = audioOffsetMs(row.startedAt, recordingStartedAt);
+ if (offset === null) continue;
+ if (offset < earliestOffset) {
+ earliestOffset = offset;
+ earliestTtft = row.ttftMs;
+ }
+ }
+ return earliestTtft;
+}
+
function buildJudgeTurns(
turnRows: readonly ReplayTurnRow[],
transcripts: readonly TurnTranscriptRow[],
diff --git a/src/server/otlp/otlp.service.test.ts b/src/server/otlp/otlp.service.test.ts
index 41e88a8..c863a90 100644
--- a/src/server/otlp/otlp.service.test.ts
+++ b/src/server/otlp/otlp.service.test.ts
@@ -117,6 +117,8 @@ describe("ingestOtlpTraces — gen_ai vocabulary", () => {
"gen_ai.request.model": "gpt-4o",
"gen_ai.usage.input_tokens": 42,
"gen_ai.usage.output_tokens": 17,
+ // Seconds (semconv) → ms. 0.25 encodes as a doubleValue.
+ "gen_ai.response.time_to_first_chunk": 0.25,
},
},
],
@@ -129,6 +131,7 @@ describe("ingestOtlpTraces — gen_ai vocabulary", () => {
expect(usage?.outputTokens).toBe(17);
expect(usage?.totalTokens).toBe(59);
expect(usage?.latencyMs).toBe(500);
+ expect(usage?.ttftMs).toBe(250);
store.close();
});
@@ -179,6 +182,8 @@ describe("ingestOtlpTraces — langfuse vocabulary", () => {
expect(usage?.model).toBe("claude-3-5-sonnet");
expect(usage?.inputTokens).toBe(5);
expect(usage?.outputTokens).toBe(9);
+ // No time_to_first_chunk on this span → null, like the token counts.
+ expect(usage?.ttftMs).toBeNull();
store.close();
});
});
diff --git a/src/server/otlp/otlp.service.ts b/src/server/otlp/otlp.service.ts
index 07dee5e..f029438 100644
--- a/src/server/otlp/otlp.service.ts
+++ b/src/server/otlp/otlp.service.ts
@@ -127,14 +127,13 @@ export function ingestOtlpTraces(
}
/**
- * Persist extracted tool_calls / model_usage rows with `turn_idx = null`.
- * The analyze-replay job backfills `turn_idx` from `replay_turns` once VAD
- * has produced authoritative turn boundaries — see spec 0001 §8.
- *
- * The receiver no longer trusts driver-emitted `xray.turn.idx`: spans can
- * arrive before VAD has run (or with stale baggage from a prior turn).
- * Timestamp-based attribution against `replay_turns.voice_start_ms..voice_end_ms`
- * is the single source of truth.
+ * Persist extracted tool_calls / model_usage rows. No turn association is
+ * stored — rows carry only their wall-clock `started_at`. Turn membership is
+ * derived at read/eval time by mapping `started_at` onto the audio timeline
+ * (`audio_offset_ms = started_at − replays.recording_started_at`) and testing
+ * the turn window — see `docs/specs/0001-timeline-clock-alignment.md` and
+ * `src/server/replays/timeline.ts`. Every recognized row is recorded
+ * unconditionally; the timeline placement decides display + assertion scope.
*/
function persistExtracted(
tx: StoreDb,
@@ -147,7 +146,6 @@ function persistExtracted(
.values(
extraction.toolCalls.map((tc) => ({
replayId,
- turnIdx: null,
spanId: span.spanId,
name: tc.name,
argsJson: tc.argsJson,
@@ -164,13 +162,13 @@ function persistExtracted(
.values(
extraction.modelUsage.map((mu) => ({
replayId,
- turnIdx: null,
spanId: span.spanId,
provider: mu.provider,
model: mu.model,
inputTokens: mu.inputTokens,
outputTokens: mu.outputTokens,
totalTokens: mu.totalTokens,
+ ttftMs: mu.ttftMs,
startedAt: mu.startedAt,
endedAt: mu.endedAt,
latencyMs: mu.latencyMs,
diff --git a/src/server/otlp/vocabularies/attrs.ts b/src/server/otlp/vocabularies/attrs.ts
index 5f4ba49..88ab3ef 100644
--- a/src/server/otlp/vocabularies/attrs.ts
+++ b/src/server/otlp/vocabularies/attrs.ts
@@ -20,6 +20,20 @@ export function asInteger(v: FlatAttributes[string] | undefined): number | null
return null;
}
+/**
+ * Parse a finite floating-point value (number, or numeric string incl.
+ * decimals). Used for semconv durations expressed in seconds — e.g.
+ * `gen_ai.response.time_to_first_chunk` = `0.5`.
+ */
+export function asFiniteNumber(v: FlatAttributes[string] | undefined): number | null {
+ if (typeof v === "number") return Number.isFinite(v) ? v : null;
+ if (typeof v === "string" && v.trim() !== "") {
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+ }
+ return null;
+}
+
export function safeJsonString(maybeJson: string): string {
try {
const parsed = JSON.parse(maybeJson);
diff --git a/src/server/otlp/vocabularies/gen-ai-semconv.test.ts b/src/server/otlp/vocabularies/gen-ai-semconv.test.ts
index 80e8bfd..bc1f321 100644
--- a/src/server/otlp/vocabularies/gen-ai-semconv.test.ts
+++ b/src/server/otlp/vocabularies/gen-ai-semconv.test.ts
@@ -26,6 +26,7 @@ describe("genAiSemconvVocabulary — chat / text_completion", () => {
inputTokens: 42,
outputTokens: 7,
totalTokens: 49,
+ ttftMs: null,
startedAt: "2026-05-18T12:00:00.000Z",
endedAt: "2026-05-18T12:00:00.250Z",
latencyMs: 250,
@@ -33,6 +34,30 @@ describe("genAiSemconvVocabulary — chat / text_completion", () => {
]);
});
+ it("converts gen_ai.response.time_to_first_chunk (seconds) to ttftMs", () => {
+ const span = makeProjectedSpan({
+ name: "chat gpt-4o",
+ attributes: {
+ "gen_ai.operation.name": "chat",
+ "gen_ai.response.time_to_first_chunk": 0.25,
+ },
+ });
+ const out = genAiSemconvVocabulary(span, EMPTY_RESOURCE);
+ expect(out?.modelUsage?.[0]?.ttftMs).toBe(250);
+ });
+
+ it("drops a negative time_to_first_chunk to null", () => {
+ const span = makeProjectedSpan({
+ name: "chat gpt-4o",
+ attributes: {
+ "gen_ai.operation.name": "chat",
+ "gen_ai.response.time_to_first_chunk": -1,
+ },
+ });
+ const out = genAiSemconvVocabulary(span, EMPTY_RESOURCE);
+ expect(out?.modelUsage?.[0]?.ttftMs).toBeNull();
+ });
+
it("falls back to gen_ai.request.model when response.model is absent", () => {
const span = makeProjectedSpan({
name: "chat gpt-4o",
diff --git a/src/server/otlp/vocabularies/gen-ai-semconv.ts b/src/server/otlp/vocabularies/gen-ai-semconv.ts
index 10eeb10..f2cdea0 100644
--- a/src/server/otlp/vocabularies/gen-ai-semconv.ts
+++ b/src/server/otlp/vocabularies/gen-ai-semconv.ts
@@ -1,5 +1,12 @@
import type { FlatAttributes, ProjectedSpan } from "../otlp.types.ts";
-import { asInteger, asString, msBetween, pickPrefixed, safeJsonString } from "./attrs.ts";
+import {
+ asFiniteNumber,
+ asInteger,
+ asString,
+ msBetween,
+ pickPrefixed,
+ safeJsonString,
+} from "./attrs.ts";
import type {
ExtractedModelUsage,
ExtractedToolCall,
@@ -67,6 +74,7 @@ export const genAiSemconvVocabulary: SpanVocabularyMatcher = (
asInteger(a["gen_ai.usage.input_tokens"]),
asInteger(a["gen_ai.usage.output_tokens"]),
),
+ ttftMs: ttftMsFromSeconds(a["gen_ai.response.time_to_first_chunk"]),
startedAt,
endedAt,
latencyMs,
@@ -87,3 +95,14 @@ function addOrNull(a: number | null, b: number | null): number | null {
if (a === null && b === null) return null;
return (a ?? 0) + (b ?? 0);
}
+
+/**
+ * `gen_ai.response.time_to_first_chunk` is seconds (semconv, Development
+ * stability). Convert to whole ms for `model_usage.ttft_ms`. A negative value
+ * is nonsensical for a latency and dropped to null.
+ */
+function ttftMsFromSeconds(raw: FlatAttributes[string] | undefined): number | null {
+ const seconds = asFiniteNumber(raw);
+ if (seconds === null || seconds < 0) return null;
+ return Math.round(seconds * 1000);
+}
diff --git a/src/server/otlp/vocabularies/langfuse.test.ts b/src/server/otlp/vocabularies/langfuse.test.ts
index 26031d6..6d6e020 100644
--- a/src/server/otlp/vocabularies/langfuse.test.ts
+++ b/src/server/otlp/vocabularies/langfuse.test.ts
@@ -26,6 +26,7 @@ describe("langfuseVocabulary — generation observations", () => {
inputTokens: 100,
outputTokens: 25,
totalTokens: 125,
+ ttftMs: null,
startedAt: "2026-05-18T12:00:00.000Z",
endedAt: "2026-05-18T12:00:00.400Z",
latencyMs: 400,
diff --git a/src/server/otlp/vocabularies/langfuse.ts b/src/server/otlp/vocabularies/langfuse.ts
index 543f910..deed9ae 100644
--- a/src/server/otlp/vocabularies/langfuse.ts
+++ b/src/server/otlp/vocabularies/langfuse.ts
@@ -38,6 +38,9 @@ export const langfuseVocabulary: SpanVocabularyMatcher = (
inputTokens: asInteger(a["langfuse.observation.usage_details.input"]),
outputTokens: asInteger(a["langfuse.observation.usage_details.output"]),
totalTokens: asInteger(a["langfuse.observation.usage_details.total"]),
+ // TTFT is sourced from the GenAI semconv attribute only (spec 0001);
+ // Langfuse's completion_start_time is a possible future source.
+ ttftMs: null,
startedAt,
endedAt,
latencyMs,
diff --git a/src/server/otlp/vocabularies/vocabularies.types.ts b/src/server/otlp/vocabularies/vocabularies.types.ts
index ebe6543..ef6ce6b 100644
--- a/src/server/otlp/vocabularies/vocabularies.types.ts
+++ b/src/server/otlp/vocabularies/vocabularies.types.ts
@@ -19,6 +19,9 @@ export interface ExtractedModelUsage {
inputTokens: number | null;
outputTokens: number | null;
totalTokens: number | null;
+ /** Model time-to-first-token (ms), from `gen_ai.response.time_to_first_chunk`
+ * (seconds). Optional, like the token counts — null when unemitted. */
+ ttftMs: number | null;
startedAt: string | null;
endedAt: string | null;
latencyMs: number | null;
diff --git a/src/server/replays/replays.router.test.ts b/src/server/replays/replays.router.test.ts
index b5316b6..2ec92a7 100644
--- a/src/server/replays/replays.router.test.ts
+++ b/src/server/replays/replays.router.test.ts
@@ -363,7 +363,6 @@ describe("GET /v1/replays/:id/result", () => {
replayId,
turnIdx: 1,
agentResponseMs: 100,
- ttftMs: 50,
interrupted: false,
interruptionStartMs: null,
},
diff --git a/src/server/replays/replays.service.test.ts b/src/server/replays/replays.service.test.ts
index 61d8724..27d5127 100644
--- a/src/server/replays/replays.service.test.ts
+++ b/src/server/replays/replays.service.test.ts
@@ -478,7 +478,6 @@ describe("getReplayResult — interruption timing", () => {
replayId,
turnIdx: 0,
agentResponseMs: 250,
- ttftMs: 80,
interrupted: true,
interruptionStartMs: 1450,
})
diff --git a/src/server/replays/replays.service.ts b/src/server/replays/replays.service.ts
index 2cfc993..dbccc08 100644
--- a/src/server/replays/replays.service.ts
+++ b/src/server/replays/replays.service.ts
@@ -53,6 +53,7 @@ import type {
TurnTranscriptResponse,
UpdateReplayRequest,
} from "./replays.types.ts";
+import { offsetFromOriginMs } from "./timeline.ts";
import { projectTurnMetrics } from "./turn-metrics.ts";
export interface CreateReplayOptions {
@@ -79,6 +80,7 @@ export function createReplay(
failureReason: null,
startedAt,
finishedAt: null,
+ recordingStartedAt: null,
audioPath: null,
runConfigJson: req.run_config === undefined ? null : JSON.stringify(req.run_config),
jobId: null,
@@ -194,6 +196,12 @@ function buildReplayDetail(store: Store, r: ReplayRow): ReplayDetailResponse {
.where(eq(turnTranscripts.replayId, id))
.all();
transcriptRows.sort((a, b) => a.turnIdx - b.turnIdx);
+
+ // Parse the timeline origin once for the whole detail payload — every
+ // span / tool_call / model_usage offset measures from the same string.
+ const parsedOrigin = r.recordingStartedAt === null ? null : Date.parse(r.recordingStartedAt);
+ const originMs = parsedOrigin !== null && Number.isFinite(parsedOrigin) ? parsedOrigin : null;
+ const offsetOf = (startedAt: string | null) => offsetFromOriginMs(startedAt, originMs);
return {
id: r.id,
conversation_hash: r.conversationHash,
@@ -202,6 +210,7 @@ function buildReplayDetail(store: Store, r: ReplayRow): ReplayDetailResponse {
failure_reason: r.failureReason,
started_at: r.startedAt,
finished_at: r.finishedAt,
+ recording_started_at: r.recordingStartedAt,
audio_path: r.audioPath,
job_id: r.jobId,
run_config: parseJsonOrNull(r.runConfigJson),
@@ -209,9 +218,9 @@ function buildReplayDetail(store: Store, r: ReplayRow): ReplayDetailResponse {
speech_segments: segments.map(toSegmentResponse),
transcripts: transcriptRows.map(toTranscriptResponse),
turn_metrics: buildTurnMetrics(store, id),
- tool_calls: toolCallRows.map(toToolCallResponse),
- model_usage: modelUsageRows.map(toModelUsageResponse),
- spans: spanRows.map(toSpanResponse),
+ tool_calls: toolCallRows.map((row) => toToolCallResponse(row, offsetOf(row.startedAt))),
+ model_usage: modelUsageRows.map((row) => toModelUsageResponse(row, offsetOf(row.startedAt))),
+ spans: spanRows.map((row) => toSpanResponse(row, offsetOf(row.startedAt))),
};
}
@@ -271,10 +280,10 @@ function toSegmentResponse(row: SpeechSegmentRow): SpeechSegmentResponse {
};
}
-function toToolCallResponse(row: ToolCallRow): ToolCallResponse {
+function toToolCallResponse(row: ToolCallRow, audioOffsetMs: number | null): ToolCallResponse {
return {
id: row.id,
- turn_idx: row.turnIdx,
+ audio_offset_ms: audioOffsetMs,
span_id: row.spanId,
name: row.name,
args_json: row.argsJson,
@@ -285,23 +294,27 @@ function toToolCallResponse(row: ToolCallRow): ToolCallResponse {
};
}
-function toModelUsageResponse(row: ModelUsageRow): ModelUsageResponse {
+function toModelUsageResponse(
+ row: ModelUsageRow,
+ audioOffsetMs: number | null,
+): ModelUsageResponse {
return {
id: row.id,
- turn_idx: row.turnIdx,
+ audio_offset_ms: audioOffsetMs,
span_id: row.spanId,
provider: row.provider,
model: row.model,
input_tokens: row.inputTokens,
output_tokens: row.outputTokens,
total_tokens: row.totalTokens,
+ ttft_ms: row.ttftMs,
started_at: row.startedAt,
ended_at: row.endedAt,
latency_ms: row.latencyMs,
};
}
-function toSpanResponse(row: SpanRow): SpanResponse {
+function toSpanResponse(row: SpanRow, audioOffsetMs: number | null): SpanResponse {
return {
id: row.id,
trace_id: row.traceId,
@@ -312,6 +325,7 @@ function toSpanResponse(row: SpanRow): SpanResponse {
started_at: row.startedAt,
ended_at: row.endedAt,
attributes_json: row.attributesJson,
+ audio_offset_ms: audioOffsetMs,
};
}
diff --git a/src/server/replays/replays.types.ts b/src/server/replays/replays.types.ts
index 12abc40..5f60c66 100644
--- a/src/server/replays/replays.types.ts
+++ b/src/server/replays/replays.types.ts
@@ -45,7 +45,10 @@ export const RUN_CONFIG_MAX_BYTES = MAX_RUN_CONFIG_BYTES;
export const ToolCallResponseSchema = v.object({
id: v.number(),
- turn_idx: v.nullable(v.number()),
+ // Offset on the audio timeline (ms from recording t=0), derived from
+ // started_at − replays.recording_started_at. Null when either is missing
+ // — the row is still listed; it just can't be placed on the timeline.
+ audio_offset_ms: v.nullable(v.number()),
span_id: v.nullable(v.string()),
name: v.string(),
args_json: v.nullable(v.string()),
@@ -58,13 +61,16 @@ export type ToolCallResponse = v.InferOutput;
export const ModelUsageResponseSchema = v.object({
id: v.number(),
- turn_idx: v.nullable(v.number()),
+ audio_offset_ms: v.nullable(v.number()),
span_id: v.nullable(v.string()),
provider: v.nullable(v.string()),
model: v.nullable(v.string()),
input_tokens: v.nullable(v.number()),
output_tokens: v.nullable(v.number()),
total_tokens: v.nullable(v.number()),
+ // Model time-to-first-token (ms), from gen_ai.response.time_to_first_chunk.
+ // Null when the agent's instrumentation doesn't emit it.
+ ttft_ms: v.nullable(v.number()),
started_at: v.nullable(v.string()),
ended_at: v.nullable(v.string()),
latency_ms: v.nullable(v.number()),
@@ -127,19 +133,27 @@ export const SpanResponseSchema = v.object({
started_at: v.string(),
ended_at: v.string(),
attributes_json: v.string(),
+ // Offset of started_at on the audio timeline (ms from recording t=0),
+ // derived from started_at − replays.recording_started_at. Null when the
+ // replay has no anchor — the span is then unplaceable and renders untimed.
+ // The single origin for client span placement (spec 0001 §3.2): the client
+ // never re-derives offsets from wall-clock, so it can't diverge from the
+ // assertion evaluator's view.
+ audio_offset_ms: v.nullable(v.number()),
});
export type SpanResponse = v.InferOutput;
/**
- * Per-turn timing — the silence/gap before an agent responds (`agent_response_ms`),
- * time-to-first-token, and barge-in. Observability data: rides the replay detail
- * (Run details UI) AND the evaluation result (SDK `ReplayResult.metrics`).
+ * Per-turn timing — the silence/gap before an agent responds
+ * (`agent_response_ms`) and barge-in. Observability data: rides the replay
+ * detail (Run details UI) AND the evaluation result (SDK
+ * `ReplayResult.metrics`). Model TTFT is no longer a per-turn metric — it's an
+ * optional per-call attribute (`model_usage.ttft_ms`, spec 0001).
*/
export const TurnMetricsResponseSchema = v.object({
turn_idx: v.number(),
role: TurnRoleSchema,
agent_response_ms: v.nullable(v.number()),
- ttft_ms: v.nullable(v.number()),
interrupted: v.boolean(),
interruption_start_ms: v.nullable(v.number()),
});
@@ -166,6 +180,11 @@ export const ReplayDetailResponseSchema = v.object({
failure_reason: v.nullable(ReplayFailureReasonSchema),
started_at: v.string(),
finished_at: v.nullable(v.string()),
+ // Wall-clock of audio sample 0 (the timeline origin). The client maps any
+ // wall-clock timestamp (spans, tool calls, model usage) onto the audio
+ // timeline with `started_at − recording_started_at`. Null for older
+ // uploads that omitted the X-Recording-Started-At header.
+ recording_started_at: v.nullable(v.string()),
audio_path: v.nullable(v.string()),
job_id: v.nullable(v.string()),
run_config: v.unknown(),
diff --git a/src/server/replays/timeline.test.ts b/src/server/replays/timeline.test.ts
new file mode 100644
index 0000000..6f95c3e
--- /dev/null
+++ b/src/server/replays/timeline.test.ts
@@ -0,0 +1,124 @@
+import type { TurnWindow } from "./timeline.ts";
+import {
+ audioOffsetMs,
+ clampedTurnWindows,
+ offsetInTurnWindow,
+ rowsInTurnWindow,
+} from "./timeline.ts";
+import { describe, expect, it } from "bun:test";
+
+// Authentic numbers from snapshot/xray.db replay 7b8e2770… :
+// replays.started_at (WRONG origin) = 14:31:28.688
+// recording t=0 (first xray.turn span) = 14:31:31.023 ← correct origin
+// The 2335 ms gap between them is the bug this slice exists to kill.
+const RECORDING_T0 = "2026-05-26T14:31:31.023Z";
+const ROW_CREATION = "2026-05-26T14:31:28.688Z"; // replays.started_at — must NOT be used as origin
+
+describe("audioOffsetMs", () => {
+ it("measures the offset from the recording origin, not row creation", () => {
+ // A span emitted 4000 ms into the recording.
+ const span = "2026-05-26T14:31:35.023Z";
+ expect(audioOffsetMs(span, RECORDING_T0)).toBe(4000);
+ // Using the row-creation time as origin would inflate it by the 2335 ms
+ // gap — the exact bug. Documented here so the regression is legible.
+ expect(audioOffsetMs(span, ROW_CREATION)).toBe(6335);
+ });
+
+ it("returns null when either timestamp is missing", () => {
+ expect(audioOffsetMs(null, RECORDING_T0)).toBeNull();
+ expect(audioOffsetMs("2026-05-26T14:31:35.023Z", null)).toBeNull();
+ expect(audioOffsetMs(null, null)).toBeNull();
+ });
+
+ it("returns null when a timestamp is unparseable", () => {
+ expect(audioOffsetMs("not-a-date", RECORDING_T0)).toBeNull();
+ expect(audioOffsetMs("2026-05-26T14:31:35.023Z", "garbage")).toBeNull();
+ });
+
+ it("can be negative for a span emitted before the recording started", () => {
+ expect(audioOffsetMs("2026-05-26T14:31:30.523Z", RECORDING_T0)).toBe(-500);
+ });
+});
+
+// Snapshot-derived tiling: t0 agent, t1 user, t2 agent. turnStart_N == voiceEnd_{N-1}.
+const T0: TurnWindow = { turnStartMs: 0, turnEndMs: 2190 };
+const T1: TurnWindow = { turnStartMs: 2190, turnEndMs: 4680 };
+const T2: TurnWindow = { turnStartMs: 4680, turnEndMs: 11070 };
+
+describe("offsetInTurnWindow", () => {
+ it("includes the start, excludes the end (half-open)", () => {
+ expect(offsetInTurnWindow(2190, T1)).toBe(true);
+ expect(offsetInTurnWindow(4680, T1)).toBe(false); // belongs to T2
+ expect(offsetInTurnWindow(4680, T2)).toBe(true);
+ });
+
+ it("tiles: every offset belongs to exactly one turn", () => {
+ const turns = [T0, T1, T2];
+ for (const offset of [0, 100, 2189, 2190, 4679, 4680, 11069]) {
+ expect(turns.filter((t) => offsetInTurnWindow(offset, t)).length).toBe(1);
+ }
+ });
+});
+
+describe("clampedTurnWindows", () => {
+ it("tiles strictly-interleaved turns: start_N = end_{N-1}, no gaps", () => {
+ // Three turns whose voice ends are already monotonic: 2190, 4680, 11070.
+ const windows = clampedTurnWindows([2190, 4680, 11070]);
+ expect(windows).toEqual([
+ { turnStartMs: 0, turnEndMs: 2190 },
+ { turnStartMs: 2190, turnEndMs: 4680 },
+ { turnStartMs: 4680, turnEndMs: 11070 },
+ ]);
+ });
+
+ it("collapses an inverted window and never overlaps under barge-in", () => {
+ // Agent voiceEnd 5000, user (barged-in) voiceEnd 3500, agent resumes 8000.
+ // Raw windows would be [0,5000), [5000,3500) (inverted!), [3500,8000)
+ // (overlapping the first). The monotonic cursor fixes both.
+ const windows = clampedTurnWindows([5000, 3500, 8000]);
+ expect(windows).toEqual([
+ { turnStartMs: 0, turnEndMs: 5000 },
+ { turnStartMs: 5000, turnEndMs: 5000 }, // empty — fully barged over
+ { turnStartMs: 5000, turnEndMs: 8000 },
+ ]);
+ // A call at offset 4200 (fired during agent turn 0) lands in exactly ONE
+ // window — turn 0 — not also turn 2 as the raw overlap would allow.
+ const hits = windows.filter((w) => offsetInTurnWindow(4200, w));
+ expect(hits).toEqual([{ turnStartMs: 0, turnEndMs: 5000 }]);
+ });
+
+ it("every offset belongs to exactly one clamped window", () => {
+ const windows = clampedTurnWindows([5000, 3500, 8000]);
+ for (const offset of [0, 4200, 4999, 5000, 6000, 7999]) {
+ expect(windows.filter((w) => offsetInTurnWindow(offset, w)).length).toBe(1);
+ }
+ });
+
+ it("returns [] for no turns", () => {
+ expect(clampedTurnWindows([])).toEqual([]);
+ });
+});
+
+describe("rowsInTurnWindow", () => {
+ const rows = [
+ { id: "prep-t2", startedAt: "2026-05-26T14:31:35.023Z" }, // offset 4000 → T1
+ { id: "voice-t2", startedAt: "2026-05-26T14:31:37.265Z" }, // offset 6242 → T2
+ { id: "no-ts", startedAt: null },
+ ];
+
+ it("selects only rows whose offset lands in the window", () => {
+ expect(rowsInTurnWindow(rows, T2, RECORDING_T0).map((r) => r.id)).toEqual(["voice-t2"]);
+ expect(rowsInTurnWindow(rows, T1, RECORDING_T0).map((r) => r.id)).toEqual(["prep-t2"]);
+ });
+
+ it("flags a mistimed call: with the correct origin a 4000ms call is NOT in the agent turn T2", () => {
+ // This is the regression. With the buggy origin (ROW_CREATION) the same
+ // row maps to offset 6335 and would be wrongly counted into T2.
+ expect(rowsInTurnWindow(rows, T2, RECORDING_T0).map((r) => r.id)).not.toContain("prep-t2");
+ expect(rowsInTurnWindow(rows, T2, ROW_CREATION).map((r) => r.id)).toContain("prep-t2");
+ });
+
+ it("drops every row when there is no recording anchor", () => {
+ expect(rowsInTurnWindow(rows, T2, null)).toEqual([]);
+ });
+});
diff --git a/src/server/replays/timeline.ts b/src/server/replays/timeline.ts
new file mode 100644
index 0000000..318f429
--- /dev/null
+++ b/src/server/replays/timeline.ts
@@ -0,0 +1,112 @@
+/**
+ * Audio-timeline coordinate mapping. The single place that converts a span's
+ * wall-clock `started_at` into an offset on the audio timeline (ms from the
+ * recording's t=0), and decides which turn's window an offset falls in.
+ *
+ * See `docs/specs/0001-timeline-clock-alignment.md`. The origin is ALWAYS
+ * `replays.recording_started_at` (the driver's audio sample-0 wall-clock) —
+ * never `replays.started_at`, which is row-creation time and precedes the
+ * recording by the room-connect + agent-join latency.
+ */
+
+/**
+ * Offset of a wall-clock ISO timestamp on the audio timeline, in ms from the
+ * recording's t=0.
+ *
+ * Returns `null` when either input is missing or unparseable — callers MUST
+ * treat `null` as "cannot place this span" and skip attribution, NOT fall back
+ * to a different origin.
+ */
+export function audioOffsetMs(
+ startedAtIso: string | null,
+ recordingStartedAtIso: string | null,
+): number | null {
+ if (recordingStartedAtIso === null) return null;
+ const origin = Date.parse(recordingStartedAtIso);
+ if (!Number.isFinite(origin)) return null;
+ return offsetFromOriginMs(startedAtIso, origin);
+}
+
+/**
+ * Offset of a wall-clock ISO timestamp from a pre-parsed origin (epoch ms), or
+ * null if `startedAtIso` is missing/unparseable. Lets a read path that places
+ * many rows against one replay's origin parse that origin once instead of
+ * re-parsing the same string per row.
+ */
+export function offsetFromOriginMs(
+ startedAtIso: string | null,
+ originMs: number | null,
+): number | null {
+ if (startedAtIso === null || originMs === null) return null;
+ const start = Date.parse(startedAtIso);
+ if (!Number.isFinite(start)) return null;
+ return start - originMs;
+}
+
+/**
+ * An attribution window on the audio timeline: the half-open ms range a turn
+ * owns for span/tool/model membership. NOT the same as a turn's display extent
+ * (`turn_start_ms`/`turn_end_ms`), which may overlap a neighbour visually — see
+ * `clampedTurnWindows` for why attribution windows must tile while display
+ * bars need not.
+ */
+export interface TurnWindow {
+ readonly turnStartMs: number;
+ readonly turnEndMs: number;
+}
+
+/** True iff `offsetMs` falls in `[turnStartMs, turnEndMs)`. */
+export function offsetInTurnWindow(offsetMs: number, turn: TurnWindow): boolean {
+ return offsetMs >= turn.turnStartMs && offsetMs < turn.turnEndMs;
+}
+
+/**
+ * Build the tiling attribution windows for a replay's turns, in `idx` order,
+ * from each turn's `voiceEndMs`.
+ *
+ * The naive rule "turnStartMs = previous turn's voiceEndMs, turnEndMs = this
+ * turn's voiceEndMs" tiles cleanly ONLY when VAD turns strictly interleave. Per
+ * channel VAD plus barge-in (overlapping user/agent speech — the case the
+ * `interrupted` metric flags) produces non-monotonic `voiceEndMs`, which makes
+ * raw windows invert (`start > end`) or overlap, so one tool call lands in two
+ * turns and a fully-barged-over turn gets a window that can never match.
+ *
+ * A monotonic cursor fixes both: each window starts where the previous ended
+ * and ends at `max(cursor, voiceEndMs)`. An interrupted turn collapses to an
+ * empty `[c, c)` window (no rows attributed — correct, it was talked over) and
+ * the overlap region goes to the earlier turn (the ownership the deleted stored
+ * `turn_idx` backfill gave it). The result always tiles: every offset falls in
+ * exactly one window. Shared by the server evaluator and the client trace tree
+ * so attribution can never diverge between them.
+ */
+export function clampedTurnWindows(turnEndsMs: readonly number[]): TurnWindow[] {
+ const windows: TurnWindow[] = [];
+ let cursor = 0;
+ for (const end of turnEndsMs) {
+ const turnEndMs = Math.max(cursor, end);
+ windows.push({ turnStartMs: cursor, turnEndMs });
+ cursor = turnEndMs;
+ }
+ return windows;
+}
+
+/**
+ * The subset of `rows` whose `started_at` maps to an offset inside the turn's
+ * window. Used by the assertion evaluator to build a turn's tool/model context
+ * at eval time (replacing the deleted stored `turn_idx`).
+ *
+ * A row with no `started_at`, or when `recordingStartedAtIso` is null, is
+ * dropped — it cannot be placed on the timeline. The caller distinguishes
+ * "no anchor at all" (→ errored assertion) from "anchor present, row simply
+ * out of window" (→ failed assertion); this function only does geometry.
+ */
+export function rowsInTurnWindow(
+ rows: readonly T[],
+ turn: TurnWindow,
+ recordingStartedAtIso: string | null,
+): T[] {
+ return rows.filter((row) => {
+ const offset = audioOffsetMs(row.startedAt, recordingStartedAtIso);
+ return offset !== null && offsetInTurnWindow(offset, turn);
+ });
+}
diff --git a/src/server/replays/turn-metrics.test.ts b/src/server/replays/turn-metrics.test.ts
index 6540ee0..4dd7ab4 100644
--- a/src/server/replays/turn-metrics.test.ts
+++ b/src/server/replays/turn-metrics.test.ts
@@ -12,14 +12,12 @@ describe("projectTurnMetrics", () => {
{
turnIdx: 1,
agentResponseMs: 300,
- ttftMs: 90,
interrupted: true,
interruptionStartMs: 1200,
},
{
turnIdx: 0,
agentResponseMs: null,
- ttftMs: null,
interrupted: false,
interruptionStartMs: null,
},
@@ -30,7 +28,6 @@ describe("projectTurnMetrics", () => {
turn_idx: 1,
role: "agent",
agent_response_ms: 300,
- ttft_ms: 90,
interrupted: true,
interruption_start_ms: 1200,
});
@@ -43,7 +40,6 @@ describe("projectTurnMetrics", () => {
turn_idx: 0,
role: "agent",
agent_response_ms: null,
- ttft_ms: null,
interrupted: false,
interruption_start_ms: null,
},
diff --git a/src/server/replays/turn-metrics.ts b/src/server/replays/turn-metrics.ts
index 850f24f..049271f 100644
--- a/src/server/replays/turn-metrics.ts
+++ b/src/server/replays/turn-metrics.ts
@@ -10,7 +10,6 @@ interface TurnLike {
interface TurnMetricLike {
readonly turnIdx: number;
readonly agentResponseMs: number | null;
- readonly ttftMs: number | null;
readonly interrupted: boolean;
readonly interruptionStartMs: number | null;
}
@@ -45,7 +44,6 @@ export function projectTurnMetrics(
turn_idx: turn.idx,
role: turn.role,
agent_response_ms: m?.agentResponseMs ?? null,
- ttft_ms: m?.ttftMs ?? null,
interrupted: m?.interrupted ?? false,
interruption_start_ms: m?.interruptionStartMs ?? null,
};
diff --git a/src/server/store/migrations/0002_slim_master_chief.sql b/src/server/store/migrations/0002_slim_master_chief.sql
new file mode 100644
index 0000000..e1275eb
--- /dev/null
+++ b/src/server/store/migrations/0002_slim_master_chief.sql
@@ -0,0 +1,5 @@
+ALTER TABLE `model_usage` ADD `ttft_ms` integer;--> statement-breakpoint
+ALTER TABLE `model_usage` DROP COLUMN `turn_idx`;--> statement-breakpoint
+ALTER TABLE `replays` ADD `recording_started_at` text;--> statement-breakpoint
+ALTER TABLE `replay_metrics` DROP COLUMN `ttft_ms`;--> statement-breakpoint
+ALTER TABLE `tool_calls` DROP COLUMN `turn_idx`;
\ No newline at end of file
diff --git a/src/server/store/migrations/meta/0002_snapshot.json b/src/server/store/migrations/meta/0002_snapshot.json
new file mode 100644
index 0000000..9ebcf1f
--- /dev/null
+++ b/src/server/store/migrations/meta/0002_snapshot.json
@@ -0,0 +1,1226 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "114db5ae-a1af-4f49-8f47-b2a6728f787f",
+ "prevId": "cf3a83d4-c9c9-4a2e-b104-4cc2119e5f99",
+ "tables": {
+ "assertion_results": {
+ "name": "assertion_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_idx": {
+ "name": "turn_idx",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assertion_idx": {
+ "name": "assertion_idx",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "params_json": {
+ "name": "params_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "evaluated_at": {
+ "name": "evaluated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_assertion_results_replay_turn": {
+ "name": "idx_assertion_results_replay_turn",
+ "columns": [
+ "replay_id",
+ "turn_idx"
+ ],
+ "isUnique": false
+ },
+ "uq_assertion_results_replay_turn_idx": {
+ "name": "uq_assertion_results_replay_turn_idx",
+ "columns": [
+ "replay_id",
+ "turn_idx",
+ "assertion_idx"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "assertion_results_replay_id_replays_id_fk": {
+ "name": "assertion_results_replay_id_replays_id_fk",
+ "tableFrom": "assertion_results",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "assertion_results_status_ck": {
+ "name": "assertion_results_status_ck",
+ "value": "\"assertion_results\".\"status\" IN ('passed', 'failed', 'errored')"
+ }
+ }
+ },
+ "conversations": {
+ "name": "conversations",
+ "columns": {
+ "hash": {
+ "name": "hash",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turns_json": {
+ "name": "turns_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_conversations_last_run_at": {
+ "name": "idx_conversations_last_run_at",
+ "columns": [
+ "last_run_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "conversations_hash_ck": {
+ "name": "conversations_hash_ck",
+ "value": "length(\"conversations\".\"hash\") = 64"
+ }
+ }
+ },
+ "judge_results": {
+ "name": "judge_results",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "judge_idx": {
+ "name": "judge_idx",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "params_json": {
+ "name": "params_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "status": {
+ "name": "status",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "score": {
+ "name": "score",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evaluated_at": {
+ "name": "evaluated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_judge_results_replay": {
+ "name": "idx_judge_results_replay",
+ "columns": [
+ "replay_id"
+ ],
+ "isUnique": false
+ },
+ "uq_judge_results_replay_judge_idx": {
+ "name": "uq_judge_results_replay_judge_idx",
+ "columns": [
+ "replay_id",
+ "judge_idx"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "judge_results_replay_id_replays_id_fk": {
+ "name": "judge_results_replay_id_replays_id_fk",
+ "tableFrom": "judge_results",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "judge_results_status_ck": {
+ "name": "judge_results_status_ck",
+ "value": "\"judge_results\".\"status\" IN ('passed', 'failed', 'errored')"
+ },
+ "judge_results_score_ck": {
+ "name": "judge_results_score_ck",
+ "value": "\"judge_results\".\"score\" IS NULL OR (\"judge_results\".\"score\" >= 0 AND \"judge_results\".\"score\" <= 100)"
+ }
+ }
+ },
+ "model_usage": {
+ "name": "model_usage",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "span_id": {
+ "name": "span_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "input_tokens": {
+ "name": "input_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "output_tokens": {
+ "name": "output_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "total_tokens": {
+ "name": "total_tokens",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ttft_ms": {
+ "name": "ttft_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "latency_ms": {
+ "name": "latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_model_usage_replay": {
+ "name": "idx_model_usage_replay",
+ "columns": [
+ "replay_id",
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "model_usage_replay_id_replays_id_fk": {
+ "name": "model_usage_replay_id_replays_id_fk",
+ "tableFrom": "model_usage",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "replay_evaluations": {
+ "name": "replay_evaluations",
+ "columns": {
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "passed": {
+ "name": "passed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assertions_total": {
+ "name": "assertions_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "assertions_passed": {
+ "name": "assertions_passed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "judges_total": {
+ "name": "judges_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "judges_passed": {
+ "name": "judges_passed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "evaluated_at": {
+ "name": "evaluated_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "replay_evaluations_replay_id_replays_id_fk": {
+ "name": "replay_evaluations_replay_id_replays_id_fk",
+ "tableFrom": "replay_evaluations",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "replay_metrics": {
+ "name": "replay_metrics",
+ "columns": {
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_idx": {
+ "name": "turn_idx",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "agent_response_ms": {
+ "name": "agent_response_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "interrupted": {
+ "name": "interrupted",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "interruption_start_ms": {
+ "name": "interruption_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "replay_metrics_replay_id_replays_id_fk": {
+ "name": "replay_metrics_replay_id_replays_id_fk",
+ "tableFrom": "replay_metrics",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "replay_metrics_pk": {
+ "columns": [
+ "replay_id",
+ "turn_idx"
+ ],
+ "name": "replay_metrics_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "replay_turns": {
+ "name": "replay_turns",
+ "columns": {
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "idx": {
+ "name": "idx",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_start_ms": {
+ "name": "turn_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_end_ms": {
+ "name": "turn_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "voice_start_ms": {
+ "name": "voice_start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "voice_end_ms": {
+ "name": "voice_end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "replay_turns_replay_id_replays_id_fk": {
+ "name": "replay_turns_replay_id_replays_id_fk",
+ "tableFrom": "replay_turns",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "replay_turns_pk": {
+ "columns": [
+ "replay_id",
+ "idx"
+ ],
+ "name": "replay_turns_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "replay_turns_role_ck": {
+ "name": "replay_turns_role_ck",
+ "value": "\"replay_turns\".\"role\" IN ('user', 'agent')"
+ }
+ }
+ },
+ "replays": {
+ "name": "replays",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "conversation_hash": {
+ "name": "conversation_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "lifecycle_state": {
+ "name": "lifecycle_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "analysis_step": {
+ "name": "analysis_step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "failure_reason": {
+ "name": "failure_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "recording_started_at": {
+ "name": "recording_started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "audio_path": {
+ "name": "audio_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "run_config_json": {
+ "name": "run_config_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "job_id": {
+ "name": "job_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_replays_conversation_hash": {
+ "name": "idx_replays_conversation_hash",
+ "columns": [
+ "conversation_hash",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "idx_replays_started_at": {
+ "name": "idx_replays_started_at",
+ "columns": [
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "replays_conversation_hash_conversations_hash_fk": {
+ "name": "replays_conversation_hash_conversations_hash_fk",
+ "tableFrom": "replays",
+ "tableTo": "conversations",
+ "columnsFrom": [
+ "conversation_hash"
+ ],
+ "columnsTo": [
+ "hash"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "replays_lifecycle_state_ck": {
+ "name": "replays_lifecycle_state_ck",
+ "value": "\"replays\".\"lifecycle_state\" IN ('pending', 'running', 'recording_uploaded', 'analyzing', 'completed', 'failed')"
+ },
+ "replays_analysis_step_ck": {
+ "name": "replays_analysis_step_ck",
+ "value": "\"replays\".\"analysis_step\" IS NULL OR \"replays\".\"analysis_step\" IN ('vad', 'transcribe', 'metrics', 'evaluate')"
+ },
+ "replays_failure_reason_ck": {
+ "name": "replays_failure_reason_ck",
+ "value": "\"replays\".\"failure_reason\" IS NULL OR \"replays\".\"failure_reason\" IN ('stalled', 'timeout', 'explicit_fail', 'max_attempts_exceeded', 'worker_lost', 'upload_failed', 'driver_aborted', 'agent_not_joined', 'audio_missing', 'missing_credential', 'transcription_failed', 'metrics_failed', 'evaluation_failed', 'spec_vad_mismatch')"
+ }
+ }
+ },
+ "spans": {
+ "name": "spans",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "trace_id": {
+ "name": "trace_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "span_id": {
+ "name": "span_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_span_id": {
+ "name": "parent_span_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "vocabulary": {
+ "name": "vocabulary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "attributes_json": {
+ "name": "attributes_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_spans_replay_started": {
+ "name": "idx_spans_replay_started",
+ "columns": [
+ "replay_id",
+ "started_at"
+ ],
+ "isUnique": false
+ },
+ "idx_spans_trace": {
+ "name": "idx_spans_trace",
+ "columns": [
+ "trace_id"
+ ],
+ "isUnique": false
+ },
+ "spans_replay_span_uk": {
+ "name": "spans_replay_span_uk",
+ "columns": [
+ "replay_id",
+ "span_id"
+ ],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "spans_replay_id_replays_id_fk": {
+ "name": "spans_replay_id_replays_id_fk",
+ "tableFrom": "spans",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "spans_vocabulary_ck": {
+ "name": "spans_vocabulary_ck",
+ "value": "\"spans\".\"vocabulary\" IN ('xray', 'gen_ai', 'langfuse')"
+ }
+ }
+ },
+ "speech_segments": {
+ "name": "speech_segments",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "channel": {
+ "name": "channel",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "start_ms": {
+ "name": "start_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "end_ms": {
+ "name": "end_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_speech_segments_replay_start": {
+ "name": "idx_speech_segments_replay_start",
+ "columns": [
+ "replay_id",
+ "start_ms"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "speech_segments_replay_id_replays_id_fk": {
+ "name": "speech_segments_replay_id_replays_id_fk",
+ "tableFrom": "speech_segments",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "speech_segments_channel_ck": {
+ "name": "speech_segments_channel_ck",
+ "value": "\"speech_segments\".\"channel\" IN ('user', 'agent')"
+ }
+ }
+ },
+ "tool_calls": {
+ "name": "tool_calls",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": true
+ },
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "span_id": {
+ "name": "span_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "args_json": {
+ "name": "args_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "result_json": {
+ "name": "result_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "latency_ms": {
+ "name": "latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "idx_tool_calls_replay": {
+ "name": "idx_tool_calls_replay",
+ "columns": [
+ "replay_id",
+ "started_at"
+ ],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "tool_calls_replay_id_replays_id_fk": {
+ "name": "tool_calls_replay_id_replays_id_fk",
+ "tableFrom": "tool_calls",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "tts_synth_cache": {
+ "name": "tts_synth_cache",
+ "columns": {
+ "fingerprint": {
+ "name": "fingerprint",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "audio_sha256": {
+ "name": "audio_sha256",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "voice": {
+ "name": "voice",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {
+ "tts_synth_cache_fingerprint_ck": {
+ "name": "tts_synth_cache_fingerprint_ck",
+ "value": "length(\"tts_synth_cache\".\"fingerprint\") = 64"
+ },
+ "tts_synth_cache_audio_sha256_ck": {
+ "name": "tts_synth_cache_audio_sha256_ck",
+ "value": "length(\"tts_synth_cache\".\"audio_sha256\") = 64"
+ }
+ }
+ },
+ "turn_transcripts": {
+ "name": "turn_transcripts",
+ "columns": {
+ "replay_id": {
+ "name": "replay_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "turn_idx": {
+ "name": "turn_idx",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "text": {
+ "name": "text",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "words_json": {
+ "name": "words_json",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "model": {
+ "name": "model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "turn_transcripts_replay_id_replays_id_fk": {
+ "name": "turn_transcripts_replay_id_replays_id_fk",
+ "tableFrom": "turn_transcripts",
+ "tableTo": "replays",
+ "columnsFrom": [
+ "replay_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "turn_transcripts_pk": {
+ "columns": [
+ "replay_id",
+ "turn_idx"
+ ],
+ "name": "turn_transcripts_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
\ No newline at end of file
diff --git a/src/server/store/migrations/meta/_journal.json b/src/server/store/migrations/meta/_journal.json
index c1b56da..d5c431b 100644
--- a/src/server/store/migrations/meta/_journal.json
+++ b/src/server/store/migrations/meta/_journal.json
@@ -15,6 +15,13 @@
"when": 1780499814403,
"tag": "0001_fast_lightspeed",
"breakpoints": true
+ },
+ {
+ "idx": 2,
+ "version": "6",
+ "when": 1781126983039,
+ "tag": "0002_slim_master_chief",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/server/store/schema.ts b/src/server/store/schema.ts
index b6e1a2c..4f94f6d 100644
--- a/src/server/store/schema.ts
+++ b/src/server/store/schema.ts
@@ -105,6 +105,15 @@ export const replays = sqliteTable(
failureReason: text("failure_reason").$type(),
startedAt: text("started_at").notNull(),
finishedAt: text("finished_at"),
+ // Wall-clock (UTC ISO-8601) of audio sample 0 — the driver's
+ // `min(segment.started_at)`, sent via the `X-Recording-Started-At`
+ // header on POST /audio. This is the SOLE origin for mapping a span's
+ // wall-clock `started_at` onto the audio timeline
+ // (`audio_offset_ms = started_at − recording_started_at`). Null when an
+ // older SDK omits the header: offsets are then undefined and span→turn
+ // attribution is skipped rather than mis-anchored to `started_at`
+ // (which is row-creation time, not recording start — see spec 0001).
+ recordingStartedAt: text("recording_started_at"),
// Path under XRAY_AUDIO_ROOT to the uploaded stereo WAV.
audioPath: text("audio_path"),
// Opaque dev-side snapshot of the SUT config at run start.
@@ -208,7 +217,6 @@ export const toolCalls = sqliteTable(
replayId: text("replay_id")
.notNull()
.references(() => replays.id, { onDelete: "cascade" }),
- turnIdx: integer("turn_idx"),
spanId: text("span_id"),
name: text("name").notNull(),
argsJson: text("args_json"),
@@ -227,13 +235,17 @@ export const modelUsage = sqliteTable(
replayId: text("replay_id")
.notNull()
.references(() => replays.id, { onDelete: "cascade" }),
- turnIdx: integer("turn_idx"),
spanId: text("span_id"),
provider: text("provider"),
model: text("model"),
inputTokens: integer("input_tokens"),
outputTokens: integer("output_tokens"),
totalTokens: integer("total_tokens"),
+ // Model time-to-first-token (ms), from the GenAI semconv span attribute
+ // `gen_ai.response.time_to_first_chunk` (seconds → ms). Optional, exactly
+ // like the token counts: null when the agent's instrumentation doesn't
+ // emit it. A same-clock delta, so it needs no audio-timeline correlation.
+ ttftMs: integer("ttft_ms"),
startedAt: text("started_at"),
endedAt: text("ended_at"),
latencyMs: integer("latency_ms"),
@@ -265,11 +277,13 @@ export const turnTranscripts = sqliteTable(
// Per-turn timing metrics. Produced by `calculate-metrics`.
//
// `agent_response_ms` is `voice_start_ms - prior_user_turn.voice_end_ms` for
-// agent turns; null for user turns. `ttft_ms` is the gap from the agent
-// turn's `voice_start_ms` back to the start of the FIRST `gen_ai.client.*`
-// span attributed to this turn — null if no such span exists.
-// `interrupted` is true when an opposite-channel speech segment started
-// while this turn was still active.
+// agent turns; null for user turns. `interrupted` is true when an
+// opposite-channel speech segment started while this turn was still active.
+//
+// These are the audio-frame metrics — both operands of every value come from
+// VAD on the same recording, so they need no cross-clock correlation. Model
+// TTFT is NOT here: it's a span-level attribute on `model_usage.ttft_ms`
+// (see spec 0001), surfaced on the timeline rather than aggregated per turn.
export const replayMetrics = sqliteTable(
"replay_metrics",
{
@@ -278,7 +292,6 @@ export const replayMetrics = sqliteTable(
.references(() => replays.id, { onDelete: "cascade" }),
turnIdx: integer("turn_idx").notNull(),
agentResponseMs: integer("agent_response_ms"),
- ttftMs: integer("ttft_ms"),
interrupted: integer("interrupted", { mode: "boolean" }).notNull(),
interruptionStartMs: integer("interruption_start_ms"),
},
diff --git a/src/server/store/store.test.ts b/src/server/store/store.test.ts
index 2ba06de..ad33d35 100644
--- a/src/server/store/store.test.ts
+++ b/src/server/store/store.test.ts
@@ -1,4 +1,4 @@
-import { existsSync, mkdtempSync, rmSync } from "node:fs";
+import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -200,3 +200,57 @@ describe("openStoreFromEnv", () => {
second.close();
});
});
+
+describe("migration 0002 — slim_master_chief (populated-DB upgrade)", () => {
+ const migrationSql = (file: string) =>
+ readFileSync(new URL(`./migrations/${file}`, import.meta.url).pathname, "utf8");
+
+ function columnNames(db: Database, table: string): string[] {
+ return db
+ .query<{ name: string }, []>(`PRAGMA table_info(${table})`)
+ .all()
+ .map((r) => r.name);
+ }
+
+ it("drops turn_idx / replay_metrics.ttft_ms and adds model_usage.ttft_ms + recording_started_at while preserving existing rows", () => {
+ const db = new Database(":memory:");
+ // FK off: we seed leaf rows without a parent `replays` row — the test is
+ // about the ALTER TABLE structural change, not referential integrity.
+ db.exec("PRAGMA foreign_keys = OFF");
+ db.exec(migrationSql("0000_init.sql"));
+ db.exec(migrationSql("0001_fast_lightspeed.sql"));
+
+ // Seed old-shape rows: tool/model rows carrying the soon-to-be-dropped
+ // turn_idx, and a replay_metrics row carrying the soon-to-be-dropped
+ // ttft_ms.
+ db.exec("INSERT INTO tool_calls (replay_id, turn_idx, name) VALUES ('r1', 3, 'lookup')");
+ db.exec("INSERT INTO model_usage (replay_id, turn_idx, provider) VALUES ('r1', 2, 'openai')");
+ db.exec(
+ "INSERT INTO replay_metrics (replay_id, turn_idx, ttft_ms, interrupted) VALUES ('r1', 0, 500, 0)",
+ );
+
+ // The destructive upgrade.
+ db.exec(migrationSql("0002_slim_master_chief.sql"));
+
+ expect(columnNames(db, "tool_calls")).not.toContain("turn_idx");
+ expect(columnNames(db, "model_usage")).not.toContain("turn_idx");
+ expect(columnNames(db, "model_usage")).toContain("ttft_ms");
+ expect(columnNames(db, "replay_metrics")).not.toContain("ttft_ms");
+ expect(columnNames(db, "replays")).toContain("recording_started_at");
+
+ // Surviving columns keep their data; the new nullable columns default to null.
+ const tool = db
+ .query<{ name: string }, []>("SELECT name FROM tool_calls WHERE replay_id = 'r1'")
+ .get();
+ expect(tool?.name).toBe("lookup");
+ const usage = db
+ .query<{ provider: string; ttft_ms: number | null }, []>(
+ "SELECT provider, ttft_ms FROM model_usage WHERE replay_id = 'r1'",
+ )
+ .get();
+ expect(usage?.provider).toBe("openai");
+ expect(usage?.ttft_ms).toBeNull();
+
+ db.close();
+ });
+});