@vellumai/assistant 0.11.5-dev.202608241517.6c95b21 → 0.11.5-dev.202608241702.d462ce2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/openapi.yaml CHANGED
@@ -21254,8 +21254,9 @@ paths:
21254
21254
  summary: Import a .vbundle archive
21255
21255
  description:
21256
21256
  Commit a .vbundle archive import to disk — destructive. Accepts the bundle as raw bytes
21257
- (application/octet-stream), multipart/form-data, or a JSON body with `{ url }` carrying a signed URL the daemon
21258
- fetches.
21257
+ (application/octet-stream), multipart/form-data, a JSON body with `{ url }` carrying a signed URL the daemon
21258
+ fetches, or a JSON body with `{ path }` pointing at a file staged under the workspace `.restore-staging`
21259
+ directory.
21259
21260
  tags:
21260
21261
  - migrations
21261
21262
  requestBody:
@@ -21266,11 +21267,13 @@ paths:
21266
21267
  type: object
21267
21268
  properties:
21268
21269
  url:
21270
+ description: A signed GCS URL pointing to the .vbundle archive (JSON body path only).
21269
21271
  type: string
21270
21272
  format: uri
21271
- description: A signed GCS URL pointing to the .vbundle archive (JSON body path only).
21272
- required:
21273
- - url
21273
+ path:
21274
+ description: Workspace-relative or absolute path to a staged .vbundle under .restore-staging (JSON body path only).
21275
+ type: string
21276
+ minLength: 1
21274
21277
  responses:
21275
21278
  "200":
21276
21279
  description: Successful response
@@ -21353,9 +21356,23 @@ paths:
21353
21356
  post:
21354
21357
  operationId: migrations_importpreflight_post
21355
21358
  summary: Dry-run import analysis
21356
- description: Validate a .vbundle archive and return a report of what would change on import without modifying data.
21359
+ description:
21360
+ Validate a .vbundle archive and return a report of what would change on import without modifying data.
21361
+ Accepts raw bytes, multipart form data, or JSON `{ path }` pointing at a file staged under the workspace
21362
+ `.restore-staging` directory.
21357
21363
  tags:
21358
21364
  - migrations
21365
+ requestBody:
21366
+ required: true
21367
+ content:
21368
+ application/json:
21369
+ schema:
21370
+ type: object
21371
+ properties:
21372
+ path:
21373
+ description: Workspace-relative or absolute path to a staged .vbundle under .restore-staging (JSON body path only).
21374
+ type: string
21375
+ minLength: 1
21359
21376
  responses:
21360
21377
  "200":
