@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 +23 -6
- package/package.json +1 -1
- package/src/__tests__/migration-import-from-path.test.ts +349 -0
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +5 -2
- package/src/live-voice/live-voice-session.ts +52 -2
- package/src/runtime/migrations/__tests__/staged-import-path.test.ts +104 -0
- package/src/runtime/migrations/staged-import-path.ts +116 -0
- package/src/runtime/routes/migration-routes.ts +218 -10
- package/src/telemetry/__tests__/live-voice-funnel.test.ts +108 -0
- package/src/telemetry/live-voice-funnel.ts +75 -8
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,
|
|
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
|
-
|
|
21272
|
-
|
|
21273
|
-
|
|
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:
|
|
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
|
@@ -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 {
|
|
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(
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
*
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
-
|
|
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
|
}
|