@mycadet/assistant 0.12.10 → 0.13.0

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.
Files changed (180) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.yaml +2 -708
  10. package/package.json +2 -3
  11. package/scripts/sync-web-search-catalog.ts +1 -2
  12. package/src/__tests__/cloud-profile-guard.test.ts +41 -5
  13. package/src/__tests__/config-schema.test.ts +6 -38
  14. package/src/__tests__/conversation-queue.test.ts +45 -6
  15. package/src/__tests__/db-readiness-http-gate.test.ts +0 -6
  16. package/src/__tests__/persist-onboarding-artifacts.test.ts +0 -1
  17. package/src/__tests__/persona-resolver.test.ts +0 -51
  18. package/src/__tests__/provider-cloud-proxy-integration.test.ts +2 -110
  19. package/src/__tests__/run-due-schedules.test.ts +0 -4
  20. package/src/__tests__/schedule-store.test.ts +0 -12
  21. package/src/__tests__/stt-stream-session.test.ts +4 -4
  22. package/src/__tests__/telephony-tts-guard.test.ts +2 -50
  23. package/src/__tests__/voice-config-update.test.ts +12 -167
  24. package/src/__tests__/web-search-catalog-parity.test.ts +0 -2
  25. package/src/__tests__/workspace-migration-130-speech-mode-to-provider.test.ts +9 -7
  26. package/src/__tests__/workspace-migration-132-web-search-mode-to-provider.test.ts +6 -6
  27. package/src/calls/__tests__/voice-session-bridge.test.ts +1 -4
  28. package/src/calls/resolve-call-tts-provider.ts +6 -25
  29. package/src/calls/telephony-credential-preflight.ts +0 -10
  30. package/src/calls/telephony-tts-capability.ts +1 -17
  31. package/src/cli/commands/tts.ts +1 -2
  32. package/src/cli/index.help.ts +0 -2
  33. package/src/cli/program.ts +0 -2
  34. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +4 -42
  35. package/src/config/schemas/__tests__/stt.test.ts +10 -14
  36. package/src/config/schemas/llm.ts +3 -5
  37. package/src/config/schemas/services.ts +5 -6
  38. package/src/config/schemas/stt.ts +1 -3
  39. package/src/config/schemas/tts.ts +1 -30
  40. package/src/daemon/daemon-readiness.ts +4 -29
  41. package/src/daemon/lifecycle.ts +0 -31
  42. package/src/daemon/providers-setup.ts +0 -8
  43. package/src/heartbeat/__tests__/heartbeat-service.test.ts +0 -1
  44. package/src/heartbeat/heartbeat-service.ts +1 -66
  45. package/src/ipc/__tests__/db-readiness-ipc-gate.test.ts +1 -9
  46. package/src/live-voice/__tests__/live-voice-credential-preflight.test.ts +0 -44
  47. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +4 -6
  48. package/src/live-voice/__tests__/live-voice-stt.test.ts +1 -1
  49. package/src/live-voice/live-voice-credential-preflight.ts +9 -33
  50. package/src/live-voice/live-voice-session.ts +10 -6
  51. package/src/live-voice/live-voice-tts.ts +1 -8
  52. package/src/plugins/defaults/memory/worker.ts +3 -3
  53. package/src/prompts/persona-resolver.ts +0 -35
  54. package/src/providers/__tests__/search-provider-catalog.test.ts +5 -8
  55. package/src/providers/__tests__/voice-error-copy.test.ts +9 -10
  56. package/src/providers/search-provider-catalog.ts +7 -39
  57. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +3 -47
  58. package/src/providers/speech-to-text/__tests__/resolve.test.ts +6 -372
  59. package/src/providers/speech-to-text/provider-catalog.ts +2 -73
  60. package/src/providers/speech-to-text/resolve.ts +3 -120
  61. package/src/runtime/http-router-types.ts +1 -3
  62. package/src/runtime/http-server.ts +1 -1
  63. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +13 -11
  64. package/src/runtime/routes/__tests__/tts-routes.test.ts +2 -6
  65. package/src/runtime/routes/conversation-query-routes.ts +0 -96
  66. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +7 -7
  67. package/src/runtime/routes/index.ts +0 -4
  68. package/src/runtime/routes/live-voice-routes.test.ts +4 -11
  69. package/src/runtime/routes/live-voice-routes.ts +8 -11
  70. package/src/runtime/routes/secret-routes.ts +0 -6
  71. package/src/runtime/routes/tts-routes.ts +0 -38
  72. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +1 -7
  73. package/src/schedule/__tests__/worker-mcp-readiness.test.ts +0 -4
  74. package/src/schedule/schedule-store.ts +0 -7
  75. package/src/schedule/scheduler.ts +0 -5
  76. package/src/schedule/worker.ts +1 -1
  77. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +2 -10
  78. package/src/stt/daemon-batch-transcriber.ts +4 -38
  79. package/src/stt/types.ts +1 -9
  80. package/src/tools/network/__tests__/web-search.test.ts +3 -250
  81. package/src/tools/network/web-search.ts +4 -165
  82. package/src/tts/__tests__/provider-resolution.test.ts +0 -5
  83. package/src/tts/provider-catalog.ts +0 -4
  84. package/src/tts/types.ts +0 -1
  85. package/src/__tests__/cloud-speech-defaults.test.ts +0 -330
  86. package/src/__tests__/migration-cross-version-compatibility.test.ts +0 -1708
  87. package/src/__tests__/migration-export-http.test.ts +0 -557
  88. package/src/__tests__/migration-export-streaming.test.ts +0 -350
  89. package/src/__tests__/migration-export-to-gcs.test.ts +0 -706
  90. package/src/__tests__/migration-import-commit-http.test.ts +0 -1176
  91. package/src/__tests__/migration-import-from-gcs.test.ts +0 -534
  92. package/src/__tests__/migration-import-from-path.test.ts +0 -324
  93. package/src/__tests__/migration-import-from-url.test.ts +0 -588
  94. package/src/__tests__/migration-import-preflight-http.test.ts +0 -739
  95. package/src/__tests__/migration-jobs-status.test.ts +0 -139
  96. package/src/__tests__/migration-parity-persistence.test.ts +0 -1891
  97. package/src/__tests__/migration-transport.test.ts +0 -1089
  98. package/src/__tests__/migration-validate-http.test.ts +0 -634
  99. package/src/__tests__/migration-wizard.test.ts +0 -1395
  100. package/src/__tests__/rebind-secrets-screen.test.ts +0 -876
  101. package/src/__tests__/sanitize-config-for-transfer.test.ts +0 -259
  102. package/src/__tests__/transfer-progress-screen.test.ts +0 -1217
  103. package/src/__tests__/validation-results-screen.test.ts +0 -1130
  104. package/src/__tests__/vbundle-pax-and-symlink.test.ts +0 -147
  105. package/src/background-wake/background-wake-routes.test.ts +0 -906
  106. package/src/background-wake/cloud-client.test.ts +0 -328
  107. package/src/background-wake/cloud-client.ts +0 -196
  108. package/src/background-wake/next-wake.test.ts +0 -335
  109. package/src/background-wake/next-wake.ts +0 -188
  110. package/src/background-wake/publisher.ts +0 -93
  111. package/src/background-wake/wake-intent-hooks.test.ts +0 -234
  112. package/src/cli/commands/__tests__/backup.test.ts +0 -363
  113. package/src/cli/commands/backup.help.ts +0 -180
  114. package/src/cli/commands/backup.ts +0 -398
  115. package/src/cloud/cloud-speech.test.ts +0 -335
  116. package/src/cloud/cloud-speech.ts +0 -300
  117. package/src/config/cloud-speech-defaults.ts +0 -300
  118. package/src/config/sanitize-for-transfer.ts +0 -111
  119. package/src/live-voice/__tests__/live-voice-cloud-speech-fallback.test.ts +0 -250
  120. package/src/providers/speech-to-text/__tests__/cadet-cloud-realtime.test.ts +0 -457
  121. package/src/providers/speech-to-text/__tests__/cadet-cloud.test.ts +0 -271
  122. package/src/providers/speech-to-text/__tests__/cadet-speech-relay-connection.test.ts +0 -126
  123. package/src/providers/speech-to-text/cadet-cloud-flux-realtime.ts +0 -309
  124. package/src/providers/speech-to-text/cadet-cloud-realtime.ts +0 -315
  125. package/src/providers/speech-to-text/cadet-cloud.ts +0 -108
  126. package/src/providers/speech-to-text/cadet-speech-relay-connection.ts +0 -176
  127. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +0 -254
  128. package/src/runtime/migrations/__tests__/job-registry.test.ts +0 -346
  129. package/src/runtime/migrations/__tests__/origin-mode.test.ts +0 -62
  130. package/src/runtime/migrations/__tests__/rebind-secrets-credentials.test.ts +0 -184
  131. package/src/runtime/migrations/__tests__/staged-import-path.test.ts +0 -110
  132. package/src/runtime/migrations/__tests__/v1-test-helpers.ts +0 -112
  133. package/src/runtime/migrations/__tests__/vbundle-builder-credentials.test.ts +0 -285
  134. package/src/runtime/migrations/__tests__/vbundle-builder-fd-leak.test.ts +0 -126
  135. package/src/runtime/migrations/__tests__/vbundle-builder-v1-shape.test.ts +0 -253
  136. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +0 -208
  137. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +0 -485
  138. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +0 -349
  139. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +0 -196
  140. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +0 -551
  141. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +0 -428
  142. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +0 -220
  143. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +0 -1823
  144. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +0 -598
  145. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +0 -463
  146. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -538
  147. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +0 -515
  148. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +0 -453
  149. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +0 -319
  150. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +0 -240
  151. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +0 -473
  152. package/src/runtime/migrations/gcs-signed-url.ts +0 -190
  153. package/src/runtime/migrations/job-registry.ts +0 -297
  154. package/src/runtime/migrations/migration-transport.ts +0 -612
  155. package/src/runtime/migrations/migration-wizard.ts +0 -747
  156. package/src/runtime/migrations/origin-mode.ts +0 -42
  157. package/src/runtime/migrations/rebind-secrets-screen.ts +0 -412
  158. package/src/runtime/migrations/staged-import-path.ts +0 -116
  159. package/src/runtime/migrations/transfer-progress-screen.ts +0 -323
  160. package/src/runtime/migrations/validation-results-screen.ts +0 -469
  161. package/src/runtime/migrations/vbundle-builder.ts +0 -1382
  162. package/src/runtime/migrations/vbundle-import-analyzer.ts +0 -405
  163. package/src/runtime/migrations/vbundle-import-policy.ts +0 -241
  164. package/src/runtime/migrations/vbundle-importer.ts +0 -826
  165. package/src/runtime/migrations/vbundle-metadata-merge.ts +0 -113
  166. package/src/runtime/migrations/vbundle-streaming-importer.ts +0 -2844
  167. package/src/runtime/migrations/vbundle-streaming-validator.ts +0 -426
  168. package/src/runtime/migrations/vbundle-tar-stream.ts +0 -244
  169. package/src/runtime/migrations/vbundle-validator.ts +0 -724
  170. package/src/runtime/routes/__tests__/migration-cadet-metadata-reconcile.test.ts +0 -227
  171. package/src/runtime/routes/__tests__/migration-export-secrets-redacted.test.ts +0 -54
  172. package/src/runtime/routes/__tests__/migration-import-allowed-hosts-env.test.ts +0 -70
  173. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +0 -257
  174. package/src/runtime/routes/background-wake-routes.ts +0 -387
  175. package/src/runtime/routes/migration-routes.ts +0 -2584
  176. package/src/tools/network/__tests__/cloud-search-proxy.test.ts +0 -282
  177. package/src/tools/network/cloud-search-proxy.ts +0 -211
  178. package/src/tts/__tests__/cadet-provider.test.ts +0 -253
  179. package/src/tts/providers/cadet-provider.ts +0 -244
  180. package/src/types/tar-stream.d.ts +0 -66