21361
21378
  description: Successful response
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.5-dev.202608241517.6c95b21",
3
+ "version": "0.11.5-dev.202608241702.d462ce2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,349 @@
1
+ /**
2
+ * HTTP tests for JSON `{ path }` on POST /v1/migrations/import and
3
+ * POST /v1/migrations/import-preflight.
4
+ *
5
+ * The daemon only opens a regular `.vbundle` that realpath-resolves
6
+ * under `${workspace}/.restore-staging/`.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ realpathSync,
14
+ rmSync,
15
+ symlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import {
21
+ afterAll,
22
+ afterEach,
23
+ beforeEach,
24
+ describe,
25
+ expect,
26
+ mock,
27
+ test,
28
+ } from "bun:test";
29
+
30
+ mock.module("../permissions/trust-store.js", () => ({
31
+ getAllRules: () => [],
32
+ isStarterBundleAccepted: () => false,
33
+ clearCache: () => {},
34
+ }));
35
+
36
+ mock.module("../config/env.js", () => ({
37
+ isHttpAuthDisabled: () => true,
38
+ hasUngatedHttpAuthDisabled: () => false,
39
+ getGatewayInternalBaseUrl: () => "http://127.0.0.1:7830",
40
+ getGatewayPort: () => 7830,
41
+ getRuntimeHttpPort: () => 7821,
42
+ getRuntimeHttpHost: () => "127.0.0.1",
43
+ getRuntimeGatewayOriginSecret: () => undefined,
44
+ getIngressPublicBaseUrl: () => undefined,
45
+ setIngressPublicBaseUrl: () => {},
46
+ }));
47
+
48
+ import { defaultV1Options } from "../runtime/migrations/__tests__/v1-test-helpers.js";
49
+ import { RESTORE_STAGING_DIRNAME } from "../runtime/migrations/staged-import-path.js";
50
+ import { buildVBundle } from "../runtime/migrations/vbundle-builder.js";
51
+ import {
52
+ handleMigrationImport,
53
+ handleMigrationImportPreflight,
54
+ } from "../runtime/routes/migration-routes.js";
55
+ import { callHandler } from "./helpers/call-route-handler.js";
56
+
57
+ const originalWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR;
58
+
59
+ function freshWorkspaceRoot(): string {
60
+ const parent = realpathSync(
61
+ mkdtempSync(join(tmpdir(), "migration-import-from-path-")),
62
+ );
63
+ const workspaceDir = join(parent, "workspace");
64
+ mkdirSync(workspaceDir, { recursive: true });
65
+ return workspaceDir;
66
+ }
67
+
68
+ function setWorkspaceDir(dir: string): void {
69
+ process.env.VELLUM_WORKSPACE_DIR = dir;
70
+ }
71
+
72
+ function makeSmallValidBundle(): Uint8Array {
73
+ const { archive } = buildVBundle({
74
+ files: [
75
+ {
76
+ path: "workspace/data/db/assistant.db",
77
+ data: new TextEncoder().encode("SQLite format 3\0"),
78
+ },
79
+ {
80
+ path: "workspace/config.json",
81
+ data: new TextEncoder().encode(
82
+ JSON.stringify({ provider: "anthropic", model: "test-model" }),
83
+ ),
84
+ },
85
+ ],
86
+ ...defaultV1Options(),
87
+ });
88
+ return archive;
89
+ }
90
+
91
+ function stageBundle(workspaceDir: string, archive: Uint8Array): string {
92
+ const staging = join(workspaceDir, RESTORE_STAGING_DIRNAME);
93
+ mkdirSync(staging, { recursive: true });
94
+ const filename = "backup.vbundle";
95
+ writeFileSync(join(staging, filename), archive);
96
+ return `${RESTORE_STAGING_DIRNAME}/${filename}`;
97
+ }
98
+
99
+ interface ImportCommitResponse {
100
+ success: boolean;
101
+ summary: {
102
+ total_files: number;
103
+ files_created: number;
104
+ files_overwritten: number;
105
+ files_skipped: number;
106
+ backups_created: number;
107
+ };
108
+ files: Array<{ path: string }>;
109
+ manifest: Record<string, unknown>;
110
+ }
111
+
112
+ interface PreflightResponse {
113
+ can_import: boolean;
114
+ summary?: {
115
+ files_to_create: number;
116
+ files_to_overwrite: number;
117
+ files_unchanged: number;
118
+ total_files: number;
119
+ };
120
+ validation?: { is_valid: false; errors: Array<{ code: string }> };
121
+ }
122
+
123
+ interface BadRequestResponse {
124
+ error: { code: string; message: string };
125
+ }
126
+
127
+ let testWorkspaceRoot: string;
128
+ let testParent: string;
129
+
130
+ beforeEach(() => {
131
+ testWorkspaceRoot = freshWorkspaceRoot();
132
+ testParent = join(testWorkspaceRoot, "..");
133
+ setWorkspaceDir(testWorkspaceRoot);
134
+ });
135
+
136
+ afterEach(() => {
137
+ try {
138
+ rmSync(testParent, { recursive: true, force: true });
139
+ } catch {
140
+ /* best effort */
141
+ }
142
+ });
143
+
144
+ afterAll(() => {
145
+ if (originalWorkspaceDir !== undefined) {
146
+ process.env.VELLUM_WORKSPACE_DIR = originalWorkspaceDir;
147
+ } else {
148
+ delete process.env.VELLUM_WORKSPACE_DIR;
149
+ }
150
+ });
151
+
152
+ describe("handleMigrationImport — JSON {path} body", () => {
153
+ test("happy path: streams a staged .vbundle and imports it", async () => {
154
+ const relativePath = stageBundle(
155
+ testWorkspaceRoot,
156
+ makeSmallValidBundle(),
157
+ );
158
+
159
+ const req = new Request("http://localhost/v1/migrations/import", {
160
+ method: "POST",
161
+ headers: { "Content-Type": "application/json" },
162
+ body: JSON.stringify({ path: relativePath }),
163
+ });
164
+
165
+ const res = await callHandler(handleMigrationImport, req);
166
+ const body = (await res.json()) as ImportCommitResponse;
167
+
168
+ expect(res.status).toBe(200);
169
+ expect(body.success).toBe(true);
170
+ expect(body.summary.total_files).toBeGreaterThan(0);
171
+ expect(body.files.length).toBeGreaterThan(0);
172
+ expect(existsSync(join(testWorkspaceRoot, "data", "db", "assistant.db"))).toBe(
173
+ true,
174
+ );
175
+ expect(existsSync(join(testWorkspaceRoot, "config.json"))).toBe(true);
176
+ });
177
+
178
+ test("absolute path inside staging is accepted", async () => {
179
+ const relativePath = stageBundle(
180
+ testWorkspaceRoot,
181
+ makeSmallValidBundle(),
182
+ );
183
+ const absolutePath = join(testWorkspaceRoot, relativePath);
184
+
185
+ const req = new Request("http://localhost/v1/migrations/import", {
186
+ method: "POST",
187
+ headers: { "Content-Type": "application/json" },
188
+ body: JSON.stringify({ path: absolutePath }),
189
+ });
190
+
191
+ const res = await callHandler(handleMigrationImport, req);
192
+ expect(res.status).toBe(200);
193
+ const body = (await res.json()) as ImportCommitResponse;
194
+ expect(body.success).toBe(true);
195
+ });
196
+
197
+ test("both url and path returns 400", async () => {
198
+ const req = new Request("http://localhost/v1/migrations/import", {
199
+ method: "POST",
200
+ headers: { "Content-Type": "application/json" },
201
+ body: JSON.stringify({
202
+ url: "https://storage.googleapis.com/b/o?X-Goog-Signature=x",
203
+ path: `${RESTORE_STAGING_DIRNAME}/backup.vbundle`,
204
+ }),
205
+ });
206
+
207
+ const res = await callHandler(handleMigrationImport, req);
208
+ const body = (await res.json()) as BadRequestResponse;
209
+
210
+ expect(res.status).toBe(400);
211
+ expect(body.error.code).toBe("BAD_REQUEST");
212
+ expect(body.error.message).toContain("exactly one");
213
+ });
214
+
215
+ test("missing staged file returns 400", async () => {
216
+ const req = new Request("http://localhost/v1/migrations/import", {
217
+ method: "POST",
218
+ headers: { "Content-Type": "application/json" },
219
+ body: JSON.stringify({
220
+ path: `${RESTORE_STAGING_DIRNAME}/missing.vbundle`,
221
+ }),
222
+ });
223
+
224
+ const res = await callHandler(handleMigrationImport, req);
225
+ const body = (await res.json()) as BadRequestResponse;
226
+
227
+ expect(res.status).toBe(400);
228
+ expect(body.error.code).toBe("BAD_REQUEST");
229
+ expect(body.error.message.toLowerCase()).toContain("not found");
230
+ });
231
+
232
+ test("traversal out of staging returns 400", async () => {
233
+ writeFileSync(join(testWorkspaceRoot, "secret.vbundle"), "nope");
234
+
235
+ const req = new Request("http://localhost/v1/migrations/import", {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ path: `${RESTORE_STAGING_DIRNAME}/../secret.vbundle`,
240
+ }),
241
+ });
242
+
243
+ const res = await callHandler(handleMigrationImport, req);
244
+ const body = (await res.json()) as BadRequestResponse;
245
+
246
+ expect(res.status).toBe(400);
247
+ expect(body.error.code).toBe("BAD_REQUEST");
248
+ expect(body.error.message.toLowerCase()).toContain("staging");
249
+ });
250
+
251
+ test("path outside the workspace returns 400", async () => {
252
+ const outside = join(testParent, "outside.vbundle");
253
+ writeFileSync(outside, "nope");
254
+
255
+ const req = new Request("http://localhost/v1/migrations/import", {
256
+ method: "POST",
257
+ headers: { "Content-Type": "application/json" },
258
+ body: JSON.stringify({ path: outside }),
259
+ });
260
+
261
+ const res = await callHandler(handleMigrationImport, req);
262
+ const body = (await res.json()) as BadRequestResponse;
263
+
264
+ expect(res.status).toBe(400);
265
+ expect(body.error.code).toBe("BAD_REQUEST");
266
+ expect(body.error.message.toLowerCase()).toContain("staging");
267
+ });
268
+
269
+ test("symlink in staging returns 400", async () => {
270
+ const relativePath = stageBundle(
271
+ testWorkspaceRoot,
272
+ makeSmallValidBundle(),
273
+ );
274
+ const target = join(testWorkspaceRoot, relativePath);
275
+ const link = join(testWorkspaceRoot, RESTORE_STAGING_DIRNAME, "alias.vbundle");
276
+ symlinkSync(target, link);
277
+
278
+ const req = new Request("http://localhost/v1/migrations/import", {
279
+ method: "POST",
280
+ headers: { "Content-Type": "application/json" },
281
+ body: JSON.stringify({
282
+ path: `${RESTORE_STAGING_DIRNAME}/alias.vbundle`,
283
+ }),
284
+ });
285
+
286
+ const res = await callHandler(handleMigrationImport, req);
287
+ const body = (await res.json()) as BadRequestResponse;
288
+
289
+ expect(res.status).toBe(400);
290
+ expect(body.error.code).toBe("BAD_REQUEST");
291
+ expect(body.error.message.toLowerCase()).toContain("symlink");
292
+ });
293
+ });
294
+
295
+ describe("handleMigrationImportPreflight — JSON {path} body", () => {
296
+ test("happy path: analyzes a staged .vbundle without writing files", async () => {
297
+ const relativePath = stageBundle(
298
+ testWorkspaceRoot,
299
+ makeSmallValidBundle(),
300
+ );
301
+
302
+ const req = new Request("http://localhost/v1/migrations/import-preflight", {
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ body: JSON.stringify({ path: relativePath }),
306
+ });
307
+
308
+ const res = await callHandler(handleMigrationImportPreflight, req);
309
+ const body = (await res.json()) as PreflightResponse;
310
+
311
+ expect(res.status).toBe(200);
312
+ expect(body.can_import).toBe(true);
313
+ expect(body.summary?.total_files).toBeGreaterThan(0);
314
+ expect(existsSync(join(testWorkspaceRoot, "config.json"))).toBe(false);
315
+ });
316
+
317
+ test("empty path returns 400", async () => {
318
+ const req = new Request("http://localhost/v1/migrations/import-preflight", {
319
+ method: "POST",
320
+ headers: { "Content-Type": "application/json" },
321
+ body: JSON.stringify({ path: "" }),
322
+ });
323
+
324
+ const res = await callHandler(handleMigrationImportPreflight, req);
325
+ const body = (await res.json()) as BadRequestResponse;
326
+
327
+ expect(res.status).toBe(400);
328
+ expect(body.error.code).toBe("BAD_REQUEST");
329
+ });
330
+
331
+ test("non-.vbundle staging file returns 400", async () => {
332
+ const staging = join(testWorkspaceRoot, RESTORE_STAGING_DIRNAME);
333
+ mkdirSync(staging, { recursive: true });
334
+ writeFileSync(join(staging, "notes.txt"), "nope");
335
+
336
+ const req = new Request("http://localhost/v1/migrations/import-preflight", {
337
+ method: "POST",
338
+ headers: { "Content-Type": "application/json" },
339
+ body: JSON.stringify({ path: `${RESTORE_STAGING_DIRNAME}/notes.txt` }),
340
+ });
341
+
342
+ const res = await callHandler(handleMigrationImportPreflight, req);
343
+ const body = (await res.json()) as BadRequestResponse;
344
+
345
+ expect(res.status).toBe(400);
346
+ expect(body.error.code).toBe("BAD_REQUEST");
347
+ expect(body.error.message.toLowerCase()).toContain(".vbundle");
348
+ });
349
+ });
@@ -147,9 +147,12 @@ describe("live-voice session telemetry", () => {
147
147
 
148
148
  await session.close("client_end");
149
149
 
150
+ // These sessions reach `ready` and are closed without ever sending audio,
151
+ // so they are silent by construction and the reason says which layer
152
+ // stopped short. See `telemetry/live-voice-funnel.ts`.
150
153
  expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
151
154
  sessionId: "session-123",
152
- screen: "ended_client_end",
155
+ screen: "ended_client_end:silent_no_audio",
153
156
  outcome: "completed",
154
157
  });