@@ -1,1708 +0,0 @@
1
- /**
2
- * Cross-version compatibility tests and failure diagnostics for migration
3
- * endpoints.
4
- *
5
- * Tests cover:
6
- * - Schema version compatibility: bundles with different schema_version values
7
- * can be validated and imported when otherwise structurally valid
8
- * - Future/unknown schema versions: produce clear, actionable errors when
9
- * structural changes break parsing
10
- * - Missing or malformed version fields: fail with descriptive diagnostics
11
- * - Round-trip: export -> validate -> import-preflight -> import produces
12
- * consistent results
13
- * - Partial failure scenarios: import fails mid-write, write errors surface
14
- * partial reports
15
- * - Edge cases: empty bundles, very large file entries, duplicate paths,
16
- * corrupted checksums, empty file entries
17
- * - Diagnostic quality: every failure mode includes a machine-readable error
18
- * code and a human-readable message
19
- */
20
- import { createHash } from "node:crypto";
21
- import {
22
- mkdirSync,
23
- readdirSync,
24
- readFileSync,
25
- rmSync,
26
- unlinkSync,
27
- writeFileSync,
28
- } from "node:fs";
29
- import { join } from "node:path";
30
- import { gzipSync } from "node:zlib";
31
- import {
32
- afterEach,
33
- beforeAll,
34
- beforeEach,
35
- describe,
36
- expect,
37
- test,
38
- } from "bun:test";
39
-
40
- /** Convert a Uint8Array to an ArrayBuffer for BodyInit compatibility. */
41
- function toArrayBuffer(data: Uint8Array): ArrayBuffer {
42
- return data.buffer.slice(
43
- data.byteOffset,
44
- data.byteOffset + data.byteLength,
45
- ) as ArrayBuffer;
46
- }
47
-
48
- const testDir = process.env.CADET_WORKSPACE_DIR!;
49
- const testDbDir = join(testDir, "data", "db");
50
- const testDbPath = join(testDbDir, "assistant.db");
51
- const testConfigPath = join(testDir, "config.json");
52
-
53
- process.env.DISABLE_HTTP_AUTH = "true";
54
-
55
- import {
56
- buildTestManifest,
57
- defaultV1Options,
58
- } from "../runtime/migrations/__tests__/v1-test-helpers.js";
59
- import { buildVBundle } from "../runtime/migrations/vbundle-builder.js";
60
- import {
61
- analyzeImport,
62
- DefaultPathResolver,
63
- } from "../runtime/migrations/vbundle-import-analyzer.js";
64
- import { commitImport } from "../runtime/migrations/vbundle-importer.js";
65
- import { validateVBundle } from "../runtime/migrations/vbundle-validator.js";
66
- import {
67
- handleMigrationExport,
68
- handleMigrationImport,
69
- handleMigrationImportPreflight,
70
- handleMigrationValidate,
71
- } from "../runtime/routes/migration-routes.js";
72
- import { callHandler } from "./helpers/call-route-handler.js";
73
- // ---------------------------------------------------------------------------
74
- // Test fixture data
75
- // ---------------------------------------------------------------------------
76
-
77
- const EXISTING_DB_DATA = new Uint8Array([
78
- 0x53, 0x51, 0x4c, 0x69, 0x74, 0x65, 0x20, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74,
79
- 0x20, 0x33, 0x00,
80
- ]);
81
- const EXISTING_CONFIG = { provider: "anthropic", model: "test-model" };
82
-
83
- beforeAll(() => {
84
- mkdirSync(testDbDir, { recursive: true });
85
- writeFileSync(testDbPath, EXISTING_DB_DATA);
86
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
87
- });
88
-
89
- // Restore test files before each test
90
- beforeEach(() => {
91
- mkdirSync(testDbDir, { recursive: true });
92
- writeFileSync(testDbPath, EXISTING_DB_DATA);
93
- writeFileSync(testConfigPath, JSON.stringify(EXISTING_CONFIG, null, 2));
94
- });
95
-
96
- // Clean up backup files after each test
97
- afterEach(() => {
98
- try {
99
- for (const dir of [testDbDir, testDir]) {
100
- const entries = readdirSync(dir);
101
- for (const entry of entries) {
102
- if (entry.includes(".backup-")) {
103
- unlinkSync(join(dir, entry));
104
- }
105
- }
106
- }
107
- } catch {
108
- /* best effort */
109
- }
110
- });
111
-
112
- // ---------------------------------------------------------------------------
113
- // Tar archive builder helpers
114
- // ---------------------------------------------------------------------------
115
-
116
- const BLOCK_SIZE = 512;
117
-
118
- function padToBlock(data: Uint8Array): Uint8Array {
119
- const remainder = data.length % BLOCK_SIZE;
120
- if (remainder === 0) {
121
- return data;
122
- }
123
- const padded = new Uint8Array(data.length + (BLOCK_SIZE - remainder));
124
- padded.set(data);
125
- return padded;
126
- }
127
-
128
- function writeOctal(
129
- buf: Uint8Array,
130
- offset: number,
131
- length: number,
132
- value: number,
133
- ): void {
134
- const str = value.toString(8).padStart(length - 1, "0");
135
- for (let i = 0; i < str.length; i++) {
136
- buf[offset + i] = str.charCodeAt(i);
137
- }
138
- buf[offset + length - 1] = 0;
139
- }
140
-
141
- function computeHeaderChecksum(header: Uint8Array): number {
142
- let sum = 0;
143
- for (let i = 0; i < 512; i++) {
144
- if (i >= 148 && i < 156) {
145
- sum += 0x20;
146
- } else {
147
- sum += header[i];
148
- }
149
- }
150
- return sum;
151
- }
152
-
153
- function createTarEntry(name: string, data: Uint8Array): Uint8Array {
154
- const header = new Uint8Array(BLOCK_SIZE);
155
- const encoder = new TextEncoder();
156
-
157
- const nameBytes = encoder.encode(name);
158
- header.set(nameBytes.subarray(0, 100), 0);
159
-
160
- writeOctal(header, 100, 8, 0o644);
161
- writeOctal(header, 108, 8, 0);
162
- writeOctal(header, 116, 8, 0);
163
- writeOctal(header, 124, 12, data.length);
164
- writeOctal(header, 136, 12, Math.floor(Date.now() / 1000));
165
- header[156] = "0".charCodeAt(0);
166
-
167
- const magic = encoder.encode("ustar\0");
168
- header.set(magic, 257);
169
- header[263] = "0".charCodeAt(0);
170
- header[264] = "0".charCodeAt(0);
171
-
172
- const checksum = computeHeaderChecksum(header);
173
- writeOctal(header, 148, 7, checksum);
174
- header[155] = 0x20;
175
-
176
- const paddedData = padToBlock(data);
177
- const result = new Uint8Array(header.length + paddedData.length);
178
- result.set(header, 0);
179
- result.set(paddedData, header.length);
180
- return result;
181
- }
182
-
183
- function createTarArchive(
184
- entries: Array<{ name: string; data: Uint8Array }>,
185
- ): Uint8Array {
186
- const parts: Uint8Array[] = [];
187
- for (const entry of entries) {
188
- parts.push(createTarEntry(entry.name, entry.data));
189
- }
190
- parts.push(new Uint8Array(BLOCK_SIZE * 2));
191
-
192
- const totalLength = parts.reduce((sum, p) => sum + p.length, 0);
193
- const result = new Uint8Array(totalLength);
194
- let offset = 0;
195
- for (const part of parts) {
196
- result.set(part, offset);
197
- offset += part.length;
198
- }
199
- return result;
200
- }
201
-
202
- function sha256Hex(data: Uint8Array | string): string {
203
- return createHash("sha256").update(data).digest("hex");
204
- }
205
-
206
- interface VBundleFile {
207
- path: string;
208
- data: Uint8Array;
209
- }
210
-
211
- function createValidVBundle(
212
- files?: VBundleFile[],
213
- overrides?: Partial<{ schema_version: number }>,
214
- ): Uint8Array {
215
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
216
- const bundleFiles = files ?? [{ path: "data/db/assistant.db", data: dbData }];
217
-
218
- const contents = bundleFiles.map((f) => ({
219
- path: f.path,
220
- sha256: sha256Hex(f.data),
221
- size_bytes: f.data.length,
222
- }));
223
-
224
- const manifest = buildTestManifest({
225
- contents,
226
- overrides: {
227
- bundle_id: "00000000-0000-4000-8000-000000000000",
228
- ...(overrides?.schema_version !== undefined
229
- ? { schema_version: overrides.schema_version }
230
- : {}),
231
- },
232
- });
233
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
234
-
235
- const tarEntries = [
236
- { name: "manifest.json", data: manifestData },
237
- ...bundleFiles.map((f) => ({ name: f.path, data: f.data })),
238
- ];
239
-
240
- const tar = createTarArchive(tarEntries);
241
- return gzipSync(tar);
242
- }
243
-
244
- /**
245
- * Create a vbundle with a raw manifest object (bypassing normal checksumming).
246
- * Useful for testing malformed/unusual manifests.
247
- */
248
- function createVBundleWithRawManifest(
249
- manifestObj: Record<string, unknown>,
250
- archiveFiles: Array<{ name: string; data: Uint8Array }>,
251
- ): Uint8Array {
252
- const manifestData = new TextEncoder().encode(JSON.stringify(manifestObj));
253
- const tarEntries = [
254
- { name: "manifest.json", data: manifestData },
255
- ...archiveFiles,
256
- ];
257
- const tar = createTarArchive(tarEntries);
258
- return gzipSync(tar);
259
- }
260
-
261
- // ---------------------------------------------------------------------------
262
- // Response types
263
- // ---------------------------------------------------------------------------
264
-
265
- interface ValidationResponse {
266
- is_valid: boolean;
267
- errors: Array<{ code: string; message: string; path?: string }>;
268
- manifest?: Record<string, unknown>;
269
- }
270
-
271
- interface ImportDryRunResponse {
272
- can_import: boolean;
273
- summary?: {
274
- total_files: number;
275
- files_to_create: number;
276
- files_to_overwrite: number;
277
- files_unchanged: number;
278
- files_to_skip: number;
279
- };
280
- files?: Array<{
281
- path: string;
282
- action: string;
283
- bundle_size: number;
284
- current_size: number | null;
285
- bundle_sha256: string;
286
- current_sha256: string | null;
287
- }>;
288
- conflicts?: Array<{ code: string; message: string; path?: string }>;
289
- manifest?: Record<string, unknown>;
290
- validation?: {
291
- is_valid: boolean;
292
- errors: Array<{ code: string; message: string; path?: string }>;
293
- };
294
- }
295
-
296
- interface ImportCommitResponse {
297
- success: boolean;
298
- reason?: string;
299
- message?: string;
300
- summary?: {
301
- total_files: number;
302
- files_created: number;
303
- files_overwritten: number;
304
- files_skipped: number;
305
- backups_created: number;
306
- };
307
- files?: Array<{
308
- path: string;
309
- disk_path: string;
310
- action: string;
311
- size: number;
312
- sha256: string;
313
- backup_path: string | null;
314
- }>;
315
- manifest?: Record<string, unknown>;
316
- warnings?: string[];
317
- errors?: Array<{ code: string; message: string; path?: string }>;
318
- partial_report?: ImportCommitResponse;
319
- }
320
-
321
- // ═══════════════════════════════════════════════════════════════════════════
322
- // 1. Schema version compatibility
323
- // ═══════════════════════════════════════════════════════════════════════════
324
-
325
- describe("schema version compatibility", () => {
326
- test("bundle with schema_version 1 validates successfully", () => {
327
- const vbundle = createValidVBundle();
328
- const result = validateVBundle(vbundle);
329
-
330
- expect(result.is_valid).toBe(true);
331
- expect(result.manifest?.schema_version).toBe(1);
332
- });
333
-
334
- test("bundle with schema_version != 1 is rejected by the v1 validator", () => {
335
- // The v1 validator pins `schema_version: z.literal(1)`. Bundles that
336
- // claim a different schema version are rejected outright; this used to
337
- // be permissive when schema_version was a free-form string.
338
- const vbundle = createValidVBundle(undefined, { schema_version: 2 });
339
- const result = validateVBundle(vbundle);
340
-
341
- expect(result.is_valid).toBe(false);
342
- });
343
- });
344
-
345
- // ═══════════════════════════════════════════════════════════════════════════
346
- // 2. Missing or malformed version fields
347
- // ═══════════════════════════════════════════════════════════════════════════
348
-
349
- describe("missing or malformed version fields", () => {
350
- test("missing schema_version produces MANIFEST_SCHEMA_ERROR", () => {
351
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
352
- const manifestObj = {
353
- // schema_version intentionally omitted
354
- created_at: new Date().toISOString(),
355
- files: [
356
- {
357
- path: "data/db/assistant.db",
358
- sha256: sha256Hex(dbData),
359
- size: dbData.length,
360
- },
361
- ],
362
- manifest_sha256: "placeholder",
363
- };
364
-
365
- const vbundle = createVBundleWithRawManifest(manifestObj, [
366
- { name: "data/db/assistant.db", data: dbData },
367
- ]);
368
- const result = validateVBundle(vbundle);
369
-
370
- expect(result.is_valid).toBe(false);
371
- const schemaError = result.errors.find(
372
- (e) => e.code === "MANIFEST_SCHEMA_ERROR",
373
- );
374
- expect(schemaError).toBeDefined();
375
- expect(schemaError!.message).toContain("schema_version");
376
- });
377
-
378
- test("numeric schema_version (instead of string) produces schema error", () => {
379
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
380
- const manifestObj = {
381
- schema_version: 1, // should be a string
382
- created_at: new Date().toISOString(),
383
- files: [
384
- {
385
- path: "data/db/assistant.db",
386
- sha256: sha256Hex(dbData),
387
- size: dbData.length,
388
- },
389
- ],
390
- manifest_sha256: "placeholder",
391
- };
392
-
393
- const vbundle = createVBundleWithRawManifest(manifestObj, [
394
- { name: "data/db/assistant.db", data: dbData },
395
- ]);
396
- const result = validateVBundle(vbundle);
397
-
398
- expect(result.is_valid).toBe(false);
399
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
400
- true,
401
- );
402
- });
403
-
404
- test("null schema_version produces schema error", () => {
405
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
406
- const manifestObj = {
407
- schema_version: null,
408
- created_at: new Date().toISOString(),
409
- files: [
410
- {
411
- path: "data/db/assistant.db",
412
- sha256: sha256Hex(dbData),
413
- size: dbData.length,
414
- },
415
- ],
416
- manifest_sha256: "placeholder",
417
- };
418
-
419
- const vbundle = createVBundleWithRawManifest(manifestObj, [
420
- { name: "data/db/assistant.db", data: dbData },
421
- ]);
422
- const result = validateVBundle(vbundle);
423
-
424
- expect(result.is_valid).toBe(false);
425
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
426
- true,
427
- );
428
- });
429
-
430
- test("zero schema_version is rejected by the v1 validator", () => {
431
- // The v1 schema pins schema_version to the literal `1`; any other
432
- // numeric value (including 0) is rejected.
433
- const vbundle = createValidVBundle(undefined, { schema_version: 0 });
434
- const result = validateVBundle(vbundle);
435
-
436
- expect(result.is_valid).toBe(false);
437
- });
438
-
439
- test("missing created_at produces MANIFEST_SCHEMA_ERROR", () => {
440
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
441
- const manifestObj = {
442
- schema_version: 1,
443
- // created_at intentionally omitted
444
- files: [
445
- {
446
- path: "data/db/assistant.db",
447
- sha256: sha256Hex(dbData),
448
- size: dbData.length,
449
- },
450
- ],
451
- manifest_sha256: "placeholder",
452
- };
453
-
454
- const vbundle = createVBundleWithRawManifest(manifestObj, [
455
- { name: "data/db/assistant.db", data: dbData },
456
- ]);
457
- const result = validateVBundle(vbundle);
458
-
459
- expect(result.is_valid).toBe(false);
460
- // The v1 schema reports errors per missing required field; we just
461
- // assert at least one MANIFEST_SCHEMA_ERROR fires for any of the
462
- // newly-required fields. The legacy assertion looking for "created_at"
463
- // in the message specifically is no longer meaningful — the validator
464
- // surfaces the first missing field it finds, which under the v1
465
- // schema may be `bundle_id`, `assistant`, `origin`, etc.
466
- const error = result.errors.find((e) => e.code === "MANIFEST_SCHEMA_ERROR");
467
- expect(error).toBeDefined();
468
- });
469
-
470
- test("missing files array produces MANIFEST_SCHEMA_ERROR", () => {
471
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
472
- const manifestObj = {
473
- schema_version: 1,
474
- created_at: new Date().toISOString(),
475
- // files intentionally omitted
476
- manifest_sha256: "placeholder",
477
- };
478
-
479
- const vbundle = createVBundleWithRawManifest(manifestObj, [
480
- { name: "data/db/assistant.db", data: dbData },
481
- ]);
482
- const result = validateVBundle(vbundle);
483
-
484
- expect(result.is_valid).toBe(false);
485
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
486
- true,
487
- );
488
- });
489
-
490
- test("missing manifest_sha256 produces MANIFEST_SCHEMA_ERROR", () => {
491
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
492
- const manifestObj = {
493
- schema_version: 1,
494
- created_at: new Date().toISOString(),
495
- files: [
496
- {
497
- path: "data/db/assistant.db",
498
- sha256: sha256Hex(dbData),
499
- size: dbData.length,
500
- },
501
- ],
502
- // manifest_sha256 intentionally omitted
503
- };
504
-
505
- const vbundle = createVBundleWithRawManifest(manifestObj, [
506
- { name: "data/db/assistant.db", data: dbData },
507
- ]);
508
- const result = validateVBundle(vbundle);
509
-
510
- expect(result.is_valid).toBe(false);
511
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
512
- true,
513
- );
514
- });
515
-
516
- test("file entry missing sha256 produces MANIFEST_SCHEMA_ERROR", () => {
517
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
518
- const manifestObj = {
519
- schema_version: 1,
520
- created_at: new Date().toISOString(),
521
- files: [
522
- {
523
- path: "data/db/assistant.db",
524
- // sha256 intentionally omitted
525
- size: dbData.length,
526
- },
527
- ],
528
- manifest_sha256: "placeholder",
529
- };
530
-
531
- const vbundle = createVBundleWithRawManifest(manifestObj, [
532
- { name: "data/db/assistant.db", data: dbData },
533
- ]);
534
- const result = validateVBundle(vbundle);
535
-
536
- expect(result.is_valid).toBe(false);
537
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
538
- true,
539
- );
540
- });
541
-
542
- test("file entry with negative size produces MANIFEST_SCHEMA_ERROR", () => {
543
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
544
- const manifestObj = {
545
- schema_version: 1,
546
- created_at: new Date().toISOString(),
547
- files: [
548
- {
549
- path: "data/db/assistant.db",
550
- sha256: sha256Hex(dbData),
551
- size: -1,
552
- },
553
- ],
554
- manifest_sha256: "placeholder",
555
- };
556
-
557
- const vbundle = createVBundleWithRawManifest(manifestObj, [
558
- { name: "data/db/assistant.db", data: dbData },
559
- ]);
560
- const result = validateVBundle(vbundle);
561
-
562
- expect(result.is_valid).toBe(false);
563
- expect(result.errors.some((e) => e.code === "MANIFEST_SCHEMA_ERROR")).toBe(
564
- true,
565
- );
566
- });
567
-
568
- test("version field error includes descriptive path in diagnostic", () => {
569
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
570
- const manifestObj = {
571
- schema_version: 42, // wrong type
572
- created_at: new Date().toISOString(),
573
- files: [
574
- {
575
- path: "data/db/assistant.db",
576
- sha256: sha256Hex(dbData),
577
- size: dbData.length,
578
- },
579
- ],
580
- manifest_sha256: "placeholder",
581
- };
582
-
583
- const vbundle = createVBundleWithRawManifest(manifestObj, [
584
- { name: "data/db/assistant.db", data: dbData },
585
- ]);
586
- const result = validateVBundle(vbundle);
587
-
588
- expect(result.is_valid).toBe(false);
589
- const error = result.errors.find((e) => e.code === "MANIFEST_SCHEMA_ERROR");
590
- expect(error).toBeDefined();
591
- // The error path should indicate where in the manifest the issue is
592
- expect(error!.path).toBeDefined();
593
- expect(error!.path).toContain("manifest.json");
594
- });
595
- });
596
-
597
- // ═══════════════════════════════════════════════════════════════════════════
598
- // 3. Round-trip: export -> validate -> import-preflight -> import
599
- // ═══════════════════════════════════════════════════════════════════════════
600
-
601
- describe("round-trip: export -> validate -> preflight -> import", () => {
602
- test("full round-trip produces consistent results via HTTP handlers", async () => {
603
- // Step 1: Export
604
- const exportReq = new Request("http://localhost/v1/migrations/export", {
605
- method: "POST",
606
- });
607
- const exportRes = await callHandler(handleMigrationExport, exportReq);
608
- expect(exportRes.status).toBe(200);
609
-
610
- const archiveBuffer = await exportRes.arrayBuffer();
611
- const archiveData = new Uint8Array(archiveBuffer);
612
- expect(archiveData.length).toBeGreaterThan(0);
613
-
614
- // Step 2: Validate the exported archive
615
- const validateReq = new Request("http://localhost/v1/migrations/validate", {
616
- method: "POST",
617
- headers: { "Content-Type": "application/octet-stream" },
618
- body: toArrayBuffer(archiveData),
619
- });
620
- const validateRes = await callHandler(handleMigrationValidate, validateReq);
621
- const validateBody = (await validateRes.json()) as ValidationResponse;
622
-
623
- expect(validateRes.status).toBe(200);
624
- expect(validateBody.is_valid).toBe(true);
625
- expect(validateBody.errors).toHaveLength(0);
626
- expect(validateBody.manifest).toBeDefined();
627
-
628
- // Step 3: Import preflight (dry-run)
629
- const preflightReq = new Request(
630
- "http://localhost/v1/migrations/import-preflight",
631
- {
632
- method: "POST",
633
- headers: { "Content-Type": "application/octet-stream" },
634
- body: toArrayBuffer(archiveData),
635
- },
636
- );
637
- const preflightRes = await callHandler(
638
- handleMigrationImportPreflight,
639
- preflightReq,
640
- );
641
- const preflightBody = (await preflightRes.json()) as ImportDryRunResponse;
642
-
643
- expect(preflightRes.status).toBe(200);
644
- expect(preflightBody.can_import).toBe(true);
645
- expect(preflightBody.summary).toBeDefined();
646
- expect(preflightBody.files).toBeDefined();
647
- expect(preflightBody.manifest).toBeDefined();
648
-
649
- // The files should show "unchanged" since we exported from the same disk.
650
- // New format uses workspace/ prefix for archive paths.
651
- const dbFile = preflightBody.files!.find(
652
- (f) => f.path === "workspace/data/db/assistant.db",
653
- );
654
- expect(dbFile).toBeDefined();
655
- expect(dbFile!.action).toBe("unchanged");
656
-
657
- // Step 4: Import (commit)
658
- const importReq = new Request("http://localhost/v1/migrations/import", {
659
- method: "POST",
660
- headers: { "Content-Type": "application/octet-stream" },
661
- body: toArrayBuffer(archiveData),
662
- });
663
- const importRes = await callHandler(handleMigrationImport, importReq);
664
- const importBody = (await importRes.json()) as ImportCommitResponse;
665
-
666
- expect(importRes.status).toBe(200);
667
- expect(importBody.success).toBe(true);
668
- expect(importBody.manifest).toBeDefined();
669
-
670
- // Schema version should be consistent across all steps
671
- expect(validateBody.manifest!.schema_version).toBe(
672
- preflightBody.manifest!.schema_version,
673
- );
674
- expect(validateBody.manifest!.schema_version).toBe(
675
- importBody.manifest!.schema_version,
676
- );
677
- });
678
-
679
- test("round-trip via builder: build -> validate -> analyze -> commit", () => {
680
- const dbData = new Uint8Array([0xca, 0xfe, 0xba, 0xbe]);
681
- const configData = new TextEncoder().encode('{"model":"test-round-trip"}');
682
-
683
- // Step 1: Build
684
- const { archive, manifest } = buildVBundle({
685
- files: [
686
- { path: "data/db/assistant.db", data: dbData },
687
- { path: "config/settings.json", data: configData },
688
- ],
689
- ...defaultV1Options(),
690
- });
691
-
692
- expect(manifest.schema_version).toBe(1);
693
- expect(manifest.contents).toHaveLength(2);
694
-
695
- // Step 2: Validate
696
- const validationResult = validateVBundle(archive);
697
- expect(validationResult.is_valid).toBe(true);
698
- expect(validationResult.manifest?.checksum).toBe(manifest.checksum);
699
-
700
- // Step 3: Analyze (preflight)
701
- const resolver = new DefaultPathResolver(testDir);
702
- const report = analyzeImport({
703
- manifest: validationResult.manifest!,
704
- pathResolver: resolver,
705
- });
706
-
707
- expect(report.can_import).toBe(true);
708
- expect(report.summary.total_files).toBe(2);
709
-
710
- // Step 4: Commit
711
- const commitResult = commitImport({
712
- archiveData: archive,
713
- pathResolver: resolver,
714
- });
715
-
716
- expect(commitResult.ok).toBe(true);
717
- if (commitResult.ok) {
718
- expect(commitResult.report.success).toBe(true);
719
- expect(commitResult.report.manifest.schema_version).toBe(1);
720
- expect(commitResult.report.summary.total_files).toBe(2);
721
-
722
- // Verify data on disk matches what we built
723
- const writtenDb = new Uint8Array(readFileSync(testDbPath));
724
- expect(sha256Hex(writtenDb)).toBe(sha256Hex(dbData));
725
-
726
- const writtenConfig = readFileSync(testConfigPath, "utf8");
727
- expect(writtenConfig).toBe(
728
- JSON.stringify({ model: "test-round-trip" }, null, 2) + "\n",
729
- );
730
- }
731
- });
732
-
733
- test("export manifest checksum remains valid through validate step", async () => {
734
- const exportReq = new Request("http://localhost/v1/migrations/export", {
735
- method: "POST",
736
- });
737
- const exportRes = await callHandler(handleMigrationExport, exportReq);
738
- const archiveData = new Uint8Array(await exportRes.arrayBuffer());
739
-
740
- // The X-Vbundle-Manifest-Sha256 response header value should match
741
- // the manifest's `checksum` field inside the archive (the legacy
742
- // header name is preserved across the v1 rename).
743
- const headerSha = exportRes.headers.get("X-Vbundle-Manifest-Sha256");
744
- expect(headerSha).toBeDefined();
745
-
746
- const validationResult = validateVBundle(archiveData);
747
- expect(validationResult.is_valid).toBe(true);
748
- expect(validationResult.manifest?.checksum).toBe(headerSha!);
749
- });
750
- });
751
-
752
- // ═══════════════════════════════════════════════════════════════════════════
753
- // 4. Partial failure scenarios
754
- // ═══════════════════════════════════════════════════════════════════════════
755
-
756
- describe("partial failure scenarios", () => {
757
- test("commitImport to read-only path returns write_failed with partial report", () => {
758
- const newDbData = new Uint8Array([0xde, 0xad]);
759
- const vbundle = createValidVBundle([
760
- { path: "data/db/assistant.db", data: newDbData },
761
- ]);
762
-
763
- // Point to a workspace path where data/db cannot be created
764
- // (a regular file blocks directory creation)
765
- const blockerWorkspace = join(testDir, "blocker-workspace");
766
- mkdirSync(blockerWorkspace, { recursive: true });
767
- // Create "data" as a regular file — mkdirSync("data/db") will fail
768
- writeFileSync(
769
- join(blockerWorkspace, "data"),
770
- "I am a file, not a directory",
771
- );
772
-
773
- const resolver = new DefaultPathResolver(blockerWorkspace);
774
- const result = commitImport({
775
- archiveData: vbundle,
776
- pathResolver: resolver,
777
- });
778
-
779
- expect(result.ok).toBe(false);
780
- if (!result.ok) {
781
- expect(result.reason).toBe("write_failed");
782
- expect((result as { message: string }).message).toBeDefined();
783
- expect((result as { message: string }).message.length).toBeGreaterThan(0);
784
- }
785
-
786
- // Clean up
787
- rmSync(blockerWorkspace, { recursive: true, force: true });
788
- });
789
-
790
- test("commitImport with invalid archive returns validation_failed", () => {
791
- const resolver = new DefaultPathResolver(testDir);
792
- const result = commitImport({
793
- archiveData: new Uint8Array([0xba, 0xad, 0xf0, 0x0d]),
794
- pathResolver: resolver,
795
- });
796
-
797
- expect(result.ok).toBe(false);
798
- if (!result.ok) {
799
- expect(result.reason).toBe("validation_failed");
800
- const errors = (
801
- result as {
802
- errors: Array<{ code: string; message: string }>;
803
- }
804
- ).errors;
805
- expect(errors).toBeDefined();
806
- expect(errors.length).toBeGreaterThan(0);
807
- expect(errors[0].code).toBe("INVALID_GZIP");
808
- }
809
- });
810
-
811
- test("import HTTP endpoint returns structured error for invalid archives", async () => {
812
- const req = new Request("http://localhost/v1/migrations/import", {
813
- method: "POST",
814
- headers: { "Content-Type": "application/octet-stream" },
815
- body: toArrayBuffer(new Uint8Array([0xff, 0xfe, 0xfd])),
816
- });
817
-
818
- const res = await callHandler(handleMigrationImport, req);
819
- const body = (await res.json()) as ImportCommitResponse;
820
-
821
- expect(body.success).toBe(false);
822
- expect(body.reason).toBe("validation_failed");
823
- expect(body.errors).toBeDefined();
824
- expect(body.errors!.length).toBeGreaterThan(0);
825
-
826
- // Each error should have both a code and a message
827
- for (const err of body.errors!) {
828
- expect(err.code).toBeDefined();
829
- expect(err.code.length).toBeGreaterThan(0);
830
- expect(err.message).toBeDefined();
831
- expect(err.message.length).toBeGreaterThan(0);
832
- }
833
- });
834
-
835
- test("preflight returns validation errors for corrupted bundle", async () => {
836
- const req = new Request("http://localhost/v1/migrations/import-preflight", {
837
- method: "POST",
838
- headers: { "Content-Type": "application/octet-stream" },
839
- body: toArrayBuffer(new Uint8Array([0xba, 0xdc, 0x0d, 0xe5])),
840
- });
841
-
842
- const res = await callHandler(handleMigrationImportPreflight, req);
843
- const body = (await res.json()) as ImportDryRunResponse;
844
-
845
- expect(res.status).toBe(200);
846
- expect(body.can_import).toBe(false);
847
- expect(body.validation).toBeDefined();
848
- expect(body.validation!.is_valid).toBe(false);
849
- expect(body.validation!.errors.length).toBeGreaterThan(0);
850
- });
851
-
852
- test("import does not modify disk when bundle has checksum mismatch", () => {
853
- // Save original disk state
854
- const originalDb = new Uint8Array(readFileSync(testDbPath));
855
- const originalConfig = readFileSync(testConfigPath, "utf8");
856
-
857
- // Create bundle with corrupted checksum
858
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
859
- const manifest = {
860
- schema_version: 1,
861
- created_at: new Date().toISOString(),
862
- files: [
863
- {
864
- path: "data/db/assistant.db",
865
- sha256: sha256Hex(dbData),
866
- size: dbData.length,
867
- },
868
- ],
869
- manifest_sha256:
870
- "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
871
- };
872
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
873
- const tar = createTarArchive([
874
- { name: "manifest.json", data: manifestData },
875
- { name: "data/db/assistant.db", data: dbData },
876
- ]);
877
- const corruptVBundle = gzipSync(tar);
878
-
879
- const resolver = new DefaultPathResolver(testDir);
880
- const result = commitImport({
881
- archiveData: corruptVBundle,
882
- pathResolver: resolver,
883
- });
884
-
885
- expect(result.ok).toBe(false);
886
-
887
- // Disk should be unchanged
888
- const currentDb = new Uint8Array(readFileSync(testDbPath));
889
- const currentConfig = readFileSync(testConfigPath, "utf8");
890
- expect(currentDb).toEqual(originalDb);
891
- expect(currentConfig).toBe(originalConfig);
892
- });
893
- });
894
-
895
- // ═══════════════════════════════════════════════════════════════════════════
896
- // 5. Edge cases
897
- // ═══════════════════════════════════════════════════════════════════════════
898
-
899
- describe("edge cases", () => {
900
- test("bundle with empty database file validates and imports", () => {
901
- const emptyDb = new Uint8Array(0);
902
- const vbundle = createValidVBundle([
903
- { path: "data/db/assistant.db", data: emptyDb },
904
- ]);
905
-
906
- const result = validateVBundle(vbundle);
907
- expect(result.is_valid).toBe(true);
908
- expect(result.manifest?.contents[0].size_bytes).toBe(0);
909
-
910
- // Import should also succeed
911
- const resolver = new DefaultPathResolver(testDir);
912
- const importResult = commitImport({
913
- archiveData: vbundle,
914
- pathResolver: resolver,
915
- });
916
- expect(importResult.ok).toBe(true);
917
- if (importResult.ok) {
918
- expect(importResult.report.success).toBe(true);
919
- }
920
- });
921
-
922
- test("bundle with large file entry validates correctly", () => {
923
- // Create a 100KB file to test larger payloads
924
- const largeData = new Uint8Array(100 * 1024);
925
- for (let i = 0; i < largeData.length; i++) {
926
- largeData[i] = i % 256;
927
- }
928
-
929
- const vbundle = createValidVBundle([
930
- { path: "data/db/assistant.db", data: largeData },
931
- ]);
932
-
933
- const result = validateVBundle(vbundle);
934
- expect(result.is_valid).toBe(true);
935
- expect(result.manifest?.contents[0].size_bytes).toBe(100 * 1024);
936
- });
937
-
938
- test("bundle with duplicate archive paths uses last occurrence in tar", () => {
939
- // A tar archive with duplicate paths: the tar parser will keep the
940
- // last one it sees. Create manually to have duplicates.
941
- const dbData1 = new Uint8Array([0x01, 0x02, 0x03]);
942
- const dbData2 = new Uint8Array([0x04, 0x05, 0x06]);
943
-
944
- // Build a valid v1 manifest that references the second data.
945
- const manifest = buildTestManifest({
946
- contents: [
947
- {
948
- path: "data/db/assistant.db",
949
- sha256: sha256Hex(dbData2),
950
- size_bytes: dbData2.length,
951
- },
952
- ],
953
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
954
- });
955
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
956
-
957
- // Build tar with duplicate data/db/assistant.db entries
958
- const tar = createTarArchive([
959
- { name: "manifest.json", data: manifestData },
960
- { name: "data/db/assistant.db", data: dbData1 },
961
- { name: "data/db/assistant.db", data: dbData2 },
962
- ]);
963
- const vbundle = gzipSync(tar);
964
-
965
- // The tar parser's Map will keep the last entry for a duplicate key.
966
- // Since the manifest references dbData2's checksum and the Map
967
- // stores the last occurrence, validation should pass.
968
- const result = validateVBundle(vbundle);
969
- expect(result.is_valid).toBe(true);
970
- });
971
-
972
- test("corrupted file checksum is detected with clear diagnostic", () => {
973
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
974
- const wrongChecksum =
975
- "0000000000000000000000000000000000000000000000000000000000000000";
976
-
977
- const manifest = buildTestManifest({
978
- contents: [
979
- {
980
- path: "data/db/assistant.db",
981
- sha256: wrongChecksum,
982
- size_bytes: dbData.length,
983
- },
984
- ],
985
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
986
- });
987
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
988
-
989
- const tar = createTarArchive([
990
- { name: "manifest.json", data: manifestData },
991
- { name: "data/db/assistant.db", data: dbData },
992
- ]);
993
- const vbundle = gzipSync(tar);
994
- const result = validateVBundle(vbundle);
995
-
996
- expect(result.is_valid).toBe(false);
997
- const checksumError = result.errors.find(
998
- (e) => e.code === "FILE_CHECKSUM_MISMATCH",
999
- );
1000
- expect(checksumError).toBeDefined();
1001
- expect(checksumError!.message).toContain("data/db/assistant.db");
1002
- expect(checksumError!.message).toContain(wrongChecksum);
1003
- expect(checksumError!.path).toBe("data/db/assistant.db");
1004
- });
1005
-
1006
- test("manifest declaring non-existent file produces MISSING_DECLARED_FILE", () => {
1007
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1008
- const ghostSha = sha256Hex(new TextEncoder().encode("ghost-content"));
1009
-
1010
- const manifest = buildTestManifest({
1011
- contents: [
1012
- {
1013
- path: "data/db/assistant.db",
1014
- sha256: sha256Hex(dbData),
1015
- size_bytes: dbData.length,
1016
- },
1017
- {
1018
- path: "data/extra/ghost.bin",
1019
- sha256: ghostSha,
1020
- size_bytes: 13,
1021
- },
1022
- ],
1023
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1024
- });
1025
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1026
-
1027
- const tar = createTarArchive([
1028
- { name: "manifest.json", data: manifestData },
1029
- { name: "data/db/assistant.db", data: dbData },
1030
- // data/extra/ghost.bin intentionally NOT included
1031
- ]);
1032
- const vbundle = gzipSync(tar);
1033
- const result = validateVBundle(vbundle);
1034
-
1035
- expect(result.is_valid).toBe(false);
1036
- const missingError = result.errors.find(
1037
- (e) => e.code === "MISSING_DECLARED_FILE",
1038
- );
1039
- expect(missingError).toBeDefined();
1040
- expect(missingError!.path).toBe("data/extra/ghost.bin");
1041
- expect(missingError!.message).toContain("data/extra/ghost.bin");
1042
- });
1043
-
1044
- test("bundle with empty contents is rejected by the v1 contents refine", () => {
1045
- // The v1 schema's `.refine()` requires `data/db/assistant.db` (legacy)
1046
- // or its `workspace/`-prefixed counterpart. A manifest declaring an
1047
- // empty contents array is valid at the field level but rejected by
1048
- // the refine.
1049
- const dbBytes = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1050
- const manifest = buildTestManifest({
1051
- contents: [],
1052
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1053
- });
1054
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1055
-
1056
- const tar = createTarArchive([
1057
- { name: "manifest.json", data: manifestData },
1058
- ]);
1059
- const vbundle = gzipSync(tar);
1060
- const result = validateVBundle(vbundle);
1061
-
1062
- expect(result.is_valid).toBe(false);
1063
- // Sanity: this manifest WOULD be valid if it included the DB entry.
1064
- expect(dbBytes).toBeDefined();
1065
- });
1066
-
1067
- test("completely empty gzip content (no tar entries) fails gracefully", () => {
1068
- // A gzip of an empty buffer
1069
- const emptyGzip = gzipSync(new Uint8Array(0));
1070
- const result = validateVBundle(emptyGzip);
1071
-
1072
- expect(result.is_valid).toBe(false);
1073
- // Should fail with missing entries since the tar is empty
1074
- expect(result.errors.length).toBeGreaterThan(0);
1075
- });
1076
-
1077
- test("non-gzip data (random bytes) produces INVALID_GZIP", () => {
1078
- const randomBytes = new Uint8Array(256);
1079
- for (let i = 0; i < randomBytes.length; i++) {
1080
- randomBytes[i] = Math.floor(Math.random() * 256);
1081
- }
1082
-
1083
- const result = validateVBundle(randomBytes);
1084
-
1085
- expect(result.is_valid).toBe(false);
1086
- expect(result.errors).toHaveLength(1);
1087
- expect(result.errors[0].code).toBe("INVALID_GZIP");
1088
- expect(result.errors[0].message).toContain("gzip");
1089
- });
1090
-
1091
- test("truncated gzip data produces INVALID_GZIP", () => {
1092
- // Create a valid gzip and truncate it
1093
- const validGzip = gzipSync(new Uint8Array([1, 2, 3, 4, 5]));
1094
- const truncated = validGzip.subarray(0, Math.floor(validGzip.length / 2));
1095
-
1096
- const result = validateVBundle(truncated);
1097
-
1098
- expect(result.is_valid).toBe(false);
1099
- expect(result.errors[0].code).toBe("INVALID_GZIP");
1100
- });
1101
-
1102
- test("single byte input produces INVALID_GZIP", () => {
1103
- const result = validateVBundle(new Uint8Array([0x00]));
1104
-
1105
- expect(result.is_valid).toBe(false);
1106
- expect(result.errors[0].code).toBe("INVALID_GZIP");
1107
- });
1108
-
1109
- test("bundle with extra undeclared files in archive still validates (extra files are ignored)", () => {
1110
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1111
- const extraData = new TextEncoder().encode("bonus content");
1112
-
1113
- // Manifest only declares the db file
1114
- const manifest = buildTestManifest({
1115
- contents: [
1116
- {
1117
- path: "data/db/assistant.db",
1118
- sha256: sha256Hex(dbData),
1119
- size_bytes: dbData.length,
1120
- },
1121
- ],
1122
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1123
- });
1124
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1125
-
1126
- // Archive has an extra file not in the manifest
1127
- const tar = createTarArchive([
1128
- { name: "manifest.json", data: manifestData },
1129
- { name: "data/db/assistant.db", data: dbData },
1130
- { name: "bonus/extra.txt", data: extraData },
1131
- ]);
1132
- const vbundle = gzipSync(tar);
1133
-
1134
- const result = validateVBundle(vbundle);
1135
- // Extra files in the archive (not declared in manifest) are not
1136
- // an error — the validator only checks declared files.
1137
- expect(result.is_valid).toBe(true);
1138
- });
1139
- });
1140
-
1141
- // ═══════════════════════════════════════════════════════════════════════════
1142
- // 6. Diagnostic quality — every error has a code and descriptive message
1143
- // ═══════════════════════════════════════════════════════════════════════════
1144
-
1145
- describe("diagnostic quality", () => {
1146
- test("INVALID_GZIP error message mentions gzip", () => {
1147
- const result = validateVBundle(new Uint8Array([0xff, 0xfe]));
1148
- const err = result.errors.find((e) => e.code === "INVALID_GZIP");
1149
- expect(err).toBeDefined();
1150
- expect(err!.message.toLowerCase()).toContain("gzip");
1151
- });
1152
-
1153
- test("MISSING_ENTRY error identifies which entry is missing", () => {
1154
- const tar = createTarArchive([
1155
- { name: "something-else.txt", data: new TextEncoder().encode("hi") },
1156
- ]);
1157
- const vbundle = gzipSync(tar);
1158
- const result = validateVBundle(vbundle);
1159
-
1160
- const manifestMissing = result.errors.find(
1161
- (e) => e.code === "MISSING_ENTRY" && e.path === "manifest.json",
1162
- );
1163
- expect(manifestMissing).toBeDefined();
1164
- expect(manifestMissing!.message).toContain("manifest.json");
1165
- });
1166
-
1167
- test("MANIFEST_CHECKSUM_MISMATCH includes expected and computed hashes", () => {
1168
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1169
- const badHash =
1170
- "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
1171
- const manifest = {
1172
- schema_version: 1,
1173
- bundle_id: "00000000-0000-4000-8000-000000000000",
1174
- created_at: new Date().toISOString(),
1175
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
1176
- origin: { mode: "self-hosted-local" as const },
1177
- compatibility: {
1178
- min_runtime_version: "0.0.0-test",
1179
- max_runtime_version: null,
1180
- },
1181
- contents: [
1182
- {
1183
- path: "data/db/assistant.db",
1184
- sha256: sha256Hex(dbData),
1185
- size_bytes: dbData.length,
1186
- },
1187
- ],
1188
- checksum: badHash,
1189
- secrets_redacted: false,
1190
- export_options: {
1191
- include_logs: false,
1192
- include_browser_state: false,
1193
- include_memory_vectors: false,
1194
- },
1195
- };
1196
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1197
- const tar = createTarArchive([
1198
- { name: "manifest.json", data: manifestData },
1199
- { name: "data/db/assistant.db", data: dbData },
1200
- ]);
1201
- const vbundle = gzipSync(tar);
1202
- const result = validateVBundle(vbundle);
1203
-
1204
- const err = result.errors.find(
1205
- (e) => e.code === "MANIFEST_CHECKSUM_MISMATCH",
1206
- );
1207
- expect(err).toBeDefined();
1208
- expect(err!.message).toContain(badHash); // expected hash
1209
- expect(err!.message).toContain("computed"); // shows computed hash
1210
- });
1211
-
1212
- test("FILE_CHECKSUM_MISMATCH includes file path and both hashes", () => {
1213
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1214
- const wrongSha =
1215
- "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
1216
-
1217
- const manifest = buildTestManifest({
1218
- contents: [
1219
- {
1220
- path: "data/db/assistant.db",
1221
- sha256: wrongSha,
1222
- size_bytes: dbData.length,
1223
- },
1224
- ],
1225
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1226
- });
1227
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1228
- const tar = createTarArchive([
1229
- { name: "manifest.json", data: manifestData },
1230
- { name: "data/db/assistant.db", data: dbData },
1231
- ]);
1232
- const vbundle = gzipSync(tar);
1233
- const result = validateVBundle(vbundle);
1234
-
1235
- const err = result.errors.find((e) => e.code === "FILE_CHECKSUM_MISMATCH");
1236
- expect(err).toBeDefined();
1237
- expect(err!.path).toBe("data/db/assistant.db");
1238
- expect(err!.message).toContain(wrongSha);
1239
- expect(err!.message).toContain("data/db/assistant.db");
1240
- });
1241
-
1242
- test("FILE_SIZE_MISMATCH includes file path and both sizes", () => {
1243
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1244
-
1245
- const manifest = buildTestManifest({
1246
- contents: [
1247
- {
1248
- path: "data/db/assistant.db",
1249
- sha256: sha256Hex(dbData),
1250
- size_bytes: 99999,
1251
- },
1252
- ],
1253
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1254
- });
1255
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1256
- const tar = createTarArchive([
1257
- { name: "manifest.json", data: manifestData },
1258
- { name: "data/db/assistant.db", data: dbData },
1259
- ]);
1260
- const vbundle = gzipSync(tar);
1261
- const result = validateVBundle(vbundle);
1262
-
1263
- const err = result.errors.find((e) => e.code === "FILE_SIZE_MISMATCH");
1264
- expect(err).toBeDefined();
1265
- expect(err!.path).toBe("data/db/assistant.db");
1266
- expect(err!.message).toContain("99999");
1267
- expect(err!.message).toContain(String(dbData.length));
1268
- });
1269
-
1270
- test("import commit validation_failed response includes error codes and messages", () => {
1271
- const resolver = new DefaultPathResolver(testDir);
1272
- const result = commitImport({
1273
- archiveData: new Uint8Array([0x00]),
1274
- pathResolver: resolver,
1275
- });
1276
-
1277
- expect(result.ok).toBe(false);
1278
- if (!result.ok && result.reason === "validation_failed") {
1279
- for (const err of result.errors) {
1280
- expect(typeof err.code).toBe("string");
1281
- expect(err.code.length).toBeGreaterThan(0);
1282
- expect(typeof err.message).toBe("string");
1283
- expect(err.message.length).toBeGreaterThan(0);
1284
- }
1285
- }
1286
- });
1287
-
1288
- test("preflight validation_failed response includes structured errors", async () => {
1289
- const req = new Request("http://localhost/v1/migrations/import-preflight", {
1290
- method: "POST",
1291
- headers: { "Content-Type": "application/octet-stream" },
1292
- body: toArrayBuffer(new Uint8Array([0x00])),
1293
- });
1294
-
1295
- const res = await callHandler(handleMigrationImportPreflight, req);
1296
- const body = (await res.json()) as ImportDryRunResponse;
1297
-
1298
- expect(body.can_import).toBe(false);
1299
- expect(body.validation).toBeDefined();
1300
- expect(body.validation!.errors.length).toBeGreaterThan(0);
1301
-
1302
- for (const err of body.validation!.errors) {
1303
- expect(typeof err.code).toBe("string");
1304
- expect(err.code.length).toBeGreaterThan(0);
1305
- expect(typeof err.message).toBe("string");
1306
- expect(err.message.length).toBeGreaterThan(0);
1307
- }
1308
- });
1309
- });
1310
-
1311
- // ═══════════════════════════════════════════════════════════════════════════
1312
- // 7. Multiple errors accumulation
1313
- // ═══════════════════════════════════════════════════════════════════════════
1314
-
1315
- describe("multiple error accumulation", () => {
1316
- test("bundle with multiple file-level errors reports all of them", () => {
1317
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1318
- const configData = new TextEncoder().encode('{"key":"value"}');
1319
- const wrongSha =
1320
- "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee";
1321
-
1322
- // Manifest declares correct db checksum but wrong config checksum and
1323
- // also wrong config size
1324
- const manifest = buildTestManifest({
1325
- contents: [
1326
- {
1327
- path: "data/db/assistant.db",
1328
- sha256: sha256Hex(dbData),
1329
- size_bytes: dbData.length,
1330
- },
1331
- {
1332
- path: "config/settings.json",
1333
- sha256: wrongSha,
1334
- size_bytes: 999,
1335
- },
1336
- ],
1337
- overrides: { bundle_id: "00000000-0000-4000-8000-000000000000" },
1338
- });
1339
- const manifestData = new TextEncoder().encode(JSON.stringify(manifest));
1340
-
1341
- const tar = createTarArchive([
1342
- { name: "manifest.json", data: manifestData },
1343
- { name: "data/db/assistant.db", data: dbData },
1344
- { name: "config/settings.json", data: configData },
1345
- ]);
1346
- const vbundle = gzipSync(tar);
1347
- const result = validateVBundle(vbundle);
1348
-
1349
- expect(result.is_valid).toBe(false);
1350
-
1351
- // Should have both a checksum mismatch and a size mismatch for config
1352
- const checksumError = result.errors.find(
1353
- (e) =>
1354
- e.code === "FILE_CHECKSUM_MISMATCH" &&
1355
- e.path === "config/settings.json",
1356
- );
1357
- const sizeError = result.errors.find(
1358
- (e) =>
1359
- e.code === "FILE_SIZE_MISMATCH" && e.path === "config/settings.json",
1360
- );
1361
-
1362
- expect(checksumError).toBeDefined();
1363
- expect(sizeError).toBeDefined();
1364
- // At least 2 errors for the config file
1365
- expect(result.errors.length).toBeGreaterThanOrEqual(2);
1366
- });
1367
-
1368
- test("manifest with multiple missing required fields reports all issues", () => {
1369
- const dbData = new Uint8Array([0x53, 0x51, 0x4c, 0x69, 0x74, 0x65]);
1370
- // Completely empty manifest (has none of the required fields)
1371
- const manifestObj = {};
1372
-
1373
- const vbundle = createVBundleWithRawManifest(manifestObj, [
1374
- { name: "data/db/assistant.db", data: dbData },
1375
- ]);
1376
- const result = validateVBundle(vbundle);
1377
-
1378
- expect(result.is_valid).toBe(false);
1379
- // Should report multiple schema errors for missing fields
1380
- const schemaErrors = result.errors.filter(
1381
- (e) => e.code === "MANIFEST_SCHEMA_ERROR",
1382
- );
1383
- expect(schemaErrors.length).toBeGreaterThanOrEqual(3);
1384
- });
1385
- });
1386
-
1387
- // ═══════════════════════════════════════════════════════════════════════════
1388
- // 8. Builder -> validator consistency
1389
- // ═══════════════════════════════════════════════════════════════════════════
1390
-
1391
- describe("builder -> validator consistency", () => {
1392
- test("buildVBundle archives across content shapes pass validation", () => {
1393
- const testCases: Array<{ files: VBundleFile[] }> = [
1394
- {
1395
- files: [
1396
- {
1397
- path: "data/db/assistant.db",
1398
- data: new Uint8Array([0x01]),
1399
- },
1400
- ],
1401
- },
1402
- {
1403
- files: [
1404
- {
1405
- path: "data/db/assistant.db",
1406
- data: new Uint8Array(10000), // 10KB of zeroes
1407
- },
1408
- {
1409
- path: "config/settings.json",
1410
- data: new TextEncoder().encode('{"big":"config","keys":[1,2,3]}'),
1411
- },
1412
- ],
1413
- },
1414
- {
1415
- files: [
1416
- {
1417
- path: "data/db/assistant.db",
1418
- data: new Uint8Array(0), // empty
1419
- },
1420
- ],
1421
- },
1422
- ];
1423
-
1424
- for (const tc of testCases) {
1425
- const { archive } = buildVBundle({
1426
- files: tc.files,
1427
- ...defaultV1Options(),
1428
- });
1429
-
1430
- const result = validateVBundle(archive);
1431
- expect(result.is_valid).toBe(true);
1432
- expect(result.manifest?.schema_version).toBe(1);
1433
- }
1434
- });
1435
-
1436
- test("builder output checksum matches validator computation", () => {
1437
- const { archive, manifest } = buildVBundle({
1438
- files: [
1439
- {
1440
- path: "data/db/assistant.db",
1441
- data: new Uint8Array([0xca, 0xfe]),
1442
- },
1443
- ],
1444
- ...defaultV1Options(),
1445
- });
1446
-
1447
- const result = validateVBundle(archive);
1448
- expect(result.is_valid).toBe(true);
1449
- expect(result.manifest?.checksum).toBe(manifest.checksum);
1450
- });
1451
-
1452
- test("builder output file checksums match validator computation", () => {
1453
- const fileData = new Uint8Array([0xde, 0xad, 0xbe, 0xef]);
1454
- const { archive, manifest } = buildVBundle({
1455
- files: [{ path: "data/db/assistant.db", data: fileData }],
1456
- ...defaultV1Options(),
1457
- });
1458
-
1459
- const result = validateVBundle(archive);
1460
- expect(result.is_valid).toBe(true);
1461
- expect(result.manifest?.contents[0].sha256).toBe(
1462
- manifest.contents[0].sha256,
1463
- );
1464
- expect(result.manifest?.contents[0].sha256).toBe(sha256Hex(fileData));
1465
- });
1466
- });
1467
-
1468
- // ═══════════════════════════════════════════════════════════════════════════
1469
- // 9. Import analyzer edge cases
1470
- // ═══════════════════════════════════════════════════════════════════════════
1471
-
1472
- describe("import analyzer edge cases", () => {
1473
- test("all-unchanged files produce correct summary", () => {
1474
- const resolver = new DefaultPathResolver(testDir);
1475
- const existingConfig = new Uint8Array(readFileSync(testConfigPath));
1476
-
1477
- const report = analyzeImport({
1478
- manifest: {
1479
- schema_version: 1,
1480
- bundle_id: "00000000-0000-4000-8000-000000000000",
1481
- created_at: "2026-03-01T00:00:00Z",
1482
- assistant: { id: "self", name: "Test", runtime_version: "0.0.0-test" },
1483
- origin: { mode: "self-hosted-local" },
1484
- compatibility: {
1485
- min_runtime_version: "0.0.0-test",
1486
- max_runtime_version: null,
1487
- },
1488
- contents: [
1489
- {
1490
- path: "data/db/assistant.db",
1491
- sha256: sha256Hex(EXISTING_DB_DATA),
1492
- size_bytes: EXISTING_DB_DATA.length,
1493
- },
1494
- {
1495
- path: "config/settings.json",
1496
- sha256: sha256Hex(existingConfig),
1497
- size_bytes: existingConfig.length,
1498
- },
1499
- ],
1500
- checksum: "test",
1501
- secrets_redacted: false,
1502
- export_options: {
1503
- include_logs: false,
1504
- include_browser_state: false,
1505
- include_memory_vectors: false,
1506
- },
1507
- },
1508
- pathResolver: resolver,
1509
- });
1510
-
1511
- expect(report.can_import).toBe(true);
1512
- expect(report.summary.files_unchanged).toBe(2);
1513
- expect(report.summary.files_to_create).toBe(0);
1514
- expect(report.summary.files_to_overwrite).toBe(0);
1515
- expect(report.conflicts).toHaveLength(0);
1516
- });
1517
-
1518
- test("all-create scenario (fresh install) produces correct summary", () => {
1519
- const resolver = new DefaultPathResolver(
1520
- join(testDir, "nonexistent-workspace"),
1521
- );
1522
-
1523
- const report = analyzeImport({
1524
- manifest: buildTestManifest({
1525
- contents: [
1526
- {
1527
- path: "data/db/assistant.db",
1528
- sha256: sha256Hex(new Uint8Array([1])),
1529
- size_bytes: 1,
1530
- },
1531
- {
1532
- path: "config/settings.json",
1533
- sha256: sha256Hex(new Uint8Array([2])),
1534
- size_bytes: 1,
1535
- },
1536
- ],
1537
- overrides: {
1538
- bundle_id: "00000000-0000-4000-8000-000000000000",
1539
- created_at: "2026-03-01T00:00:00Z",
1540
- },
1541
- }),
1542
- pathResolver: resolver,
1543
- });
1544
-
1545
- expect(report.can_import).toBe(true);
1546
- expect(report.summary.files_to_create).toBe(2);
1547
- expect(report.summary.files_unchanged).toBe(0);
1548
- expect(report.summary.files_to_overwrite).toBe(0);
1549
- });
1550
-
1551
- test("mixed create/overwrite/unchanged produces accurate counts", () => {
1552
- const resolver = new DefaultPathResolver(testDir);
1553
-
1554
- // db: different from disk -> overwrite
1555
- // config: same as disk -> unchanged
1556
- const existingConfig = new Uint8Array(readFileSync(testConfigPath));
1557
-
1558
- const report = analyzeImport({
1559
- manifest: buildTestManifest({
1560
- contents: [
1561
- {
1562
- path: "data/db/assistant.db",
1563
- sha256: sha256Hex(new Uint8Array([0xff])),
1564
- size_bytes: 1,
1565
- },
1566
- {
1567
- path: "config/settings.json",
1568
- sha256: sha256Hex(existingConfig),
1569
- size_bytes: existingConfig.length,
1570
- },
1571
- ],
1572
- overrides: {
1573
- bundle_id: "00000000-0000-4000-8000-000000000000",
1574
- created_at: "2026-03-01T00:00:00Z",
1575
- },
1576
- }),
1577
- pathResolver: resolver,
1578
- });
1579
-
1580
- expect(report.summary.files_to_overwrite).toBe(1);
1581
- expect(report.summary.files_unchanged).toBe(1);
1582
- expect(report.summary.files_to_create).toBe(0);
1583
- });
1584
-
1585
- test("unknown archive path produces UNKNOWN_ARCHIVE_PATH conflict", () => {
1586
- const resolver = new DefaultPathResolver(testDir);
1587
-
1588
- const report = analyzeImport({
1589
- manifest: buildTestManifest({
1590
- contents: [
1591
- {
1592
- path: "data/db/assistant.db",
1593
- sha256: sha256Hex(EXISTING_DB_DATA),
1594
- size_bytes: EXISTING_DB_DATA.length,
1595
- },
1596
- {
1597
- path: "future/unknown-file.dat",
1598
- sha256: sha256Hex(new Uint8Array([0])),
1599
- size_bytes: 1,
1600
- },
1601
- ],
1602
- overrides: {
1603
- bundle_id: "00000000-0000-4000-8000-000000000000",
1604
- created_at: "2026-03-01T00:00:00Z",
1605
- },
1606
- }),
1607
- pathResolver: resolver,
1608
- });
1609
-
1610
- expect(report.conflicts.length).toBe(1);
1611
- expect(report.conflicts[0].code).toBe("UNKNOWN_ARCHIVE_PATH");
1612
- expect(report.conflicts[0].message).toContain("future/unknown-file.dat");
1613
- });
1614
- });
1615
-
1616
- // ═══════════════════════════════════════════════════════════════════════════
1617
- // 10. HTTP endpoint error consistency
1618
- // ═══════════════════════════════════════════════════════════════════════════
1619
-
1620
- describe("HTTP endpoint error consistency", () => {
1621
- test("validate and import agree on invalid bundle diagnosis", async () => {
1622
- const invalidData = new Uint8Array([0xba, 0xad, 0xca, 0xfe]);
1623
-
1624
- const validateReq = new Request("http://localhost/v1/migrations/validate", {
1625
- method: "POST",
1626
- headers: { "Content-Type": "application/octet-stream" },
1627
- body: toArrayBuffer(invalidData),
1628
- });
1629
- const validateRes = await callHandler(handleMigrationValidate, validateReq);
1630
- const validateBody = (await validateRes.json()) as ValidationResponse;
1631
-
1632
- const importReq = new Request("http://localhost/v1/migrations/import", {
1633
- method: "POST",
1634
- headers: { "Content-Type": "application/octet-stream" },
1635
- body: toArrayBuffer(invalidData),
1636
- });
1637
- const importRes = await callHandler(handleMigrationImport, importReq);
1638
- const importBody = (await importRes.json()) as ImportCommitResponse;
1639
-
1640
- // Both should identify the same error type
1641
- expect(validateBody.is_valid).toBe(false);
1642
- expect(importBody.success).toBe(false);
1643
- expect(validateBody.errors[0].code).toBe(importBody.errors![0].code);
1644
- });
1645
-
1646
- test("validate and preflight agree on invalid bundle diagnosis", async () => {
1647
- const invalidData = new Uint8Array([0xba, 0xad, 0xca, 0xfe]);
1648
-
1649
- const validateReq = new Request("http://localhost/v1/migrations/validate", {
1650
- method: "POST",
1651
- headers: { "Content-Type": "application/octet-stream" },
1652
- body: toArrayBuffer(invalidData),
1653
- });
1654
- const validateRes = await callHandler(handleMigrationValidate, validateReq);
1655
- const validateBody = (await validateRes.json()) as ValidationResponse;
1656
-
1657
- const preflightReq = new Request(
1658
- "http://localhost/v1/migrations/import-preflight",
1659
- {
1660
- method: "POST",
1661
- headers: { "Content-Type": "application/octet-stream" },
1662
- body: toArrayBuffer(invalidData),
1663
- },
1664
- );
1665
- const preflightRes = await callHandler(
1666
- handleMigrationImportPreflight,
1667
- preflightReq,
1668
- );
1669
- const preflightBody = (await preflightRes.json()) as ImportDryRunResponse;
1670
-
1671
- expect(validateBody.is_valid).toBe(false);
1672
- expect(preflightBody.can_import).toBe(false);
1673
- expect(validateBody.errors[0].code).toBe(
1674
- preflightBody.validation!.errors[0].code,
1675
- );
1676
- });
1677
-
1678
- test("all migration endpoints return 400 for empty body", async () => {
1679
- const endpoints = [
1680
- "http://localhost/v1/migrations/validate",
1681
- "http://localhost/v1/migrations/import-preflight",
1682
- "http://localhost/v1/migrations/import",
1683
- ];
1684
- const handlers = [
1685
- handleMigrationValidate,
1686
- handleMigrationImportPreflight,
1687
- handleMigrationImport,
1688
- ];
1689
-
1690
- for (let i = 0; i < endpoints.length; i++) {
1691
- const req = new Request(endpoints[i], {
1692
- method: "POST",
1693
- headers: { "Content-Type": "application/octet-stream" },
1694
- body: toArrayBuffer(new Uint8Array(0)),
1695
- });
1696
-
1697
- const res = await callHandler(handlers[i], req);
1698
- expect(res.status).toBe(400);
1699
-
1700
- const body = (await res.json()) as {
1701
- error: { code: string; message: string };
1702
- };
1703
- expect(body.error.code).toBe("BAD_REQUEST");
1704
- expect(body.error.message).toBeDefined();
1705
- expect(body.error.message.length).toBeGreaterThan(0);
1706
- }
1707
- });
1708
- });