155
158
  });
@@ -164,7 +167,7 @@ describe("live-voice session telemetry", () => {
164
167
  // the reason on `screen` is what distinguishes a drop from a hangup.
165
168
  expect(recordLiveVoiceSessionEnded).toHaveBeenCalledWith({
166
169
  sessionId: "session-123",
167
- screen: "ended_websocket_close",
170
+ screen: "ended_websocket_close:silent_no_audio",
168
171
  outcome: "completed",
169
172
  });
170
173
  });
@@ -71,7 +71,10 @@ import type {
71
71
  SttStreamServerEvent,
72
72
  } from "../stt/types.js";
73
73
  import { getSubagentManager } from "../subagent/index.js";
74
- import { liveVoiceEndScreen } from "../telemetry/live-voice-funnel.js";
74
+ import {
75
+ liveVoiceEndScreen,
76
+ liveVoiceSilenceReason,
77
+ } from "../telemetry/live-voice-funnel.js";
75
78
  import { getToolOwner } from "../tools/registry.js";
76
79
  import {
77
80
  createReasoningTagFilter,
@@ -1126,6 +1129,20 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1126
1129
  * session that never failed.
1127
1130
  */
1128
1131
  private failureCode: LiveVoiceProtocolErrorCode | null = null;
1132
+ /**
1133
+ * How far a session that never produced a turn actually got. Latched rather
1134
+ * than derived at close, because every one of these is a transient the
1135
+ * teardown has already destroyed by then: `state` has moved on, the current
1136
+ * utterance is gone, and the turn detector is disposed.
1137
+ *
1138
+ * A quarter of sessions end with no turn at all, and these three booleans are
1139
+ * what separate a microphone that never opened from one that was muted from a
1140
+ * user who simply left. See `telemetry/live-voice-funnel.ts`.
1141
+ */
1142
+ private reachedActive = false;
1143
+ private receivedAudio = false;
1144
+ private detectedSpeech = false;
1145
+ private dispatchedTurn = false;
1129
1146
  // Non-null iff the start frame requested turnDetection "server_vad".
1130
1147
  private readonly turnDetector: MediaTurnDetector | null;
1131
1148
  // Base energy gate for server-VAD speech classification. During estimated
@@ -1419,6 +1436,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1419
1436
  // through the pending/pre-roll paths and flushes on arm. An arm failure
1420
1437
  // surfaces as a non-recoverable error frame instead of a start rejection.
1421
1438
  this.state = "active";
1439
+ this.reachedActive = true;
1422
1440
  void this.armUtterance().catch(() => {});
1423
1441
  this.metrics.markReady();
1424
1442
  await this.sendFrame({
@@ -1522,9 +1540,22 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1522
1540
  // `recorded_at` minus the started row's, so it must be written before the
1523
1541
  // teardown below, which awaits a pending continuation and can run long.
1524
1542
  const failed = this.state === "failed";
1543
+ // Only a session that never dispatched a turn gets a silence reason; for
1544
+ // every other session the question is meaningless.
1545
+ const silenceReason = this.dispatchedTurn
1546
+ ? null
1547
+ : liveVoiceSilenceReason({
1548
+ reachedActive: this.reachedActive,
1549
+ receivedAudio: this.receivedAudio,
1550
+ detectedSpeech: this.detectedSpeech,
1551
+ });
1525
1552
  recordLiveVoiceSessionEnded({
1526
1553
  sessionId: this.context.sessionId,
1527
- screen: liveVoiceEndScreen(reason, failed ? this.failureCode : null),
1554
+ screen: liveVoiceEndScreen(
1555
+ reason,
1556
+ failed ? this.failureCode : null,
1557
+ silenceReason,
1558
+ ),
1528
1559
  outcome: failed ? "failed" : "completed",
1529
1560
  });
1530
1561
 
@@ -1831,6 +1862,11 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1831
1862
  }
1832
1863
 
1833
1864
  private async handleAudio(chunk: Buffer): Promise<void> {
1865
+ // Both transports funnel through here, and this runs before any of the
1866
+ // early returns below. The question it answers is "did the microphone ever
1867
+ // open", which a chunk arriving at all settles regardless of what the
1868
+ // session then does with it.
1869
+ this.receivedAudio = true;
1834
1870
  if (this.turnDetector) {
1835
1871
  await this.handleServerVadAudio(this.turnDetector, chunk);
1836
1872
  return;
@@ -1849,6 +1885,10 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1849
1885
  // The chunk belongs to an utterance the user is still holding the button
1850
1886
  // for, whether or not the transcriber has produced any text for it yet.
1851
1887
  utterance.manualAudioCaptured = true;
1888
+ // Manual sessions have no VAD, so the user holding the talk button is
1889
+ // the only speech signal available. Without this every abandoned manual
1890
+ // session would be misfiled as `no_turn` instead of `no_speech`.
1891
+ this.detectedSpeech = true;
1852
1892
  this.collectUserAudio(utterance, chunk);
1853
1893
  if (utterance.phase === "pending") {
1854
1894
  // The transcriber is still arming (session start overlaps the STT
@@ -1968,6 +2008,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
1968
2008
  // resets it.
1969
2009
  if (hasSpeech || this.vadPreRollHasSpeech) {
1970
2010
  utterance.speechRouted = true;
2011
+ this.detectedSpeech = true;
1971
2012
  }
1972
2013
  for (const preRollChunk of this.takeVadPreRoll()) {
1973
2014
  await this.routeVadAudio(utterance, preRollChunk);
@@ -2221,6 +2262,7 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
2221
2262
  }
2222
2263
  if (preRollHadSpeech) {
2223
2264
  utterance.speechRouted = true;
2265
+ this.detectedSpeech = true;
2224
2266
  }
2225
2267
  }
2226
2268
 
@@ -4725,6 +4767,14 @@ export class LiveVoiceSession implements LiveVoiceSessionContract {
4725
4767
  };
4726
4768
 
4727
4769
  try {
4770
+ // Latched before the await, not after: this flag only decides whether the
4771
+ // end event carries a silence classification, and the dashboard decides
4772
+ // silence from persisted turn rows instead. Setting it after would let a
4773
+ // dispatch that persisted a turn and then threw stamp "this session was
4774
+ // silent" onto a session that has turns, a contradictory row. Setting it
4775
+ // before can at worst leave a silent session unexplained, which is a gap
4776
+ // rather than a false statement.
4777
+ this.dispatchedTurn = true;
4728
4778
  const handle = await this.startVoiceTurn({
4729
4779
  conversationId: this.conversationId,
4730
4780
  voiceSessionId: this.context.sessionId,
@@ -0,0 +1,104 @@
1
+ import {
2
+ mkdirSync,
3
+ mkdtempSync,
4
+ realpathSync,
5
+ symlinkSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { describe, expect, test } from "bun:test";
11
+
12
+ import {
13
+ resolveStagedImportPath,
14
+ RESTORE_STAGING_DIRNAME,
15
+ StagedImportPathError,
16
+ } from "../staged-import-path.js";
17
+
18
+ function stagingWorkspace(): { workspace: string; staging: string } {
19
+ const workspace = realpathSync(mkdtempSync(join(tmpdir(), "staged-import-")));
20
+ const staging = join(workspace, RESTORE_STAGING_DIRNAME);
21
+ mkdirSync(staging, { recursive: true });
22
+ return { workspace, staging };
23
+ }
24
+
25
+ describe("resolveStagedImportPath", () => {
26
+ test("resolves a relative path inside the staging directory", () => {
27
+ const { workspace, staging } = stagingWorkspace();
28
+ const file = join(staging, "backup.vbundle");
29
+ writeFileSync(file, "bundle");
30
+
31
+ expect(
32
+ resolveStagedImportPath(`${RESTORE_STAGING_DIRNAME}/backup.vbundle`, workspace),
33
+ ).toBe(realpathSync(file));
34
+ });
35
+
36
+ test("resolves an absolute path inside the staging directory", () => {
37
+ const { workspace, staging } = stagingWorkspace();
38
+ const file = join(staging, "backup.vbundle");
39
+ writeFileSync(file, "bundle");
40
+
41
+ expect(resolveStagedImportPath(file, workspace)).toBe(realpathSync(file));
42
+ });
43
+
44
+ test("rejects a missing file", () => {
45
+ const { workspace } = stagingWorkspace();
46
+ expect(() =>
47
+ resolveStagedImportPath(
48
+ `${RESTORE_STAGING_DIRNAME}/missing.vbundle`,
49
+ workspace,
50
+ ),
51
+ ).toThrow(StagedImportPathError);
52
+ });
53
+
54
+ test("rejects traversal out of the staging directory", () => {
55
+ const { workspace } = stagingWorkspace();
56
+ writeFileSync(join(workspace, "secret.vbundle"), "nope");
57
+
58
+ expect(() =>
59
+ resolveStagedImportPath(
60
+ `${RESTORE_STAGING_DIRNAME}/../secret.vbundle`,
61
+ workspace,
62
+ ),
63
+ ).toThrow(StagedImportPathError);
64
+ });
65
+
66
+ test("rejects a path outside the workspace", () => {
67
+ const { workspace } = stagingWorkspace();
68
+ const outside = join(tmpdir(), `outside-${Date.now()}.vbundle`);
69
+ writeFileSync(outside, "nope");
70
+
71
+ expect(() => resolveStagedImportPath(outside, workspace)).toThrow(
72
+ StagedImportPathError,
73
+ );
74
+ });
75
+
76
+ test("rejects a symlink even when it points at a staging file", () => {
77
+ const { workspace, staging } = stagingWorkspace();
78
+ const file = join(staging, "backup.vbundle");
79
+ writeFileSync(file, "bundle");
80
+ const link = join(staging, "alias.vbundle");
81
+ symlinkSync(file, link);
82
+
83
+ expect(() => resolveStagedImportPath(link, workspace)).toThrow(
84
+ StagedImportPathError,
85
+ );
86
+ });
87
+
88
+ test("rejects a non-.vbundle file in staging", () => {
89
+ const { workspace, staging } = stagingWorkspace();
90
+ const file = join(staging, "notes.txt");
91
+ writeFileSync(file, "nope");
92
+
93
+ expect(() =>
94
+ resolveStagedImportPath(`${RESTORE_STAGING_DIRNAME}/notes.txt`, workspace),
95
+ ).toThrow(StagedImportPathError);
96
+ });
97
+
98
+ test("rejects an empty path", () => {
99
+ const { workspace } = stagingWorkspace();
100
+ expect(() => resolveStagedImportPath(" ", workspace)).toThrow(
101
+ StagedImportPathError,
102
+ );
103
+ });
104
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Resolve a caller-supplied staged-bundle path for local restore.
3
+ *
4
+ * The daemon only opens files that realpath into
5
+ * `${workspaceDir}/.restore-staging/`. Absolute host paths, `..`
6
+ * traversal, and symlink escapes are rejected.
7
+ */
8
+
9
+ import { existsSync, lstatSync, realpathSync, statSync } from "node:fs";
10
+ import { relative, resolve, sep } from "node:path";
11
+
12
+ export const RESTORE_STAGING_DIRNAME = ".restore-staging";
13
+
14
+ export type StagedImportPathErrorCode =
15
+ | "empty"
16
+ | "not_found"
17
+ | "not_file"
18
+ | "symlink"
19
+ | "outside"
20
+ | "extension";
21
+
22
+ export class StagedImportPathError extends Error {
23
+ public readonly code: StagedImportPathErrorCode;
24
+
25
+ constructor(code: StagedImportPathErrorCode, message: string) {
26
+ super(message);
27
+ this.name = "StagedImportPathError";
28
+ this.code = code;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Resolve `requestedPath` to a regular `.vbundle` file inside the
34
+ * workspace restore-staging directory.
35
+ */
36
+ export function resolveStagedImportPath(
37
+ requestedPath: string,
38
+ workspaceDir: string,
39
+ ): string {
40
+ if (typeof requestedPath !== "string" || requestedPath.trim().length === 0) {
41
+ throw new StagedImportPathError("empty", "Staged bundle path is empty");
42
+ }
43
+ if (requestedPath.includes("\0")) {
44
+ throw new StagedImportPathError(
45
+ "empty",
46
+ "Staged bundle path is not a valid file path",
47
+ );
48
+ }
49
+
50
+ if (!existsSync(workspaceDir)) {
51
+ throw new StagedImportPathError(
52
+ "not_found",
53
+ "Staged bundle file not found",
54
+ );
55
+ }
56
+
57
+ const workspaceReal = realpathSync(workspaceDir);
58
+ const stagingRoot = resolve(workspaceReal, RESTORE_STAGING_DIRNAME);
59
+ const candidate = requestedPath.startsWith("/")
60
+ ? resolve(requestedPath)
61
+ : resolve(workspaceReal, requestedPath);
62
+
63
+ if (!existsSync(candidate)) {
64
+ throw new StagedImportPathError(
65
+ "not_found",
66
+ "Staged bundle file not found",
67
+ );
68
+ }
69
+
70
+ const linkStat = lstatSync(candidate);
71
+ if (linkStat.isSymbolicLink()) {
72
+ throw new StagedImportPathError(
73
+ "symlink",
74
+ "Staged bundle path must not be a symlink",
75
+ );
76
+ }
77
+
78
+ const realFile = realpathSync(candidate);
79
+ if (!existsSync(stagingRoot)) {
80
+ throw new StagedImportPathError(
81
+ "outside",
82
+ "Staged bundle path is not inside the restore staging directory",
83
+ );
84
+ }
85
+
86
+ const realStaging = realpathSync(stagingRoot);
87
+ const rel = relative(realStaging, realFile);
88
+ if (
89
+ rel === "" ||
90
+ rel.startsWith(`..${sep}`) ||
91
+ rel === ".." ||
92
+ rel.split(sep).includes("..")
93
+ ) {
94
+ throw new StagedImportPathError(
95
+ "outside",
96
+ "Staged bundle path is not inside the restore staging directory",
97
+ );
98
+ }
99
+
100
+ const fileStat = statSync(realFile);
101
+ if (!fileStat.isFile()) {
102
+ throw new StagedImportPathError(
103
+ "not_file",
104
+ "Staged bundle path is not a file",
105
+ );
106
+ }
107
+
108
+ if (!realFile.endsWith(".vbundle")) {
109
+ throw new StagedImportPathError(
110
+ "extension",
111
+ "Staged bundle must be a .vbundle file",
112
+ );
113
+ }
114
+
115
+ return realFile;
116
+ }
@@ -7,9 +7,11 @@
7
7
  * POST /v1/migrations/import — commit a .vbundle archive import to disk.
8
8
  *
9
9
  * Accepts raw binary body (Content-Type: application/octet-stream),
10
- * multipart form data with a "file" field, or — on /import only — a JSON
11
- * body of shape `{ "url": "<signed-gcs-url>" }` that causes the daemon to
12
- * fetch the bundle from GCS and stream it through `streamCommitImport`.
10
+ * multipart form data with a "file" field, or a JSON body of shape
11
+ * `{ "url": "<signed-gcs-url>" }` (import only) or `{ "path": "<staged>" }`
12
+ * (import and import-preflight). The path form streams a file the CLI
13
+ * staged under `${workspace}/.restore-staging/`. The URL form fetches the
14
+ * bundle from GCS and streams it through `streamCommitImport`.
13
15
  * Returns structured validation results with is_valid flag and detailed
14
16
  * error descriptions.
15
17
  */
@@ -58,6 +60,10 @@ import {
58
60
  migrationJobs,
59
61
  } from "../migrations/job-registry.js";
60
62
  import { getOriginMode } from "../migrations/origin-mode.js";
63
+ import {
64
+ resolveStagedImportPath,
65
+ StagedImportPathError,
66
+ } from "../migrations/staged-import-path.js";
61
67
  import type {
62
68
  VBundleAssistantInfo,
63
69
  VBundleCompatibility,
@@ -81,6 +87,11 @@ import {
81
87
  type ImportCommitResult,
82
88
  } from "../migrations/vbundle-importer.js";
83
89
  import { streamCommitImport } from "../migrations/vbundle-streaming-importer.js";
90
+ import {
91
+ readAndValidateManifest,
92
+ StreamingValidationError,
93
+ } from "../migrations/vbundle-streaming-validator.js";
94
+ import { parseVBundleStream } from "../migrations/vbundle-tar-stream.js";
84
95
  import { validateVBundle } from "../migrations/vbundle-validator.js";
85
96
  import {
86
97
  BadGatewayError,
@@ -806,6 +817,8 @@ async function extractFileData(
806
817
  * The file can be sent as:
807
818
  * - Raw binary body with Content-Type: application/octet-stream
808
819
  * - Multipart form data with a "file" field
820
+ * - JSON body `{ "path": "<staged-relative-or-absolute>" }` pointing at a
821
+ * `.vbundle` the caller placed under `${workspace}/.restore-staging/`
809
822
  *
810
823
  * Returns:
811
824
  * 200: {
@@ -825,7 +838,13 @@ async function extractFileData(
825
838
  export async function handleMigrationImportPreflight({
826
839
  rawBody,
827
840
  headers,
841
+ body,
828
842
  }: RouteHandlerArgs) {
843
+ const contentType = headers?.["content-type"] ?? "";
844
+ if (contentType.includes("application/json")) {
845
+ return handleMigrationPreflightFromPath(body);
846
+ }
847
+
829
848
  const fileData = await extractFileData(rawBody, headers);
830
849
 
831
850
  try {
@@ -872,15 +891,18 @@ export async function handleMigrationImportPreflight({
872
891
  * 5. Verifies post-write integrity (SHA-256 check)
873
892
  * 6. Returns a detailed report of what was imported
874
893
  *
875
- * The bundle can be supplied in any of three ways:
894
+ * The bundle can be supplied as:
876
895
  * - Raw binary body with Content-Type: application/octet-stream
877
896
  * - Multipart form data with a "file" field
878
897
  * - JSON body `{ "url": "<signed-gcs-url>" }` (Content-Type:
879
898
  * application/json). The daemon fetches and streams the archive
880
899
  * through `streamCommitImport`, so peak memory stays bounded by a
881
900
  * single tar entry rather than bundle size.
901
+ * - JSON body `{ "path": "<staged>" }` pointing at a `.vbundle` the
902
+ * caller placed under `${workspace}/.restore-staging/`. The daemon
903
+ * streams that file through `streamCommitImport`.
882
904
  *
883
- * Returns (all three paths):
905
+ * Returns:
884
906
  * 200: {
885
907
  * success: true,
886
908
  * summary: { total_files, files_created, files_overwritten, files_skipped, backups_created },
@@ -900,11 +922,10 @@ export async function handleMigrationImport(
900
922
  args: RouteHandlerArgs,
901
923
  ): Promise<unknown> {
902
924
  const { body, rawBody, headers } = args;
903
- // JSON body means the caller is asking us to fetch the bundle from a
904
- // signed URL and stream it through the importer.
925
+ // JSON body is either a signed URL fetch or a staged local-file stream.
905
926
  const contentType = headers?.["content-type"] ?? "";
906
927
  if (contentType.includes("application/json")) {
907
- return handleMigrationImportFromUrl(body);
928
+ return handleMigrationImportFromJson(body);
908
929
  }
909
930
 
910
931
  const fileData = await extractFileData(rawBody, headers);
@@ -1015,6 +1036,8 @@ const URL_FETCH_TIMEOUT_MS = 60 * 60 * 1000;
1015
1036
 
1016
1037
  const MigrationImportUrlBody = z.object({ url: z.string().min(1) });
1017
1038
 
1039
+ const MigrationImportPathBody = z.object({ path: z.string().min(1) });
1040
+
1018
1041
  const MigrationImportFromGcsBody = z.object({ bundle_url: z.string().url() });
1019
1042
 
1020
1043
  /**
@@ -1591,6 +1614,174 @@ async function runGcsImport(
1591
1614
  : { ...result.report };
1592
1615
  }
1593
1616
 
1617
+ /**
1618
+ * Dispatch a JSON import body to the URL-fetch or staged-path streamer.
1619
+ * The body must include exactly one of `url` or `path`.
1620
+ */
1621
+ async function handleMigrationImportFromJson(
1622
+ body: Record<string, unknown> | undefined,
1623
+ ): Promise<unknown> {
1624
+ const hasUrl = typeof body?.url === "string";
1625
+ const hasPath = typeof body?.path === "string";
1626
+ if (hasUrl && hasPath) {
1627
+ throw new BadRequestError(
1628
+ "Request body must include exactly one of url or path",
1629
+ );
1630
+ }
1631
+ if (hasPath) {
1632
+ return handleMigrationImportFromPath(body);
1633
+ }
1634
+ return handleMigrationImportFromUrl(body);
1635
+ }
1636
+
1637
+ async function handleMigrationImportFromPath(
1638
+ body: Record<string, unknown> | undefined,
1639
+ ): Promise<unknown> {
1640
+ const parsed = MigrationImportPathBody.safeParse(body);
1641
+ if (!parsed.success) {
1642
+ throw new BadRequestError(
1643
+ "Request body must be { path: string } with a non-empty path",
1644
+ );
1645
+ }
1646
+
1647
+ let resolvedPath: string;
1648
+ try {
1649
+ resolvedPath = resolveStagedImportPath(parsed.data.path, getWorkspaceDir());
1650
+ } catch (err) {
1651
+ if (err instanceof StagedImportPathError) {
1652
+ throw new BadRequestError(err.message);
1653
+ }
1654
+ throw err;
1655
+ }
1656
+
1657
+ const source = createReadStream(resolvedPath);
1658
+ const pathResolver = new DefaultPathResolver(
1659
+ getWorkspaceDir(),
1660
+ getWorkspaceHooksDir(),
1661
+ );
1662
+ let credentialsImported: CredentialImportSummary | undefined;
1663
+ const credentialImportWarningSink: CredentialWarningSink = { warnings: [] };
1664
+
1665
+ try {
1666
+ const result = await streamCommitImport({
1667
+ source,
1668
+ pathResolver,
1669
+ workspaceDir: getWorkspaceDir(),
1670
+ importCredentials: async (bundleCredentials) => {
1671
+ credentialsImported = await importBundleCredentialsIntoCes(
1672
+ bundleCredentials,
1673
+ credentialImportWarningSink,
1674
+ );
1675
+ },
1676
+ });
1677
+
1678
+ if (!result.ok) {
1679
+ throwImportCommitFailure(result);
1680
+ }
1681
+
1682
+ if (credentialImportWarningSink.warnings.length > 0) {
1683
+ result.report.warnings.push(...credentialImportWarningSink.warnings);
1684
+ }
1685
+
1686
+ await reconcileVellumMetadataFromCes(result.report);
1687
+ appendNewerMigrationWarningsIfAny(result.report);
1688
+ return importCommitSuccessResult(result.report, credentialsImported);
1689
+ } catch (err) {
1690
+ if (err instanceof RouteError) {
1691
+ throw err;
1692
+ }
1693
+ log.error({ err }, "Unexpected error during staged-path import");
1694
+ throw new InternalError(
1695
+ err instanceof Error ? err.message : "Unexpected import error",
1696
+ );
1697
+ } finally {
1698
+ source.destroy();
1699
+ }
1700
+ }
1701
+
1702
+ async function handleMigrationPreflightFromPath(
1703
+ body: Record<string, unknown> | undefined,
1704
+ ): Promise<unknown> {
1705
+ const parsed = MigrationImportPathBody.safeParse(body);
1706
+ if (!parsed.success) {
1707
+ throw new BadRequestError(
1708
+ "Request body must be { path: string } with a non-empty path",
1709
+ );
1710
+ }
1711
+
1712
+ let resolvedPath: string;
1713
+ try {
1714
+ resolvedPath = resolveStagedImportPath(parsed.data.path, getWorkspaceDir());
1715
+ } catch (err) {
1716
+ if (err instanceof StagedImportPathError) {
1717
+ throw new BadRequestError(err.message);
1718
+ }
1719
+ throw err;
1720
+ }
1721
+
1722
+ const source = createReadStream(resolvedPath);
1723
+ try {
1724
+ const entries = parseVBundleStream(source);
1725
+ const first = await entries.next();
1726
+ if (first.done) {
1727
+ return {
1728
+ can_import: false,
1729
+ validation: {
1730
+ is_valid: false as const,
1731
+ errors: [
1732
+ {
1733
+ code: "empty_archive",
1734
+ message: "Bundle archive is empty",
1735
+ },
1736
+ ],
1737
+ },
1738
+ };
1739
+ }
1740
+
1741
+ let manifest;
1742
+ try {
1743
+ ({ manifest } = await readAndValidateManifest(first.value));
1744
+ } catch (err) {
1745
+ if (err instanceof StreamingValidationError) {
1746
+ return {
1747
+ can_import: false,
1748
+ validation: {
1749
+ is_valid: false as const,
1750
+ errors: [
1751
+ {
1752
+ code: err.code,
1753
+ message: err.message,
1754
+ ...(err.archivePath !== undefined && { path: err.archivePath }),
1755
+ },
1756
+ ],
1757
+ },
1758
+ };
1759
+ }
1760
+ throw err;
1761
+ }
1762
+
1763
+ for await (const entry of entries) {
1764
+ entry.body.resume();
1765
+ }
1766
+
1767
+ const pathResolver = new DefaultPathResolver(
1768
+ getWorkspaceDir(),
1769
+ getWorkspaceHooksDir(),
1770
+ );
1771
+ return analyzeImport({ manifest, pathResolver });
1772
+ } catch (err) {
1773
+ if (err instanceof RouteError) {
1774
+ throw err;
1775
+ }
1776
+ log.error({ err }, "Unexpected error during staged-path preflight");
1777
+ throw new InternalError(
1778
+ err instanceof Error ? err.message : "Unexpected import preflight error",
1779
+ );
1780
+ } finally {
1781
+ source.destroy();
1782
+ }
1783
+ }
1784
+
1594
1785
  /**
1595
1786
  * Handle a JSON `{ "url": "..." }` body on POST /v1/migrations/import.
1596
1787
  *
@@ -2142,8 +2333,17 @@ export const ROUTES: RouteDefinition[] = [
2142
2333
  },
2143
2334
  summary: "Dry-run import analysis",
2144
2335
  description:
2145
- "Validate a .vbundle archive and return a report of what would change on import without modifying data.",
2336
+ "Validate a .vbundle archive and return a report of what would change on import without modifying data. Accepts raw bytes, multipart form data, or JSON `{ path }` pointing at a file staged under the workspace `.restore-staging` directory.",
2146
2337
  tags: ["migrations"],
2338
+ requestBody: z.object({
2339
+ path: z
2340
+ .string()
2341
+ .min(1)
2342
+ .optional()
2343
+ .describe(
2344
+ "Workspace-relative or absolute path to a staged .vbundle under .restore-staging (JSON body path only).",
2345
+ ),
2346
+ }),
2147
2347
  responseBody: z.object({
2148
2348
  can_import: z.boolean(),
2149
2349
  summary: z.object({}).passthrough(),
@@ -2163,15 +2363,23 @@ export const ROUTES: RouteDefinition[] = [
2163
2363
  },
2164
2364
  summary: "Import a .vbundle archive",
2165
2365
  description:
2166
- "Commit a .vbundle archive import to disk — destructive. Accepts the bundle as raw bytes (application/octet-stream), multipart/form-data, or a JSON body with `{ url }` carrying a signed URL the daemon fetches.",
2366
+ "Commit a .vbundle archive import to disk — destructive. Accepts the bundle as raw bytes (application/octet-stream), multipart/form-data, a JSON body with `{ url }` carrying a signed URL the daemon fetches, or a JSON body with `{ path }` pointing at a file staged under the workspace `.restore-staging` directory.",
2167
2367
  tags: ["migrations"],
2168
2368
  requestBody: z.object({
2169
2369
  url: z
2170
2370
  .string()
2171
2371
  .url()
2372
+ .optional()
2172
2373
  .describe(
2173
2374
  "A signed GCS URL pointing to the .vbundle archive (JSON body path only).",
2174
2375
  ),
2376
+ path: z
2377
+ .string()
2378
+ .min(1)
2379
+ .optional()
2380
+ .describe(
2381
+ "Workspace-relative or absolute path to a staged .vbundle under .restore-staging (JSON body path only).",
2382
+ ),
2175
2383
  }),
2176
2384
  additionalResponses: {
2177
2385
  "502": {
@@ -0,0 +1,108 @@
1
+ import { describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ liveVoiceEndScreen,
5
+ liveVoiceSilenceReason,
6
+ } from "../live-voice-funnel.js";
7
+
8
+ describe("liveVoiceSilenceReason", () => {
9
+ it("blames the transport when the session never went active", () => {
10
+ // Nothing after `ready` can be held against a session that never got
11
+ // there: the user had no chance to speak.
12
+ expect(
13
+ liveVoiceSilenceReason({
14
+ reachedActive: false,
15
+ receivedAudio: false,
16
+ detectedSpeech: false,
17
+ }),
18
+ ).toBe("no_ready");
19
+ });
20
+
21
+ it("blames the transport even if stray audio arrived first", () => {
22
+ // Audio can land before the preflight resolves. The session still never
23
+ // became usable, and reporting that as a microphone problem would send
24
+ // someone looking in the wrong layer.
25
+ expect(
26
+ liveVoiceSilenceReason({
27
+ reachedActive: false,
28
+ receivedAudio: true,
29
+ detectedSpeech: true,
30
+ }),
31
+ ).toBe("no_ready");
32
+ });
33
+
34
+ it("reports a microphone that never opened", () => {
35
+ expect(
36
+ liveVoiceSilenceReason({
37
+ reachedActive: true,
38
+ receivedAudio: false,
39
+ detectedSpeech: false,
40
+ }),
41
+ ).toBe("no_audio");
42
+ });
43
+
44
+ it("separates a mic that opened but carried no speech", () => {
45
+ // The distinction that makes this taxonomy worth having: a denied
46
+ // permission and a muted mic both produce a silent session, and only this
47
+ // pair of flags tells them apart.
48
+ expect(
49
+ liveVoiceSilenceReason({
50
+ reachedActive: true,
51
+ receivedAudio: true,
52
+ detectedSpeech: false,
53
+ }),
54
+ ).toBe("no_speech");
55
+ });
56
+
57
+ it("reports speech that never became a turn", () => {
58
+ expect(
59
+ liveVoiceSilenceReason({
60
+ reachedActive: true,
61
+ receivedAudio: true,
62
+ detectedSpeech: true,
63
+ }),
64
+ ).toBe("no_turn");
65
+ });
66
+ });
67
+
68
+ describe("liveVoiceEndScreen", () => {
69
+ it("carries the close reason alone when nothing else applies", () => {
70
+ expect(liveVoiceEndScreen("client_end")).toBe("ended_client_end");
71
+ });
72
+
73
+ it("carries a failure code in the detail slot", () => {
74
+ expect(liveVoiceEndScreen("error", "invalid_field")).toBe(
75
+ "ended_error:invalid_field",
76
+ );
77
+ });
78
+
79
+ it("carries a silence reason when the session merely produced nothing", () => {
80
+ expect(liveVoiceEndScreen("client_end", null, "no_audio")).toBe(
81
+ "ended_client_end:silent_no_audio",
82
+ );
83
+ });
84
+
85
+ it("prefers the failure code when a session both failed and was silent", () => {
86
+ // A session that died on an error is explained by the error; its silence
87
+ // is a consequence, not a second finding.
88
+ expect(liveVoiceEndScreen("error", "invalid_field", "no_audio")).toBe(
89
+ "ended_error:invalid_field",
90
+ );
91
+ });
92
+
93
+ it("keeps every combination inside the wire field's 64-char bound", () => {
94
+ // `screen` is capped at 64 chars by the ingest serializer and an over-long
95
+ // value is dropped silently, taking the whole row with it.
96
+ const longest = liveVoiceEndScreen("transport_closed", null, "no_speech");
97
+ expect(longest.length).toBeLessThanOrEqual(64);
98
+ });
99
+
100
+ it("prefixes the silence value so it reads correctly in a mislabelled column", () => {
101
+ // It shares the detail slot with failure codes, and the admin dashboard
102
+ // renders that slot under a "failure code" header until it learns the
103
+ // difference. The prefix keeps the value honest meanwhile.
104
+ expect(liveVoiceEndScreen("client_end", null, "no_turn")).toContain(
105
+ "silent_",
106
+ );
107
+ });
108
+ });
@@ -58,18 +58,85 @@ export type LiveVoiceStepName =
58
58
  export type LiveVoiceSessionOutcome = "completed" | "failed";
59
59
 
60
60
  /**
61
- * The `screen` dimension for an ended session: how it closed, plus the
62
- * protocol error code when a failure is what closed it.
61
+ * How far a session that produced NO turn actually got.
63
62
  *
64
- * Both halves are the daemon's own vocabulary verbatim
65
- * (`LiveVoiceSessionCloseReason`, `LiveVoiceProtocolErrorCode`) rather than a
66
- * mapping, so a rename on either side surfaces here as a compile error instead
67
- * of a silently-empty dashboard facet. The longest pair this can produce is
68
- * well inside the wire field's 64-char bound.
63
+ * A quarter of live-voice sessions end without a single turn, at a median of a
64
+ * few seconds. That is the closest thing the telemetry has to a "voice didn't
65
+ * work for me" rate, and a bare connect/close row cannot separate a denied
66
+ * microphone from a muted one from someone deliberately backing out. Those have
67
+ * completely different fixes, so the reason is what makes the rate actionable.
68
+ *
69
+ * The four values are ordered by how far the session got, and each one narrows
70
+ * the cause to a different layer:
71
+ *
72
+ * - `no_ready` never reached `active`: died in the credential preflight or
73
+ * the transport, before the user could have said anything.
74
+ * - `no_audio` reached `active` but not one audio chunk ever arrived, so the
75
+ * microphone never opened. Permission denial looks like this.
76
+ * - `no_speech` audio arrived but the detector never classified any of it as
77
+ * speech: a muted or dead mic, or a genuinely silent user.
78
+ * - `no_turn` speech was detected but no turn was ever dispatched, so the
79
+ * utterance was abandoned, aborted, or failed to arm.
80
+ */
81
+ export type LiveVoiceSilenceReason =
82
+ | "no_ready"
83
+ | "no_audio"
84
+ | "no_speech"
85
+ | "no_turn";
86
+
87
+ /**
88
+ * Classify a zero-turn session from what the session observed. Call only when
89
+ * the session really produced no turn: every branch here asserts silence, so a
90
+ * session that did dispatch a turn would be mislabelled by the last one.
91
+ */
92
+ export function liveVoiceSilenceReason(signals: {
93
+ reachedActive: boolean;
94
+ receivedAudio: boolean;
95
+ detectedSpeech: boolean;
96
+ }): LiveVoiceSilenceReason {
97
+ if (!signals.reachedActive) {
98
+ return "no_ready";
99
+ }
100
+ if (!signals.receivedAudio) {
101
+ return "no_audio";
102
+ }
103
+ if (!signals.detectedSpeech) {
104
+ return "no_speech";
105
+ }
106
+ return "no_turn";
107
+ }
108
+
109
+ /**
110
+ * The `screen` dimension for an ended session: how it closed, plus a detail
111
+ * half. That detail is the protocol error code when a failure closed it, or the
112
+ * silence classification when the session produced no turn at all.
113
+ *
114
+ * Every part is the daemon's own vocabulary verbatim
115
+ * (`LiveVoiceSessionCloseReason`, `LiveVoiceProtocolErrorCode`,
116
+ * {@link LiveVoiceSilenceReason}) rather than a mapping, so a rename on any of
117
+ * them surfaces here as a compile error instead of a silently-empty dashboard
118
+ * facet. The longest combination this can produce is well inside the wire
119
+ * field's 64-char bound.
120
+ *
121
+ * A failure code wins over a silence reason when both apply: a session that
122
+ * died on an error is explained by the error, and the silence is a consequence
123
+ * of it rather than a separate finding.
124
+ *
125
+ * The silence value carries a `silent_` prefix on purpose. It shares the detail
126
+ * slot with failure codes, and the admin dashboard currently renders that slot
127
+ * in a column labelled "failure code", so the value has to read correctly even
128
+ * where the column header is wrong, until that panel learns the difference.
69
129
  */
70
130
  export function liveVoiceEndScreen(
71
131
  reason: LiveVoiceSessionCloseReason,
72
132
  failureCode?: LiveVoiceProtocolErrorCode | null,
133
+ silenceReason?: LiveVoiceSilenceReason | null,
73
134
  ): string {
74
- return failureCode ? `ended_${reason}:${failureCode}` : `ended_${reason}`;
135
+ if (failureCode) {
136
+ return `ended_${reason}:${failureCode}`;
137
+ }
138
+ if (silenceReason) {
139
+ return `ended_${reason}:silent_${silenceReason}`;
140
+ }
141
+ return `ended_${reason}`;
75
142
  }