@vellumai/assistant 0.6.4 → 0.6.6

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 (1008) hide show
  1. package/.prettierignore +5 -0
  2. package/AGENTS.md +9 -1
  3. package/ARCHITECTURE.md +43 -49
  4. package/Dockerfile +17 -3
  5. package/README.md +3 -4
  6. package/__tests__/permissions/gateway-threshold-reader.test.ts +283 -0
  7. package/bun.lock +8 -3
  8. package/docs/architecture/integrations.md +33 -59
  9. package/docs/architecture/memory.md +25 -30
  10. package/docs/architecture/security.md +19 -18
  11. package/docs/browser-use-architecture-phase2.md +63 -20
  12. package/docs/error-handling.md +111 -0
  13. package/docs/plugins.md +761 -0
  14. package/docs/skills.md +10 -10
  15. package/docs/stt-provider-onboarding.md +2 -1
  16. package/examples/plugins/echo/README.md +132 -0
  17. package/examples/plugins/echo/package.json +17 -0
  18. package/examples/plugins/echo/register.ts +187 -0
  19. package/knip.json +9 -2
  20. package/node_modules/@vellumai/ces-contracts/package.json +2 -1
  21. package/node_modules/@vellumai/ces-contracts/src/__tests__/trust-rules.test.ts +471 -0
  22. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +398 -4
  23. package/node_modules/@vellumai/credential-storage/bun.lock +2 -2
  24. package/node_modules/@vellumai/credential-storage/package.json +2 -2
  25. package/node_modules/@vellumai/credential-storage/src/oauth-runtime.ts +20 -2
  26. package/node_modules/@vellumai/egress-proxy/bun.lock +2 -2
  27. package/node_modules/@vellumai/egress-proxy/package.json +2 -2
  28. package/node_modules/@vellumai/egress-proxy/src/types.ts +19 -0
  29. package/openapi.yaml +334 -78
  30. package/package.json +6 -3
  31. package/scripts/generate-openapi.ts +50 -11
  32. package/src/__tests__/agent-loop-callsite-precedence.test.ts +318 -0
  33. package/src/__tests__/agent-loop-sentry-hygiene.test.ts +137 -0
  34. package/src/__tests__/agent-loop.test.ts +112 -1
  35. package/src/__tests__/anthropic-error-formatting.test.ts +98 -0
  36. package/src/__tests__/anthropic-provider.test.ts +171 -2
  37. package/src/__tests__/app-compiler.test.ts +57 -0
  38. package/src/__tests__/approval-cascade.test.ts +36 -10
  39. package/src/__tests__/approval-routes-http.test.ts +134 -10
  40. package/src/__tests__/assistant-attachments.test.ts +44 -0
  41. package/src/__tests__/assistant-feature-flags-integration.test.ts +29 -0
  42. package/src/__tests__/auto-analysis-end-to-end.test.ts +1 -0
  43. package/src/__tests__/avatar-generator.test.ts +4 -2
  44. package/src/__tests__/browser-fill-credential.test.ts +1 -1
  45. package/src/__tests__/browser-identifier-parity-guard.test.ts +53 -0
  46. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +23 -33
  47. package/src/__tests__/browser-skill-endstate.test.ts +51 -182
  48. package/src/__tests__/btw-routes.test.ts +47 -1
  49. package/src/__tests__/bundled-asset.test.ts +6 -6
  50. package/src/__tests__/call-controller.test.ts +1 -2
  51. package/src/__tests__/call-site-routing-provider.test.ts +214 -0
  52. package/src/__tests__/catalog-cache.test.ts +96 -4
  53. package/src/__tests__/channel-approval-routes.test.ts +4 -4
  54. package/src/__tests__/channel-reply-delivery.test.ts +300 -2
  55. package/src/__tests__/checker.test.ts +870 -655
  56. package/src/__tests__/circuit-breaker-pipeline.test.ts +406 -0
  57. package/src/__tests__/cli-command-risk-guard.test.ts +30 -33
  58. package/src/__tests__/compaction-events.test.ts +501 -0
  59. package/src/__tests__/compaction-pipeline.test.ts +210 -0
  60. package/src/__tests__/compaction-strip-metadata-clear.test.ts +181 -0
  61. package/src/__tests__/compaction-timeout-recovery.test.ts +262 -0
  62. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  63. package/src/__tests__/config-analysis.test.ts +11 -28
  64. package/src/__tests__/config-loader-backfill.test.ts +174 -0
  65. package/src/__tests__/config-loader-corrupt.test.ts +183 -0
  66. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +202 -0
  67. package/src/__tests__/config-model-image-provider.test.ts +110 -0
  68. package/src/__tests__/config-schema-cmd.test.ts +11 -5
  69. package/src/__tests__/config-schema.test.ts +440 -114
  70. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +0 -4
  71. package/src/__tests__/config-watcher.test.ts +2 -2
  72. package/src/__tests__/contact-store-user-file.test.ts +72 -73
  73. package/src/__tests__/contacts-tools.test.ts +26 -0
  74. package/src/__tests__/contacts-write.test.ts +4 -4
  75. package/src/__tests__/context-overflow-policy.test.ts +7 -7
  76. package/src/__tests__/context-token-estimator.test.ts +191 -1
  77. package/src/__tests__/context-window-manager.test.ts +883 -4
  78. package/src/__tests__/conversation-abort-tool-results.test.ts +32 -15
  79. package/src/__tests__/conversation-agent-loop-overflow.test.ts +86 -46
  80. package/src/__tests__/conversation-agent-loop.test.ts +435 -216
  81. package/src/__tests__/conversation-attachments.test.ts +1 -1
  82. package/src/__tests__/conversation-confirmation-signals.test.ts +36 -10
  83. package/src/__tests__/conversation-error.test.ts +37 -6
  84. package/src/__tests__/conversation-history-web-search.test.ts +7 -0
  85. package/src/__tests__/conversation-init.benchmark.test.ts +34 -12
  86. package/src/__tests__/conversation-lifecycle.test.ts +336 -0
  87. package/src/__tests__/conversation-load-history-repair.test.ts +27 -10
  88. package/src/__tests__/conversation-pairing.test.ts +174 -10
  89. package/src/__tests__/conversation-pre-run-repair.test.ts +32 -15
  90. package/src/__tests__/conversation-process-callsite.test.ts +309 -0
  91. package/src/__tests__/conversation-provider-retry-repair.test.ts +44 -21
  92. package/src/__tests__/conversation-queue.test.ts +68 -38
  93. package/src/__tests__/conversation-routes-disk-view.test.ts +36 -7
  94. package/src/__tests__/conversation-routes-slash-commands.test.ts +31 -3
  95. package/src/__tests__/conversation-runtime-assembly.test.ts +2877 -152
  96. package/src/__tests__/conversation-runtime-workspace.test.ts +35 -50
  97. package/src/__tests__/conversation-seed-composer.test.ts +2 -2
  98. package/src/__tests__/conversation-skill-tools.test.ts +12 -146
  99. package/src/__tests__/conversation-slash-queue.test.ts +39 -19
  100. package/src/__tests__/conversation-slash-unknown.test.ts +53 -16
  101. package/src/__tests__/conversation-speed-override.test.ts +36 -12
  102. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1035 -0
  103. package/src/__tests__/conversation-surfaces-standalone.test.ts +630 -0
  104. package/src/__tests__/conversation-title-service.test.ts +118 -2
  105. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +41 -2
  106. package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +1 -1
  107. package/src/__tests__/conversation-unread-route.test.ts +2 -2
  108. package/src/__tests__/conversation-usage.test.ts +4 -2
  109. package/src/__tests__/conversation-workspace-cache-state.test.ts +33 -9
  110. package/src/__tests__/conversation-workspace-injection.test.ts +46 -15
  111. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +46 -15
  112. package/src/__tests__/credential-broker-browser-fill.test.ts +110 -0
  113. package/src/__tests__/credential-health-service.test.ts +78 -9
  114. package/src/__tests__/credential-security-invariants.test.ts +5 -2
  115. package/src/__tests__/credential-storage-oauth-compat.test.ts +18 -0
  116. package/src/__tests__/credential-storage-static-compat.test.ts +28 -0
  117. package/src/__tests__/credential-vault-unit.test.ts +135 -19
  118. package/src/__tests__/credentials-cli.test.ts +1 -9
  119. package/src/__tests__/cross-provider-web-search.test.ts +84 -0
  120. package/src/__tests__/daemon-server-persist-and-process-callsite.test.ts +92 -0
  121. package/src/__tests__/db-schedule-syntax-migration.test.ts +1 -0
  122. package/src/__tests__/delete-propagation.test.ts +437 -0
  123. package/src/__tests__/dm-backfill.test.ts +417 -0
  124. package/src/__tests__/dm-persistence.test.ts +227 -0
  125. package/src/__tests__/edit-propagation.test.ts +280 -0
  126. package/src/__tests__/empty-response-pipeline.test.ts +305 -0
  127. package/src/__tests__/ephemeral-permissions.test.ts +93 -3
  128. package/src/__tests__/estimator-calibration-integration.test.ts +208 -0
  129. package/src/__tests__/estimator-calibration.test.ts +213 -0
  130. package/src/__tests__/extension-id-sync-guard.test.ts +29 -10
  131. package/src/__tests__/file-write-tool.test.ts +151 -1
  132. package/src/__tests__/filing-service.test.ts +255 -0
  133. package/src/__tests__/first-greeting.test.ts +247 -5
  134. package/src/__tests__/gemini-provider.test.ts +0 -3
  135. package/src/__tests__/guardian-grant-minting.test.ts +8 -0
  136. package/src/__tests__/headless-browser-interactions.test.ts +1 -1
  137. package/src/__tests__/headless-browser-mode.test.ts +57 -0
  138. package/src/__tests__/heartbeat-service.test.ts +96 -15
  139. package/src/__tests__/history-repair-pipeline.test.ts +399 -0
  140. package/src/__tests__/host-browser-e2e-cloud.test.ts +307 -0
  141. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +3 -3
  142. package/src/__tests__/host-proxy-interface.test.ts +36 -2
  143. package/src/__tests__/host-shell-tool.test.ts +124 -18
  144. package/src/__tests__/http-user-message-parity.test.ts +29 -1
  145. package/src/__tests__/image-credentials.test.ts +137 -0
  146. package/src/__tests__/image-service-dispatcher.test.ts +186 -0
  147. package/src/__tests__/inbound-slack-persistence.test.ts +340 -0
  148. package/src/__tests__/injector-chain.test.ts +526 -0
  149. package/src/__tests__/intent-routing.test.ts +1 -66
  150. package/src/__tests__/llm-call-pipeline.test.ts +285 -0
  151. package/src/__tests__/llm-catalog-parity.test.ts +174 -0
  152. package/src/__tests__/llm-context-normalization.test.ts +121 -0
  153. package/src/__tests__/llm-resolver.test.ts +214 -0
  154. package/src/__tests__/llm-schema.test.ts +223 -0
  155. package/src/__tests__/managed-proxy-context.test.ts +6 -2
  156. package/src/__tests__/media-generate-image.test.ts +119 -13
  157. package/src/__tests__/memory-retrieval-pipeline.test.ts +401 -0
  158. package/src/__tests__/memory-upsert-concurrency.test.ts +1 -0
  159. package/src/__tests__/messaging-skill-split.test.ts +3 -34
  160. package/src/__tests__/migration-import-from-url.test.ts +621 -0
  161. package/src/__tests__/model-intents.test.ts +11 -83
  162. package/src/__tests__/notification-broadcaster.test.ts +3 -3
  163. package/src/__tests__/notification-decision-fallback.test.ts +0 -10
  164. package/src/__tests__/notification-decision-identity.test.ts +0 -9
  165. package/src/__tests__/notification-decision-recipient-context.test.ts +0 -9
  166. package/src/__tests__/notification-decision-strategy.test.ts +0 -11
  167. package/src/__tests__/notification-schedule-notify-dedup.test.ts +108 -0
  168. package/src/__tests__/oauth-apps-routes.test.ts +1 -1
  169. package/src/__tests__/oauth-cli.test.ts +14 -12
  170. package/src/__tests__/oauth-connect-orchestrator.test.ts +4 -13
  171. package/src/__tests__/oauth-provider-serializer.test.ts +6 -4
  172. package/src/__tests__/oauth-provider-visibility.test.ts +3 -5
  173. package/src/__tests__/oauth-providers-routes.test.ts +3 -2
  174. package/src/__tests__/oauth-store.test.ts +46 -78
  175. package/src/__tests__/oauth2-gateway-transport.test.ts +8 -3
  176. package/src/__tests__/oauth2-refresh-retry.test.ts +279 -0
  177. package/src/__tests__/onboarding-template-contract.test.ts +16 -64
  178. package/src/__tests__/openai-image-service.test.ts +368 -0
  179. package/src/__tests__/openai-provider.test.ts +7 -0
  180. package/src/__tests__/openai-responses-provider.test.ts +396 -0
  181. package/src/__tests__/openrouter-provider-only.test.ts +135 -0
  182. package/src/__tests__/outbound-slack-persistence.test.ts +293 -0
  183. package/src/__tests__/overflow-reduce-pipeline.test.ts +676 -0
  184. package/src/__tests__/permission-checker-host-gate.test.ts +1 -25
  185. package/src/__tests__/permission-mode.test.ts +16 -0
  186. package/src/__tests__/permission-types.test.ts +0 -1
  187. package/src/__tests__/persist-onboarding-artifacts.test.ts +266 -0
  188. package/src/__tests__/persistence-pipeline.test.ts +377 -0
  189. package/src/__tests__/persona-resolver.test.ts +13 -13
  190. package/src/__tests__/pipeline-runner.test.ts +565 -0
  191. package/src/__tests__/pkb-autoinject.test.ts +37 -1
  192. package/src/__tests__/platform-bash-auto-approve.test.ts +1 -1
  193. package/src/__tests__/platform.test.ts +5 -2
  194. package/src/__tests__/plugin-bootstrap.test.ts +483 -0
  195. package/src/__tests__/plugin-registry.test.ts +273 -0
  196. package/src/__tests__/plugin-route-contribution.test.ts +288 -0
  197. package/src/__tests__/plugin-skill-contribution.test.ts +367 -0
  198. package/src/__tests__/plugin-tool-contribution.test.ts +286 -0
  199. package/src/__tests__/plugin-types.test.ts +320 -0
  200. package/src/__tests__/pricing.test.ts +93 -14
  201. package/src/__tests__/profiler-routes.test.ts +1 -1
  202. package/src/__tests__/provider-commit-message-generator.test.ts +14 -84
  203. package/src/__tests__/provider-env-vars-scope.test.ts +52 -0
  204. package/src/__tests__/provider-error-scenarios.test.ts +135 -6
  205. package/src/__tests__/provider-managed-proxy-integration.test.ts +42 -11
  206. package/src/__tests__/provider-registry-ollama.test.ts +1 -2
  207. package/src/__tests__/proxy-approval-callback.test.ts +69 -9
  208. package/src/__tests__/reaction-persistence.test.ts +561 -0
  209. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +1 -0
  210. package/src/__tests__/registry.test.ts +0 -2
  211. package/src/__tests__/relay-server.test.ts +1 -1
  212. package/src/__tests__/require-fresh-approval.test.ts +1 -1
  213. package/src/__tests__/retry-openrouter-only-normalization.test.ts +136 -0
  214. package/src/__tests__/retry-thinking-tool-choice.test.ts +226 -0
  215. package/src/__tests__/risk-classifier-parity.test.ts +230 -0
  216. package/src/__tests__/sanitize-config-for-transfer.test.ts +78 -1
  217. package/src/__tests__/schedule-routes.test.ts +131 -1
  218. package/src/__tests__/scheduler-recurrence.test.ts +14 -70
  219. package/src/__tests__/scheduler-reuse-conversation.test.ts +10 -50
  220. package/src/__tests__/secret-detection-handler.test.ts +0 -10
  221. package/src/__tests__/secret-ingress-http.test.ts +28 -0
  222. package/src/__tests__/secret-prompter-channel-fallback.test.ts +125 -0
  223. package/src/__tests__/secret-routes-managed-proxy.test.ts +2 -3
  224. package/src/__tests__/secret-scanner-executor.test.ts +1 -1
  225. package/src/__tests__/send-endpoint-busy.test.ts +29 -1
  226. package/src/__tests__/server-history-render.test.ts +31 -0
  227. package/src/__tests__/shell-identity.test.ts +0 -134
  228. package/src/__tests__/shell-parser-property.test.ts +13 -13
  229. package/src/__tests__/skill-cache-store.test.ts +182 -0
  230. package/src/__tests__/skills.test.ts +19 -33
  231. package/src/__tests__/slack-app-setup-skill-regression.test.ts +3 -1
  232. package/src/__tests__/slack-skill.test.ts +3 -8
  233. package/src/__tests__/starter-bundle.test.ts +35 -0
  234. package/src/__tests__/subagent-call-site-routing.test.ts +280 -0
  235. package/src/__tests__/suggestion-routes.test.ts +259 -3
  236. package/src/__tests__/system-prompt.test.ts +22 -35
  237. package/src/__tests__/task-memory-cleanup.test.ts +1 -0
  238. package/src/__tests__/task-runner.test.ts +3 -1
  239. package/src/__tests__/task-scheduler.test.ts +3 -15
  240. package/src/__tests__/tcc-sandbox-deny.test.ts +198 -0
  241. package/src/__tests__/terminal-tools.test.ts +8 -0
  242. package/src/__tests__/test-preload.ts +11 -0
  243. package/src/__tests__/test-support/browser-skill-harness.ts +2 -52
  244. package/src/__tests__/thread-backfill.test.ts +941 -0
  245. package/src/__tests__/title-generate-pipeline.test.ts +224 -0
  246. package/src/__tests__/token-estimate-pipeline.test.ts +431 -0
  247. package/src/__tests__/tool-error-pipeline.test.ts +244 -0
  248. package/src/__tests__/tool-execute-pipeline.test.ts +431 -0
  249. package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -8
  250. package/src/__tests__/tool-executor-lifecycle-events.test.ts +2 -2
  251. package/src/__tests__/tool-executor-shell-integration.test.ts +7 -10
  252. package/src/__tests__/tool-executor.test.ts +201 -94
  253. package/src/__tests__/tool-result-truncate-pipeline.test.ts +356 -0
  254. package/src/__tests__/tool-result-truncation.test.ts +0 -110
  255. package/src/__tests__/trust-store.test.ts +442 -109
  256. package/src/__tests__/update-bulletin-job.test.ts +389 -0
  257. package/src/__tests__/usage-cache-backfill-migration.test.ts +3 -1
  258. package/src/__tests__/user-plugin-loader.test.ts +191 -0
  259. package/src/__tests__/verification-control-plane-policy.test.ts +1 -22
  260. package/src/__tests__/voice-session-bridge.test.ts +39 -0
  261. package/src/__tests__/volume-security-guard.test.ts +3 -2
  262. package/src/__tests__/web-search-history.test.ts +337 -0
  263. package/src/__tests__/workspace-migration-039-drop-legacy-llm-keys.test.ts +343 -0
  264. package/src/__tests__/workspace-migration-043-release-notes-latex-rendering.test.ts +202 -0
  265. package/src/__tests__/workspace-migration-045-release-notes-meet-avatar.test.ts +210 -0
  266. package/src/__tests__/workspace-migration-046-seed-conversation-starters-callsite.test.ts +185 -0
  267. package/src/__tests__/workspace-migration-049-release-notes-default-sonnet.test.ts +100 -0
  268. package/src/__tests__/workspace-migration-050-seed-main-agent-opus-callsite.test.ts +171 -0
  269. package/src/__tests__/workspace-migration-051-seed-conversation-summarization-callsite.test.ts +252 -0
  270. package/src/__tests__/workspace-migration-drop-user-md.test.ts +11 -11
  271. package/src/__tests__/workspace-migration-remove-hooks.test.ts +99 -0
  272. package/src/__tests__/workspace-migration-unify-llm-callsite-configs.test.ts +841 -0
  273. package/src/__tests__/workspace-policy.test.ts +22 -16
  274. package/src/acp/client-handler.ts +1 -2
  275. package/src/agent/loop.ts +545 -115
  276. package/src/approvals/__tests__/guardian-feed-event.test.ts +304 -0
  277. package/src/approvals/guardian-request-resolvers.ts +80 -0
  278. package/src/avatar/resvg-lazy.test.ts +136 -0
  279. package/src/avatar/resvg-lazy.ts +82 -9
  280. package/src/avatar/traits-png-sync.ts +21 -1
  281. package/src/backup/__tests__/backup-worker.test.ts +2 -13
  282. package/src/backup/backup-worker.ts +3 -15
  283. package/src/browser/__tests__/operations.test.ts +163 -0
  284. package/src/browser/identifiers.ts +51 -0
  285. package/src/browser/operations.ts +660 -0
  286. package/src/browser/types.ts +81 -0
  287. package/src/bundler/app-compiler.ts +84 -1
  288. package/src/calls/call-state.ts +2 -2
  289. package/src/calls/guardian-question-copy.ts +2 -2
  290. package/src/calls/telephony-stt-routing.ts +1 -1
  291. package/src/calls/voice-session-bridge.ts +1 -0
  292. package/src/channels/__tests__/types.test.ts +3 -3
  293. package/src/channels/types.ts +6 -4
  294. package/src/cli/AGENTS.md +1 -1
  295. package/src/cli/__tests__/notifications.test.ts +87 -211
  296. package/src/cli/commands/__tests__/attachment.test.ts +438 -0
  297. package/src/cli/commands/__tests__/backup.test.ts +1 -1
  298. package/src/cli/commands/__tests__/browser.test.ts +554 -0
  299. package/src/cli/commands/__tests__/cache.test.ts +623 -0
  300. package/src/cli/commands/__tests__/email-list.test.ts +6 -0
  301. package/src/cli/commands/__tests__/email-send.test.ts +93 -1
  302. package/src/cli/commands/__tests__/image-generation.test.ts +886 -0
  303. package/src/cli/commands/__tests__/inference-send.test.ts +463 -0
  304. package/src/cli/commands/__tests__/stt-transcribe.test.ts +454 -0
  305. package/src/cli/commands/__tests__/task.test.ts +913 -0
  306. package/src/cli/commands/__tests__/tts-synthesize.test.ts +606 -0
  307. package/src/cli/commands/__tests__/ui-confirm.test.ts +650 -0
  308. package/src/cli/commands/__tests__/ui.test.ts +1215 -0
  309. package/src/cli/commands/__tests__/watchers.test.ts +716 -0
  310. package/src/cli/commands/attachment.ts +182 -0
  311. package/src/cli/commands/backup.ts +2 -2
  312. package/src/cli/commands/browser.ts +350 -0
  313. package/src/cli/commands/cache.ts +341 -0
  314. package/src/cli/commands/clients.ts +138 -0
  315. package/src/cli/commands/completions.ts +2 -12
  316. package/src/cli/commands/config.ts +6 -6
  317. package/src/cli/commands/conversations-import.ts +347 -0
  318. package/src/cli/commands/conversations.ts +69 -8
  319. package/src/cli/commands/email.ts +234 -194
  320. package/src/cli/commands/image-generation.ts +299 -0
  321. package/src/cli/commands/inference.ts +200 -0
  322. package/src/cli/commands/memory.ts +127 -17
  323. package/src/cli/commands/notifications.ts +68 -103
  324. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -1
  325. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -1
  326. package/src/cli/commands/oauth/connect.ts +2 -2
  327. package/src/cli/commands/oauth/providers.ts +176 -8
  328. package/src/cli/commands/oauth/status.ts +46 -36
  329. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -1
  330. package/src/cli/commands/platform/__tests__/connect.test.ts +0 -1
  331. package/src/cli/commands/platform/__tests__/disconnect.test.ts +0 -1
  332. package/src/cli/commands/platform/__tests__/status.test.ts +0 -1
  333. package/src/cli/commands/skills.ts +3 -4
  334. package/src/cli/commands/stt.ts +339 -0
  335. package/src/cli/commands/task.ts +795 -0
  336. package/src/cli/commands/trust.ts +50 -19
  337. package/src/cli/commands/tts.ts +273 -0
  338. package/src/cli/commands/ui.ts +670 -0
  339. package/src/cli/commands/watchers.ts +509 -0
  340. package/src/cli/lib/daemon-credential-client.ts +0 -19
  341. package/src/cli/program.ts +39 -24
  342. package/src/cli.ts +0 -37
  343. package/src/config/__tests__/backup-schema.test.ts +7 -2
  344. package/src/config/bundled-skills/app-builder/SKILL.md +2 -2
  345. package/src/config/bundled-skills/app-builder/references/WIDGETS.md +10 -10
  346. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +66 -87
  347. package/src/config/bundled-skills/contacts/tools/contact-search.ts +28 -51
  348. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +22 -40
  349. package/src/config/bundled-skills/image-studio/SKILL.md +2 -1
  350. package/src/config/bundled-skills/image-studio/TOOLS.json +2 -1
  351. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +23 -39
  352. package/src/config/bundled-skills/media-processing/services/reduce.ts +1 -1
  353. package/src/config/bundled-skills/messaging/SKILL.md +5 -5
  354. package/src/config/bundled-skills/messaging/TOOLS.json +4 -0
  355. package/src/config/bundled-skills/messaging/tools/__tests__/messaging-feed-events.test.ts +207 -0
  356. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +20 -1
  357. package/src/config/bundled-skills/messaging/tools/messaging-read.ts +15 -1
  358. package/src/config/bundled-skills/messaging/tools/messaging-search.ts +21 -1
  359. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +69 -12
  360. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +9 -8
  361. package/src/config/bundled-skills/schedule/SKILL.md +8 -3
  362. package/src/config/bundled-skills/schedule/TOOLS.json +15 -7
  363. package/src/config/bundled-skills/schedule/references/SCRIPT_MODE_PATTERNS.md +59 -0
  364. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  365. package/src/config/bundled-tool-registry.ts +0 -190
  366. package/src/config/env.ts +7 -2
  367. package/src/config/feature-flag-registry.json +42 -10
  368. package/src/config/llm-resolver.ts +128 -0
  369. package/src/config/loader.ts +194 -10
  370. package/src/config/raw-config-utils.ts +30 -2
  371. package/src/config/sanitize-for-transfer.ts +35 -0
  372. package/src/config/schema.ts +49 -41
  373. package/src/config/schemas/analysis.ts +3 -22
  374. package/src/config/schemas/backup.ts +1 -1
  375. package/src/config/schemas/calls.ts +0 -4
  376. package/src/config/schemas/conversations.ts +16 -0
  377. package/src/config/schemas/filing.ts +2 -7
  378. package/src/config/schemas/heartbeat.ts +0 -5
  379. package/src/config/schemas/inference.ts +3 -23
  380. package/src/config/schemas/llm.ts +317 -0
  381. package/src/config/schemas/memory-processing.ts +1 -9
  382. package/src/config/schemas/notifications.ts +4 -11
  383. package/src/config/schemas/platform.ts +3 -9
  384. package/src/config/schemas/security.ts +33 -0
  385. package/src/config/schemas/services.ts +9 -4
  386. package/src/config/schemas/stt.ts +1 -0
  387. package/src/config/schemas/tts.ts +64 -0
  388. package/src/config/schemas/updates.ts +1 -1
  389. package/src/config/schemas/workspace-git.ts +3 -40
  390. package/src/config/skill-state.ts +6 -2
  391. package/src/config/skills.ts +96 -7
  392. package/src/context/__tests__/compact-prompt.test.ts +63 -0
  393. package/src/context/__tests__/microcompact.test.ts +805 -0
  394. package/src/context/estimator-calibration.ts +136 -0
  395. package/src/context/microcompact.ts +443 -0
  396. package/src/context/prompts/compact.md +26 -0
  397. package/src/context/token-estimator.ts +61 -3
  398. package/src/context/tool-result-truncation.ts +3 -63
  399. package/src/context/window-manager.ts +417 -39
  400. package/src/credential-execution/approval-bridge.ts +0 -1
  401. package/src/credential-execution/executable-discovery.ts +19 -8
  402. package/src/credential-execution/process-manager.test.ts +109 -0
  403. package/src/credential-execution/process-manager.ts +65 -2
  404. package/src/credential-health/credential-health-service.ts +19 -6
  405. package/src/daemon/__tests__/conversation-feed-event.test.ts +317 -0
  406. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +4 -12
  407. package/src/daemon/__tests__/conversation-tool-setup.test.ts +14 -15
  408. package/src/daemon/approval-generators.ts +29 -4
  409. package/src/daemon/assistant-attachments.ts +24 -13
  410. package/src/daemon/classifier.ts +2 -2
  411. package/src/daemon/config-watcher.ts +0 -3
  412. package/src/daemon/context-overflow-policy.ts +4 -13
  413. package/src/daemon/context-overflow-reducer.ts +4 -1
  414. package/src/daemon/conversation-agent-loop-handlers.ts +162 -34
  415. package/src/daemon/conversation-agent-loop.ts +1282 -599
  416. package/src/daemon/conversation-attachments.ts +2 -6
  417. package/src/daemon/conversation-error.ts +36 -1
  418. package/src/daemon/conversation-history.ts +10 -19
  419. package/src/daemon/conversation-lifecycle.ts +59 -17
  420. package/src/daemon/conversation-messaging.ts +73 -4
  421. package/src/daemon/conversation-notifiers.ts +2 -110
  422. package/src/daemon/conversation-process.ts +24 -11
  423. package/src/daemon/conversation-queue-manager.ts +3 -0
  424. package/src/daemon/conversation-runtime-assembly.ts +1063 -211
  425. package/src/daemon/conversation-slash.ts +2 -2
  426. package/src/daemon/conversation-surfaces.ts +389 -1
  427. package/src/daemon/conversation-tool-setup.ts +51 -9
  428. package/src/daemon/conversation-usage.ts +1 -1
  429. package/src/daemon/conversation.ts +197 -64
  430. package/src/daemon/external-plugins-bootstrap.ts +478 -0
  431. package/src/daemon/external-skills-bootstrap.ts +41 -0
  432. package/src/daemon/first-greeting.ts +191 -14
  433. package/src/daemon/guardian-action-generators.ts +34 -14
  434. package/src/daemon/handlers/config-model.test.ts +86 -0
  435. package/src/daemon/handlers/config-model.ts +65 -12
  436. package/src/daemon/handlers/conversations.ts +9 -2
  437. package/src/daemon/handlers/shared.ts +39 -11
  438. package/src/daemon/handlers/skills.ts +7 -3
  439. package/src/daemon/handlers/slack-channel-oauth-install.ts +197 -0
  440. package/src/daemon/lifecycle.ts +109 -82
  441. package/src/daemon/message-types/computer-use.ts +2 -34
  442. package/src/daemon/message-types/conversations.ts +63 -0
  443. package/src/daemon/message-types/messages.ts +21 -1
  444. package/src/daemon/message-types/trust.ts +0 -2
  445. package/src/daemon/parse-actual-tokens-from-error.test.ts +57 -1
  446. package/src/daemon/parse-actual-tokens-from-error.ts +66 -0
  447. package/src/daemon/pkb-context-tracker.test.ts +169 -0
  448. package/src/daemon/pkb-context-tracker.ts +125 -0
  449. package/src/daemon/pkb-reminder-builder.test.ts +70 -0
  450. package/src/daemon/pkb-reminder-builder.ts +31 -0
  451. package/src/daemon/providers-setup.ts +6 -0
  452. package/src/daemon/server.ts +122 -12
  453. package/src/daemon/shutdown-handlers.ts +2 -12
  454. package/src/daemon/tool-side-effects.ts +14 -65
  455. package/src/daemon/web-search-history.ts +126 -0
  456. package/src/events/domain-events.ts +0 -1
  457. package/src/filing/filing-service.ts +9 -10
  458. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +160 -0
  459. package/src/heartbeat/heartbeat-service.ts +99 -28
  460. package/src/home/__tests__/feed-population-integration.test.ts +312 -0
  461. package/src/home/__tests__/feed-scheduler.test.ts +39 -11
  462. package/src/home/__tests__/rollup-producer.test.ts +44 -0
  463. package/src/home/assistant-feed-authoring.ts +4 -0
  464. package/src/home/emit-feed-event.ts +11 -0
  465. package/src/home/feed-scheduler.ts +20 -4
  466. package/src/home/feed-types.ts +97 -4
  467. package/src/home/relationship-state-writer.ts +2 -2
  468. package/src/home/rewrite-command-preview.ts +66 -0
  469. package/src/home/rollup-producer.ts +34 -5
  470. package/src/home/suggested-prompts.ts +101 -0
  471. package/src/ipc/__tests__/attachment-ipc.test.ts +213 -0
  472. package/src/ipc/__tests__/browser-ipc.test.ts +339 -0
  473. package/src/ipc/__tests__/cache-ipc.test.ts +266 -0
  474. package/src/ipc/__tests__/socket-path.test.ts +34 -0
  475. package/src/ipc/__tests__/task-ipc.test.ts +577 -0
  476. package/src/ipc/__tests__/ui-request-route.test.ts +495 -0
  477. package/src/ipc/__tests__/watcher-ipc.test.ts +295 -0
  478. package/src/ipc/cli-client.ts +2 -1
  479. package/src/ipc/cli-server.ts +26 -8
  480. package/src/ipc/gateway-client.ts +6 -3
  481. package/src/ipc/routes/attachment.ts +114 -0
  482. package/src/ipc/routes/browser-context.ts +63 -0
  483. package/src/ipc/routes/browser.ts +97 -0
  484. package/src/ipc/routes/cache.ts +96 -0
  485. package/src/ipc/routes/get-contact.ts +16 -0
  486. package/src/ipc/routes/index.ts +31 -1
  487. package/src/ipc/routes/list-clients.ts +31 -0
  488. package/src/ipc/routes/merge-contacts.ts +17 -0
  489. package/src/ipc/routes/notification.ts +133 -0
  490. package/src/ipc/routes/rename-conversation.ts +59 -0
  491. package/src/ipc/routes/search-contacts.ts +19 -0
  492. package/src/ipc/routes/task-queue.ts +226 -0
  493. package/src/ipc/routes/task.ts +173 -0
  494. package/src/ipc/routes/ui-request.ts +50 -0
  495. package/src/ipc/routes/upsert-contact.ts +25 -0
  496. package/src/ipc/routes/watcher.ts +203 -0
  497. package/src/ipc/socket-path.ts +76 -0
  498. package/src/media/app-icon-generator.ts +23 -46
  499. package/src/media/avatar-router.ts +26 -41
  500. package/src/media/gemini-image-service.ts +8 -41
  501. package/src/media/image-credentials.ts +73 -0
  502. package/src/media/image-service.ts +85 -0
  503. package/src/media/openai-image-service.ts +131 -0
  504. package/src/media/types.ts +46 -0
  505. package/src/memory/__tests__/conversation-analyze-job.test.ts +9 -8
  506. package/src/memory/__tests__/conversation-group-migration.test.ts +99 -0
  507. package/src/memory/admin.ts +18 -0
  508. package/src/memory/conversation-analyze-job.ts +14 -13
  509. package/src/memory/conversation-attention-store.ts +13 -6
  510. package/src/memory/conversation-crud.ts +133 -3
  511. package/src/memory/conversation-group-migration.ts +38 -6
  512. package/src/memory/conversation-queries.ts +57 -4
  513. package/src/memory/conversation-title-service.ts +32 -4
  514. package/src/memory/db-init.ts +10 -0
  515. package/src/memory/embedding-backend.ts +1 -1
  516. package/src/memory/embedding-gemini.test.ts +41 -2
  517. package/src/memory/embedding-gemini.ts +6 -1
  518. package/src/memory/graph/bootstrap.test.ts +282 -0
  519. package/src/memory/graph/bootstrap.ts +8 -5
  520. package/src/memory/graph/compaction.ts +299 -0
  521. package/src/memory/graph/consolidation.ts +4 -4
  522. package/src/memory/graph/conversation-graph-memory.ts +89 -29
  523. package/src/memory/graph/extraction.test.ts +272 -2
  524. package/src/memory/graph/extraction.ts +183 -53
  525. package/src/memory/graph/graph-search.test.ts +93 -0
  526. package/src/memory/graph/graph-search.ts +4 -1
  527. package/src/memory/graph/inspect.ts +2 -2
  528. package/src/memory/graph/narrative.ts +2 -2
  529. package/src/memory/graph/pattern-scan.ts +2 -2
  530. package/src/memory/graph/retriever.test.ts +459 -0
  531. package/src/memory/graph/retriever.ts +237 -48
  532. package/src/memory/graph/store.ts +41 -0
  533. package/src/memory/graph/tool-handlers.ts +27 -0
  534. package/src/memory/graph/tools.ts +6 -1
  535. package/src/memory/indexer.ts +5 -5
  536. package/src/memory/job-handlers/conversation-starters.ts +23 -20
  537. package/src/memory/job-handlers/summarization.ts +2 -2
  538. package/src/memory/job-utils.ts +7 -1
  539. package/src/memory/jobs/embed-pkb-file.test.ts +168 -0
  540. package/src/memory/jobs/embed-pkb-file.ts +54 -0
  541. package/src/memory/jobs-store.ts +44 -3
  542. package/src/memory/jobs-worker.ts +4 -0
  543. package/src/memory/migrations/041-approval-prompt-ts-tracker.ts +26 -0
  544. package/src/memory/migrations/140-backfill-usage-cache-accounting.ts +1 -1
  545. package/src/memory/migrations/149-oauth-tables.ts +1 -0
  546. package/src/memory/migrations/220-normalize-user-file-by-principal.ts +2 -2
  547. package/src/memory/migrations/222-strip-placeholder-sentinels-from-messages.ts +82 -0
  548. package/src/memory/migrations/223-schedule-script-column.ts +11 -0
  549. package/src/memory/migrations/224-oauth-providers-managed-service-is-paid.ts +24 -0
  550. package/src/memory/migrations/225-oauth-providers-available-scopes.ts +13 -0
  551. package/src/memory/migrations/index.ts +5 -0
  552. package/src/memory/pkb/pkb-index.test.ts +369 -0
  553. package/src/memory/pkb/pkb-index.ts +255 -0
  554. package/src/memory/pkb/pkb-reconcile.test.ts +252 -0
  555. package/src/memory/pkb/pkb-reconcile.ts +148 -0
  556. package/src/memory/pkb/pkb-search.test.ts +499 -0
  557. package/src/memory/pkb/pkb-search.ts +159 -0
  558. package/src/memory/pkb/types.ts +53 -0
  559. package/src/memory/qdrant-client.test.ts +60 -0
  560. package/src/memory/qdrant-client.ts +147 -1
  561. package/src/memory/schema/infrastructure.ts +1 -0
  562. package/src/memory/schema/oauth.ts +4 -1
  563. package/src/memory/slack-thread-store.ts +37 -0
  564. package/src/messaging/providers/gmail/adapter.ts +6 -16
  565. package/src/messaging/providers/gmail/client.ts +22 -0
  566. package/src/messaging/providers/gmail/types.ts +7 -0
  567. package/src/messaging/providers/slack/adapter.ts +14 -2
  568. package/src/messaging/providers/slack/backfill.test.ts +257 -0
  569. package/src/messaging/providers/slack/backfill.ts +101 -0
  570. package/src/messaging/providers/slack/message-metadata.test.ts +316 -0
  571. package/src/messaging/providers/slack/message-metadata.ts +123 -0
  572. package/src/messaging/providers/slack/render-transcript.test.ts +1421 -0
  573. package/src/messaging/providers/slack/render-transcript.ts +501 -0
  574. package/src/messaging/style-analyzer.ts +5 -2
  575. package/src/notifications/README.md +9 -5
  576. package/src/notifications/conversation-pairing.ts +78 -19
  577. package/src/notifications/copy-composer.ts +0 -5
  578. package/src/notifications/decision-engine.ts +3 -9
  579. package/src/notifications/emit-signal.ts +1 -1
  580. package/src/notifications/preference-extractor.ts +2 -6
  581. package/src/notifications/signal.ts +1 -2
  582. package/src/oauth/AGENTS.md +1 -1
  583. package/src/oauth/__tests__/identity-verifier.test.ts +2 -1
  584. package/src/oauth/connect-orchestrator.ts +8 -34
  585. package/src/oauth/connect-types.ts +6 -10
  586. package/src/oauth/manual-token-connection.ts +23 -0
  587. package/src/oauth/oauth-store.ts +31 -14
  588. package/src/oauth/platform-connection.test.ts +47 -0
  589. package/src/oauth/platform-connection.ts +15 -5
  590. package/src/oauth/provider-serializer.ts +6 -1
  591. package/src/oauth/seed-providers.ts +56 -106
  592. package/src/outbound-proxy/http-forwarder.ts +9 -0
  593. package/src/permissions/approval-policy.test.ts +1223 -0
  594. package/src/permissions/approval-policy.ts +309 -0
  595. package/src/permissions/arg-parser.test.ts +161 -0
  596. package/src/permissions/arg-parser.ts +141 -0
  597. package/src/permissions/bash-risk-classifier.test.ts +1620 -0
  598. package/src/permissions/bash-risk-classifier.ts +950 -0
  599. package/src/permissions/checker.ts +348 -711
  600. package/src/permissions/command-registry.test.ts +774 -0
  601. package/src/permissions/command-registry.ts +1005 -0
  602. package/src/permissions/defaults.ts +28 -79
  603. package/src/permissions/file-risk-classifier.test.ts +535 -0
  604. package/src/permissions/file-risk-classifier.ts +274 -0
  605. package/src/permissions/gateway-threshold-reader.ts +196 -0
  606. package/src/permissions/prompter.ts +4 -0
  607. package/src/permissions/risk-types.ts +262 -0
  608. package/src/permissions/schedule-risk-classifier.test.ts +129 -0
  609. package/src/permissions/schedule-risk-classifier.ts +85 -0
  610. package/src/permissions/secret-prompter.ts +53 -2
  611. package/src/permissions/shell-identity.ts +2 -42
  612. package/src/permissions/skill-risk-classifier.test.ts +311 -0
  613. package/src/permissions/skill-risk-classifier.ts +214 -0
  614. package/src/permissions/trust-client.ts +52 -25
  615. package/src/permissions/trust-store-interface.ts +1 -6
  616. package/src/permissions/trust-store.ts +161 -62
  617. package/src/permissions/types.ts +25 -14
  618. package/src/permissions/web-risk-classifier.test.ts +170 -0
  619. package/src/permissions/web-risk-classifier.ts +89 -0
  620. package/src/permissions/workspace-policy.ts +9 -19
  621. package/src/platform/client.ts +19 -1
  622. package/src/plugins/defaults/circuit-breaker.ts +146 -0
  623. package/src/plugins/defaults/compaction.ts +145 -0
  624. package/src/plugins/defaults/empty-response.ts +126 -0
  625. package/src/plugins/defaults/history-repair.ts +85 -0
  626. package/src/plugins/defaults/index.ts +116 -0
  627. package/src/plugins/defaults/injectors.ts +491 -0
  628. package/src/plugins/defaults/llm-call.ts +82 -0
  629. package/src/plugins/defaults/memory-retrieval.ts +226 -0
  630. package/src/plugins/defaults/overflow-reduce.ts +181 -0
  631. package/src/plugins/defaults/persistence.ts +129 -0
  632. package/src/plugins/defaults/title-generate.ts +95 -0
  633. package/src/plugins/defaults/token-estimate.ts +104 -0
  634. package/src/plugins/defaults/tool-error.ts +126 -0
  635. package/src/plugins/defaults/tool-execute.ts +89 -0
  636. package/src/plugins/defaults/tool-result-truncate.ts +88 -0
  637. package/src/plugins/pipeline.ts +316 -0
  638. package/src/plugins/plugin-skill-contributions.ts +292 -0
  639. package/src/plugins/registry.ts +241 -0
  640. package/src/plugins/types.ts +1134 -0
  641. package/src/plugins/user-loader.ts +177 -0
  642. package/src/prompts/persona-resolver.ts +3 -3
  643. package/src/prompts/system-prompt.ts +19 -20
  644. package/src/prompts/templates/BOOTSTRAP.md +27 -77
  645. package/src/prompts/templates/SOUL.md +2 -2
  646. package/src/prompts/update-bulletin-job.ts +190 -0
  647. package/src/providers/__tests__/context-overflow-error.test.ts +328 -0
  648. package/src/providers/__tests__/provider-env-vars.test.ts +102 -0
  649. package/src/providers/__tests__/retry-callsite.test.ts +424 -0
  650. package/src/providers/anthropic/client.ts +183 -14
  651. package/src/providers/call-site-routing.ts +71 -0
  652. package/src/providers/gemini/client.ts +65 -2
  653. package/src/providers/managed-proxy/constants.ts +2 -1
  654. package/src/providers/model-catalog.ts +524 -33
  655. package/src/providers/model-intents.ts +4 -4
  656. package/src/providers/openai/chat-completions-provider.ts +57 -1
  657. package/src/providers/openai/responses-provider.ts +86 -9
  658. package/src/providers/openrouter/client.ts +80 -9
  659. package/src/providers/provider-env-vars.ts +56 -0
  660. package/src/providers/provider-send-message.ts +22 -5
  661. package/src/providers/ratelimit.ts +4 -0
  662. package/src/providers/registry.ts +19 -8
  663. package/src/providers/retry.ts +174 -39
  664. package/src/providers/speech-to-text/__tests__/resolve.test.ts +55 -0
  665. package/src/providers/speech-to-text/deepgram-realtime.test.ts +61 -0
  666. package/src/providers/speech-to-text/deepgram-realtime.ts +57 -0
  667. package/src/providers/speech-to-text/google-gemini-live-stream.ts +4 -4
  668. package/src/providers/speech-to-text/provider-catalog.ts +17 -0
  669. package/src/providers/speech-to-text/resolve.ts +7 -0
  670. package/src/providers/speech-to-text/xai-realtime.test.ts +646 -0
  671. package/src/providers/speech-to-text/xai-realtime.ts +821 -0
  672. package/src/providers/speech-to-text/xai.test.ts +155 -0
  673. package/src/providers/speech-to-text/xai.ts +97 -0
  674. package/src/providers/types.ts +93 -3
  675. package/src/runtime/AGENTS.md +27 -18
  676. package/src/runtime/__tests__/agent-wake.test.ts +43 -2
  677. package/src/runtime/__tests__/browser-extension-pair-routes.test.ts +3 -3
  678. package/src/runtime/__tests__/client-registry.test.ts +293 -0
  679. package/src/runtime/__tests__/interactive-ui.test.ts +673 -0
  680. package/src/runtime/agent-wake.ts +63 -22
  681. package/src/runtime/auth/route-policy.ts +4 -0
  682. package/src/runtime/btw-sidechain.ts +13 -3
  683. package/src/runtime/channel-reply-delivery.ts +106 -2
  684. package/src/runtime/client-registry.ts +261 -0
  685. package/src/runtime/decision-token.ts +116 -0
  686. package/src/runtime/gateway-client.ts +2 -2
  687. package/src/runtime/http-router.ts +32 -0
  688. package/src/runtime/http-server.ts +129 -9
  689. package/src/runtime/http-types.ts +23 -3
  690. package/src/runtime/interactive-ui.ts +362 -0
  691. package/src/runtime/invite-instruction-generator.ts +2 -2
  692. package/src/runtime/migrations/__tests__/gcs-signed-url.test.ts +176 -0
  693. package/src/runtime/migrations/__tests__/vbundle-metadata-merge-integration.test.ts +390 -0
  694. package/src/runtime/migrations/__tests__/vbundle-metadata-merge.test.ts +221 -0
  695. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +1540 -0
  696. package/src/runtime/migrations/__tests__/vbundle-streaming-validator.test.ts +453 -0
  697. package/src/runtime/migrations/__tests__/vbundle-tar-stream.test.ts +222 -0
  698. package/src/runtime/migrations/gcs-signed-url.ts +162 -0
  699. package/src/runtime/migrations/vbundle-builder.ts +1 -22
  700. package/src/runtime/migrations/vbundle-importer.ts +154 -9
  701. package/src/runtime/migrations/vbundle-metadata-merge.ts +124 -0
  702. package/src/runtime/migrations/vbundle-streaming-importer.ts +2522 -0
  703. package/src/runtime/migrations/vbundle-streaming-validator.ts +244 -0
  704. package/src/runtime/migrations/vbundle-tar-stream.ts +217 -0
  705. package/src/runtime/migrations/vbundle-validator.ts +15 -6
  706. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +111 -0
  707. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +114 -75
  708. package/src/runtime/routes/__tests__/migration-vellum-metadata-reconcile.test.ts +246 -0
  709. package/src/runtime/routes/approval-prompt-ts-tracker.ts +78 -0
  710. package/src/runtime/routes/approval-routes.ts +29 -17
  711. package/src/runtime/routes/approval-strategies/guardian-callback-strategy.ts +9 -0
  712. package/src/runtime/routes/avatar-routes.ts +20 -4
  713. package/src/runtime/routes/browser-extension-pair-routes.ts +27 -8
  714. package/src/runtime/routes/btw-routes.ts +1 -4
  715. package/src/runtime/routes/conversation-management-routes.ts +20 -2
  716. package/src/runtime/routes/conversation-routes.ts +351 -138
  717. package/src/runtime/routes/debug-routes.ts +1 -1
  718. package/src/runtime/routes/diagnostics-routes.ts +6 -4
  719. package/src/runtime/routes/events-routes.ts +16 -0
  720. package/src/runtime/routes/guardian-approval-interception.ts +33 -3
  721. package/src/runtime/routes/guardian-approval-prompt.ts +13 -3
  722. package/src/runtime/routes/home-feed-routes.ts +120 -2
  723. package/src/runtime/routes/inbound-message-handler.ts +987 -2
  724. package/src/runtime/routes/inbound-stages/background-dispatch.test.ts +113 -2
  725. package/src/runtime/routes/inbound-stages/background-dispatch.ts +61 -3
  726. package/src/runtime/routes/inbound-stages/edit-intercept.ts +129 -6
  727. package/src/runtime/routes/integrations/slack/channel.ts +25 -3
  728. package/src/runtime/routes/llm-context-normalization.ts +23 -1
  729. package/src/runtime/routes/memory-item-routes.test.ts +1 -0
  730. package/src/runtime/routes/migration-routes.ts +720 -127
  731. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +284 -0
  732. package/src/runtime/routes/playground/__tests__/guard.test.ts +80 -0
  733. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +294 -0
  734. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +271 -0
  735. package/src/runtime/routes/playground/__tests__/seed-conversation.test.ts +202 -0
  736. package/src/runtime/routes/playground/__tests__/seeded-conversations.test.ts +309 -0
  737. package/src/runtime/routes/playground/__tests__/state.test.ts +224 -0
  738. package/src/runtime/routes/playground/conversation-not-found.ts +29 -0
  739. package/src/runtime/routes/playground/deps.ts +56 -0
  740. package/src/runtime/routes/playground/force-compact.ts +73 -0
  741. package/src/runtime/routes/playground/guard.ts +37 -0
  742. package/src/runtime/routes/playground/index.ts +28 -0
  743. package/src/runtime/routes/playground/inject-failures.ts +159 -0
  744. package/src/runtime/routes/playground/reset-circuit.ts +115 -0
  745. package/src/runtime/routes/playground/seed-conversation.ts +139 -0
  746. package/src/runtime/routes/playground/seeded-conversations.ts +78 -0
  747. package/src/runtime/routes/playground/state.ts +78 -0
  748. package/src/runtime/routes/schedule-routes.ts +89 -8
  749. package/src/runtime/routes/settings-routes.ts +4 -2
  750. package/src/runtime/routes/trust-rules-routes.ts +30 -14
  751. package/src/runtime/routes/work-items-routes.test.ts +1 -1
  752. package/src/runtime/routes/work-items-routes.ts +3 -2
  753. package/src/runtime/services/__tests__/analyze-conversation.test.ts +25 -43
  754. package/src/runtime/services/analyze-conversation.ts +12 -16
  755. package/src/runtime/skill-route-registry.ts +97 -15
  756. package/src/schedule/run-script.ts +68 -0
  757. package/src/schedule/schedule-store.ts +7 -1
  758. package/src/schedule/scheduler.ts +56 -8
  759. package/src/security/__tests__/provider-key-env-fallback.test.ts +119 -0
  760. package/src/security/__tests__/untrusted-content.test.ts +109 -0
  761. package/src/security/oauth2.ts +98 -35
  762. package/src/security/secure-keys.ts +7 -8
  763. package/src/security/token-manager.ts +27 -13
  764. package/src/security/untrusted-content.ts +102 -0
  765. package/src/skills/catalog-cache.ts +35 -9
  766. package/src/skills/catalog-install.ts +31 -3
  767. package/src/skills/skill-cache-store.ts +97 -0
  768. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +76 -0
  769. package/src/stt/daemon-batch-transcriber.ts +33 -0
  770. package/src/stt/stt-stream-session.ts +8 -1
  771. package/src/stt/types.ts +5 -1
  772. package/src/subagent/manager.ts +41 -13
  773. package/src/tasks/ephemeral-permissions.ts +9 -4
  774. package/src/telemetry/usage-telemetry-reporter.ts +27 -5
  775. package/src/tools/browser/__tests__/browser-status.test.ts +234 -2
  776. package/src/tools/browser/browser-execution.ts +150 -54
  777. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +230 -0
  778. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +146 -3
  779. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +22 -0
  780. package/src/tools/browser/cdp-client/extension-cdp-client.ts +54 -3
  781. package/src/tools/browser/cdp-client/factory.ts +15 -4
  782. package/src/tools/credentials/tool-policy.ts +39 -5
  783. package/src/tools/credentials/vault.ts +9 -4
  784. package/src/tools/executor.ts +129 -73
  785. package/src/tools/filesystem/write.ts +52 -0
  786. package/src/tools/host-terminal/host-shell.ts +45 -5
  787. package/src/tools/memory/register.test.ts +185 -0
  788. package/src/tools/memory/register.ts +3 -1
  789. package/src/tools/network/script-proxy/session-manager.ts +37 -1
  790. package/src/tools/network/web-fetch.ts +20 -10
  791. package/src/tools/network/web-search.ts +19 -4
  792. package/src/tools/permission-checker.ts +116 -46
  793. package/src/tools/policy-context.ts +29 -8
  794. package/src/tools/registry.ts +195 -6
  795. package/src/tools/schedule/create.ts +23 -8
  796. package/src/tools/schedule/update.ts +3 -1
  797. package/src/tools/secret-detection-handler.ts +0 -51
  798. package/src/tools/side-effects.ts +0 -11
  799. package/src/tools/skills/execute.ts +2 -2
  800. package/src/tools/skills/sandbox-runner.ts +5 -2
  801. package/src/tools/system/avatar-generator.ts +6 -2
  802. package/src/tools/terminal/backends/native.ts +51 -2
  803. package/src/tools/terminal/safe-env.ts +3 -2
  804. package/src/tools/terminal/shell.ts +1 -0
  805. package/src/tools/tool-manifest.ts +6 -21
  806. package/src/tools/types.ts +40 -5
  807. package/src/tools/verification-control-plane-policy.ts +1 -1
  808. package/src/tts/__tests__/provider-adapters.test.ts +240 -13
  809. package/src/tts/provider-catalog.ts +18 -0
  810. package/src/tts/providers/index.ts +2 -0
  811. package/src/tts/providers/xai-provider.ts +224 -0
  812. package/src/tts/types.ts +46 -0
  813. package/src/types/tar-stream.d.ts +66 -0
  814. package/src/util/json.ts +17 -0
  815. package/src/util/platform.ts +9 -4
  816. package/src/util/pricing.ts +41 -8
  817. package/src/watcher/engine.ts +1 -1
  818. package/src/watcher/providers/google-calendar.ts +134 -8
  819. package/src/watcher/providers/outlook-calendar.ts +42 -2
  820. package/src/workspace/git-service.ts +23 -4
  821. package/src/workspace/migrations/006-services-config.ts +2 -4
  822. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +2 -3
  823. package/src/workspace/migrations/038-unify-llm-callsite-configs.ts +516 -0
  824. package/src/workspace/migrations/039-drop-legacy-llm-keys.ts +171 -0
  825. package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +154 -0
  826. package/src/workspace/migrations/041-backfill-google-gmail-settings-scope.ts +56 -0
  827. package/src/workspace/migrations/042-fix-backfill-google-gmail-settings-scope.ts +70 -0
  828. package/src/workspace/migrations/043-release-notes-latex-rendering.ts +75 -0
  829. package/src/workspace/migrations/044-bump-stale-provider-stream-timeout.ts +51 -0
  830. package/src/workspace/migrations/045-release-notes-meet-avatar.ts +130 -0
  831. package/src/workspace/migrations/046-seed-conversation-starters-callsite.ts +108 -0
  832. package/src/workspace/migrations/047-remove-watch-callsites.ts +54 -0
  833. package/src/workspace/migrations/048-remove-workspace-hooks.ts +81 -0
  834. package/src/workspace/migrations/049-release-notes-default-sonnet.ts +80 -0
  835. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +86 -0
  836. package/src/workspace/migrations/051-seed-conversation-summarization-callsite.ts +128 -0
  837. package/src/workspace/migrations/AGENTS.md +1 -1
  838. package/src/workspace/migrations/registry.ts +28 -0
  839. package/src/workspace/provider-commit-message-generator.ts +19 -38
  840. package/tsconfig.json +1 -1
  841. package/hook-templates/debug-prompt-logger/hook.json +0 -7
  842. package/hook-templates/debug-prompt-logger/run.sh +0 -66
  843. package/src/__tests__/context-overflow-approval.test.ts +0 -156
  844. package/src/__tests__/gmail-archive-fallback.test.ts +0 -193
  845. package/src/__tests__/gmail-archive-gate.test.ts +0 -246
  846. package/src/__tests__/gmail-preferences.test.ts +0 -117
  847. package/src/__tests__/hooks-blocking.test.ts +0 -178
  848. package/src/__tests__/hooks-cli.test.ts +0 -182
  849. package/src/__tests__/hooks-config.test.ts +0 -108
  850. package/src/__tests__/hooks-discovery.test.ts +0 -211
  851. package/src/__tests__/hooks-integration.test.ts +0 -196
  852. package/src/__tests__/hooks-manager.test.ts +0 -226
  853. package/src/__tests__/hooks-runner.test.ts +0 -175
  854. package/src/__tests__/hooks-settings.test.ts +0 -160
  855. package/src/__tests__/hooks-templates.test.ts +0 -169
  856. package/src/__tests__/hooks-ts-runner.test.ts +0 -170
  857. package/src/__tests__/hooks-watch.test.ts +0 -112
  858. package/src/__tests__/notification-schedule-dedup.test.ts +0 -213
  859. package/src/__tests__/oauth-scope-policy.test.ts +0 -180
  860. package/src/__tests__/outlook-attachments.test.ts +0 -301
  861. package/src/__tests__/outlook-automation-tools.test.ts +0 -425
  862. package/src/__tests__/outlook-categories.test.ts +0 -212
  863. package/src/__tests__/outlook-compose-tools.test.ts +0 -325
  864. package/src/__tests__/outlook-declutter-tools.test.ts +0 -585
  865. package/src/__tests__/outlook-follow-up.test.ts +0 -196
  866. package/src/__tests__/outlook-trash.test.ts +0 -77
  867. package/src/__tests__/outlook-unsubscribe.test.ts +0 -279
  868. package/src/__tests__/send-notification-tool.test.ts +0 -83
  869. package/src/__tests__/update-bulletin-format.test.ts +0 -181
  870. package/src/__tests__/update-bulletin-state.test.ts +0 -135
  871. package/src/__tests__/update-bulletin.test.ts +0 -478
  872. package/src/__tests__/update-template-contract.test.ts +0 -29
  873. package/src/cli/commands/doctor.ts +0 -341
  874. package/src/cli/commands/shotgun.ts +0 -266
  875. package/src/config/bundled-skills/browser/SKILL.md +0 -88
  876. package/src/config/bundled-skills/browser/TOOLS.json +0 -516
  877. package/src/config/bundled-skills/browser/tools/browser-attach.ts +0 -12
  878. package/src/config/bundled-skills/browser/tools/browser-click.ts +0 -12
  879. package/src/config/bundled-skills/browser/tools/browser-close.ts +0 -12
  880. package/src/config/bundled-skills/browser/tools/browser-detach.ts +0 -12
  881. package/src/config/bundled-skills/browser/tools/browser-extract.ts +0 -12
  882. package/src/config/bundled-skills/browser/tools/browser-fill-credential.ts +0 -12
  883. package/src/config/bundled-skills/browser/tools/browser-hover.ts +0 -12
  884. package/src/config/bundled-skills/browser/tools/browser-navigate.ts +0 -12
  885. package/src/config/bundled-skills/browser/tools/browser-press-key.ts +0 -12
  886. package/src/config/bundled-skills/browser/tools/browser-screenshot.ts +0 -12
  887. package/src/config/bundled-skills/browser/tools/browser-scroll.ts +0 -12
  888. package/src/config/bundled-skills/browser/tools/browser-select-option.ts +0 -12
  889. package/src/config/bundled-skills/browser/tools/browser-snapshot.ts +0 -12
  890. package/src/config/bundled-skills/browser/tools/browser-status.ts +0 -12
  891. package/src/config/bundled-skills/browser/tools/browser-type.ts +0 -12
  892. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +0 -49
  893. package/src/config/bundled-skills/browser/tools/browser-wait-for.ts +0 -12
  894. package/src/config/bundled-skills/chatgpt-import/SKILL.md +0 -27
  895. package/src/config/bundled-skills/chatgpt-import/TOOLS.json +0 -27
  896. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +0 -378
  897. package/src/config/bundled-skills/conversations/SKILL.md +0 -20
  898. package/src/config/bundled-skills/conversations/TOOLS.json +0 -23
  899. package/src/config/bundled-skills/conversations/tools/rename-conversation.ts +0 -66
  900. package/src/config/bundled-skills/gmail/SKILL.md +0 -221
  901. package/src/config/bundled-skills/gmail/TOOLS.json +0 -588
  902. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +0 -256
  903. package/src/config/bundled-skills/gmail/tools/gmail-attachments.ts +0 -112
  904. package/src/config/bundled-skills/gmail/tools/gmail-draft.ts +0 -44
  905. package/src/config/bundled-skills/gmail/tools/gmail-filters.ts +0 -81
  906. package/src/config/bundled-skills/gmail/tools/gmail-follow-up.ts +0 -108
  907. package/src/config/bundled-skills/gmail/tools/gmail-forward.ts +0 -146
  908. package/src/config/bundled-skills/gmail/tools/gmail-label.ts +0 -53
  909. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +0 -347
  910. package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +0 -59
  911. package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +0 -82
  912. package/src/config/bundled-skills/gmail/tools/gmail-send-draft.ts +0 -26
  913. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +0 -347
  914. package/src/config/bundled-skills/gmail/tools/gmail-trash.ts +0 -29
  915. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +0 -122
  916. package/src/config/bundled-skills/gmail/tools/gmail-vacation.ts +0 -67
  917. package/src/config/bundled-skills/gmail/tools/scan-result-store.ts +0 -100
  918. package/src/config/bundled-skills/gmail/tools/shared.ts +0 -47
  919. package/src/config/bundled-skills/google-calendar/SKILL.md +0 -51
  920. package/src/config/bundled-skills/google-calendar/TOOLS.json +0 -226
  921. package/src/config/bundled-skills/google-calendar/calendar-client.ts +0 -223
  922. package/src/config/bundled-skills/google-calendar/tools/calendar-check-availability.ts +0 -27
  923. package/src/config/bundled-skills/google-calendar/tools/calendar-create-event.ts +0 -48
  924. package/src/config/bundled-skills/google-calendar/tools/calendar-get-event.ts +0 -19
  925. package/src/config/bundled-skills/google-calendar/tools/calendar-list-events.ts +0 -36
  926. package/src/config/bundled-skills/google-calendar/tools/calendar-rsvp.ts +0 -58
  927. package/src/config/bundled-skills/google-calendar/tools/shared.ts +0 -17
  928. package/src/config/bundled-skills/google-calendar/types.ts +0 -97
  929. package/src/config/bundled-skills/heartbeat/SKILL.md +0 -43
  930. package/src/config/bundled-skills/notifications/SKILL.md +0 -40
  931. package/src/config/bundled-skills/notifications/TOOLS.json +0 -80
  932. package/src/config/bundled-skills/notifications/tools/send-notification.ts +0 -152
  933. package/src/config/bundled-skills/notifications/tools/shared.ts +0 -13
  934. package/src/config/bundled-skills/outlook/SKILL.md +0 -196
  935. package/src/config/bundled-skills/outlook/TOOLS.json +0 -530
  936. package/src/config/bundled-skills/outlook/tools/outlook-attachments.ts +0 -85
  937. package/src/config/bundled-skills/outlook/tools/outlook-categories.ts +0 -77
  938. package/src/config/bundled-skills/outlook/tools/outlook-draft.ts +0 -84
  939. package/src/config/bundled-skills/outlook/tools/outlook-follow-up.ts +0 -94
  940. package/src/config/bundled-skills/outlook/tools/outlook-forward.ts +0 -49
  941. package/src/config/bundled-skills/outlook/tools/outlook-outreach-scan.ts +0 -237
  942. package/src/config/bundled-skills/outlook/tools/outlook-rules.ts +0 -161
  943. package/src/config/bundled-skills/outlook/tools/outlook-send-draft.ts +0 -32
  944. package/src/config/bundled-skills/outlook/tools/outlook-sender-digest.ts +0 -272
  945. package/src/config/bundled-skills/outlook/tools/outlook-trash.ts +0 -29
  946. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +0 -129
  947. package/src/config/bundled-skills/outlook/tools/outlook-vacation.ts +0 -87
  948. package/src/config/bundled-skills/outlook/tools/shared.ts +0 -20
  949. package/src/config/bundled-skills/outlook-calendar/SKILL.md +0 -51
  950. package/src/config/bundled-skills/outlook-calendar/TOOLS.json +0 -221
  951. package/src/config/bundled-skills/outlook-calendar/calendar-client.ts +0 -252
  952. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-check-availability.ts +0 -53
  953. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-create-event.ts +0 -74
  954. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-get-event.ts +0 -18
  955. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-list-events.ts +0 -46
  956. package/src/config/bundled-skills/outlook-calendar/tools/outlook-calendar-rsvp.ts +0 -36
  957. package/src/config/bundled-skills/outlook-calendar/tools/shared.ts +0 -17
  958. package/src/config/bundled-skills/outlook-calendar/types.ts +0 -120
  959. package/src/config/bundled-skills/screen-watch/SKILL.md +0 -27
  960. package/src/config/bundled-skills/screen-watch/TOOLS.json +0 -35
  961. package/src/config/bundled-skills/screen-watch/tools/start-screen-watch.ts +0 -12
  962. package/src/config/bundled-skills/skills-catalog/SKILL.md +0 -84
  963. package/src/config/bundled-skills/slack/SKILL.md +0 -108
  964. package/src/config/bundled-skills/tasks/SKILL.md +0 -37
  965. package/src/config/bundled-skills/tasks/TOOLS.json +0 -353
  966. package/src/config/bundled-skills/tasks/icon.svg +0 -34
  967. package/src/config/bundled-skills/tasks/tools/task-delete.ts +0 -12
  968. package/src/config/bundled-skills/tasks/tools/task-list-add.ts +0 -12
  969. package/src/config/bundled-skills/tasks/tools/task-list-remove.ts +0 -12
  970. package/src/config/bundled-skills/tasks/tools/task-list-show.ts +0 -12
  971. package/src/config/bundled-skills/tasks/tools/task-list-update.ts +0 -12
  972. package/src/config/bundled-skills/tasks/tools/task-list.ts +0 -12
  973. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +0 -12
  974. package/src/config/bundled-skills/tasks/tools/task-run.ts +0 -12
  975. package/src/config/bundled-skills/tasks/tools/task-save.ts +0 -12
  976. package/src/config/bundled-skills/watcher/SKILL.md +0 -31
  977. package/src/config/bundled-skills/watcher/TOOLS.json +0 -167
  978. package/src/config/bundled-skills/watcher/tools/watcher-create.ts +0 -12
  979. package/src/config/bundled-skills/watcher/tools/watcher-delete.ts +0 -12
  980. package/src/config/bundled-skills/watcher/tools/watcher-digest.ts +0 -12
  981. package/src/config/bundled-skills/watcher/tools/watcher-list.ts +0 -12
  982. package/src/config/bundled-skills/watcher/tools/watcher-update.ts +0 -12
  983. package/src/daemon/context-overflow-approval.ts +0 -52
  984. package/src/daemon/watch-handler.ts +0 -399
  985. package/src/hooks/cli.ts +0 -253
  986. package/src/hooks/config.ts +0 -100
  987. package/src/hooks/discovery.ts +0 -135
  988. package/src/hooks/manager.ts +0 -179
  989. package/src/hooks/runner.ts +0 -117
  990. package/src/hooks/templates.ts +0 -77
  991. package/src/hooks/types.ts +0 -75
  992. package/src/oauth/scope-policy.ts +0 -89
  993. package/src/prompts/templates/UPDATES.md +0 -50
  994. package/src/prompts/update-bulletin-format.ts +0 -85
  995. package/src/prompts/update-bulletin-state.ts +0 -58
  996. package/src/prompts/update-bulletin-template-path.ts +0 -13
  997. package/src/prompts/update-bulletin.ts +0 -139
  998. package/src/runtime/gateway-internal-client.ts +0 -94
  999. package/src/runtime/routes/watch-routes.ts +0 -156
  1000. package/src/shared/provider-env-vars.ts +0 -19
  1001. package/src/signals/shotgun.ts +0 -203
  1002. package/src/tools/watch/screen-watch.ts +0 -144
  1003. package/src/tools/watch/watch-state.ts +0 -142
  1004. package/src/tools/watcher/create.ts +0 -86
  1005. package/src/tools/watcher/delete.ts +0 -36
  1006. package/src/tools/watcher/digest.ts +0 -54
  1007. package/src/tools/watcher/list.ts +0 -83
  1008. package/src/tools/watcher/update.ts +0 -71
@@ -0,0 +1,2522 @@
1
+ /**
2
+ * Streaming `.vbundle` importer.
3
+ *
4
+ * Buffer-based `commitImport` decompresses the whole archive into RAM and
5
+ * re-walks the tar to write each file — fine for small bundles, OOMs on an
6
+ * 8 GB bundle running on a 3 GB pod. This module orchestrates the streaming
7
+ * primitives (`parseVBundleStream`, `readAndValidateManifest`,
8
+ * `createHashVerifier`) to import a bundle with peak memory bounded by
9
+ * "one tar entry size", not bundle size.
10
+ *
11
+ * Atomicity is provided by a temp-dir + double-rename pattern:
12
+ *
13
+ * 1. Entries land in `${workspaceDir}.import-<uuid>/` as they arrive, each
14
+ * byte verified against the manifest's declared sha256/size before it
15
+ * reaches disk.
16
+ * 2. After every declared entry is accounted for, the live DB connection
17
+ * is closed (`resetDb`) and the real workspace is swapped:
18
+ * `rename(workspaceDir, backupDir)`
19
+ * `rename(tempWorkspaceDir, workspaceDir)`
20
+ * — atomic on POSIX. If the second rename fails we restore the backup.
21
+ * 3. Post-commit side effects (credential import into CES, config/trust
22
+ * cache invalidation) run after the swap. Failures here are non-fatal
23
+ * — the workspace is already consistent.
24
+ *
25
+ * On any error before the rename pair, the temp workspace is removed and the
26
+ * real workspace is left untouched.
27
+ */
28
+
29
+ import { createHash, randomUUID } from "node:crypto";
30
+ import { createWriteStream, existsSync } from "node:fs";
31
+ import {
32
+ copyFile,
33
+ cp,
34
+ lstat,
35
+ mkdir,
36
+ readdir,
37
+ readFile,
38
+ readlink,
39
+ rename,
40
+ rm,
41
+ stat,
42
+ symlink,
43
+ unlink,
44
+ writeFile,
45
+ } from "node:fs/promises";
46
+ import { basename, dirname, join, resolve, sep } from "node:path";
47
+ import { type Readable, Writable } from "node:stream";
48
+ import { pipeline } from "node:stream/promises";
49
+
50
+ import { invalidateConfigCache } from "../../config/loader.js";
51
+ import { sanitizeConfigForTransfer } from "../../config/sanitize-for-transfer.js";
52
+ import { resetDb } from "../../memory/db-connection.js";
53
+ import { clearCache as clearTrustCache } from "../../permissions/trust-store.js";
54
+ import { isGuardianPersonaCustomized } from "../../prompts/persona-resolver.js";
55
+ import { getLogger } from "../../util/logger.js";
56
+ import type { PathResolver } from "./vbundle-import-analyzer.js";
57
+ import {
58
+ CONFIG_ARCHIVE_PATHS,
59
+ type ImportCommitReport,
60
+ type ImportCommitResult,
61
+ type ImportedFileReport,
62
+ type ImportFileAction,
63
+ LEGACY_USER_MD_ARCHIVE_PATH,
64
+ WORKSPACE_PRESERVE_PATHS,
65
+ } from "./vbundle-importer.js";
66
+ import { mergeMetadataPreservingVellum } from "./vbundle-metadata-merge.js";
67
+ import {
68
+ createHashVerifier,
69
+ readAndValidateManifest,
70
+ StreamingValidationError,
71
+ } from "./vbundle-streaming-validator.js";
72
+ import { parseVBundleStream } from "./vbundle-tar-stream.js";
73
+ import type { ManifestType } from "./vbundle-validator.js";
74
+
75
+ const log = getLogger("vbundle-streaming-importer");
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Resource ceilings
79
+ //
80
+ // These cap the streaming importer's exposure to attacker-controlled bundle
81
+ // inputs (e.g. a signed-URL migration from an untrusted source). Both caps
82
+ // are exposed as optional `opts.maxBundleBytes` / `opts.maxBundleEntries`
83
+ // parameters so tests can exercise the abort path with small fixtures —
84
+ // production callers should omit the opts and rely on the defaults.
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Byte ceiling for the cumulative size of all file data streamed from the
89
+ * bundle. 16 GiB gives comfortable headroom over the 8 GB product limit
90
+ * while still bounding worst-case disk use for the temp workspace.
91
+ */
92
+ const DEFAULT_MAX_BUNDLE_BYTES = 16 * 1024 * 1024 * 1024;
93
+
94
+ /**
95
+ * Entry-count ceiling for the bundle. 100k is well above the largest
96
+ * workspace we ship; anything past that is almost certainly an attack or
97
+ * a corrupted archive.
98
+ */
99
+ const DEFAULT_MAX_BUNDLE_ENTRIES = 100_000;
100
+
101
+ /**
102
+ * Prefixes used for scratch dirs the streaming importer creates INSIDE the
103
+ * workspace. Dot-prefixed to stay out of the way of real workspace content.
104
+ * Phase 1 of `swapWorkspaceContents` skips the EXACT scratch basenames for
105
+ * this run (via a `Set<string>` built from the backupDir/tempWorkspaceDir
106
+ * basenames), so a user entry that happens to start with one of these
107
+ * prefixes is still swept into the swap.
108
+ */
109
+ const IMPORT_TEMP_PREFIX = ".import-";
110
+ const IMPORT_BACKUP_PREFIX = ".pre-import-";
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Public API
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export interface StreamProgressEvent {
117
+ /** Archive path of the entry that just finished streaming. */
118
+ archivePath: string;
119
+ /** Total bytes written for that entry (equals manifest-declared size on success). */
120
+ bytesWritten: number;
121
+ /**
122
+ * Zero-based index of the entry in the order it arrived in the tar. The
123
+ * manifest itself is index 0; the first file entry is index 1.
124
+ */
125
+ entryIndex: number;
126
+ }
127
+
128
+ export interface StreamCommitArgs {
129
+ /** Byte source for the `.vbundle`. Typically an HTTP response body. */
130
+ source: Readable;
131
+ /** Maps archive paths to their canonical disk locations. */
132
+ pathResolver: PathResolver;
133
+ /** Absolute path to the real workspace directory. */
134
+ workspaceDir: string;
135
+ /** Optional progress callback invoked after each file entry finishes. */
136
+ onProgress?: (evt: StreamProgressEvent) => void;
137
+ /**
138
+ * Optional callback for importing credentials into CES after the atomic
139
+ * swap succeeds. Failures are treated as non-fatal warnings. When omitted,
140
+ * credentials discovered in the bundle are ignored — the caller
141
+ * (`migration-routes.ts`) is responsible for wiring this.
142
+ */
143
+ importCredentials?: (
144
+ credentials: Array<{ account: string; value: string }>,
145
+ ) => Promise<void>;
146
+ /**
147
+ * Test-only override for the bundle-size ceiling (bytes). Production
148
+ * callers should omit this and rely on the 16 GiB default.
149
+ */
150
+ maxBundleBytes?: number;
151
+ /**
152
+ * Test-only override for the entry-count ceiling. Production callers
153
+ * should omit this and rely on the 100_000 default.
154
+ */
155
+ maxBundleEntries?: number;
156
+ }
157
+
158
+ /**
159
+ * Stream a `.vbundle` archive from `source` and commit it to disk atomically.
160
+ *
161
+ * Returns an `ImportCommitResult` matching the shape produced by the
162
+ * buffer-based `commitImport`, so callers can treat the two paths
163
+ * interchangeably.
164
+ */
165
+ export async function streamCommitImport(
166
+ args: StreamCommitArgs,
167
+ ): Promise<ImportCommitResult> {
168
+ const {
169
+ source,
170
+ pathResolver,
171
+ workspaceDir,
172
+ onProgress,
173
+ importCredentials,
174
+ maxBundleBytes,
175
+ maxBundleEntries,
176
+ } = args;
177
+
178
+ const bundleByteCap = maxBundleBytes ?? DEFAULT_MAX_BUNDLE_BYTES;
179
+ const bundleEntryCap = maxBundleEntries ?? DEFAULT_MAX_BUNDLE_ENTRIES;
180
+
181
+ const realWorkspaceDir = resolve(workspaceDir);
182
+
183
+ // Replay recovery from any prior interrupted import BEFORE we stage
184
+ // new data. If the previous import died mid-swap, the marker / temp /
185
+ // backup still sit in the workspace and recoverInterruptedImport rolls
186
+ // them back. If that rollback is INCOMPLETE (per-entry restore failed
187
+ // and we had to preserve the marker for retry), we must REFUSE to
188
+ // start a new import — this function is about to rewrite the marker
189
+ // at the same path, and a fresh write would orphan the unresolved
190
+ // backup/temp pointers, making the interrupted state unrecoverable.
191
+ //
192
+ // In that case, return write_failed so the caller retries later; an
193
+ // operator can investigate the leftover `.pre-import-*` / `.import-*`
194
+ // dirs in the workspace.
195
+ let recoveryResult: RecoveryResult;
196
+ try {
197
+ recoveryResult = await recoverInterruptedImport(realWorkspaceDir);
198
+ } catch (err) {
199
+ log.error(
200
+ { err, realWorkspaceDir },
201
+ "recoverInterruptedImport threw before streaming import",
202
+ );
203
+ return {
204
+ ok: false,
205
+ reason: "write_failed",
206
+ message: `Pre-import recovery failed: ${errMessage(err)}`,
207
+ };
208
+ }
209
+ if (!recoveryResult.ok) {
210
+ log.error(
211
+ {
212
+ realWorkspaceDir,
213
+ failedCount: recoveryResult.failedCount,
214
+ },
215
+ "Previous import rollback is still unresolved; refusing to start a new import",
216
+ );
217
+ return {
218
+ ok: false,
219
+ reason: "write_failed",
220
+ message:
221
+ `Previous import rollback is still unresolved (${recoveryResult.failedCount} entries failed to restore). ` +
222
+ "Leftover backup/temp dirs are preserved in the workspace; manual intervention may be required before the next import.",
223
+ };
224
+ }
225
+
226
+ // Put scratch dirs (temp staging tree, backup dir) INSIDE the workspace
227
+ // mount so every move during the content-level swap stays on the same
228
+ // filesystem. If they lived as siblings (on the container overlay),
229
+ // every rename in swapWorkspaceContents would cross filesystems and
230
+ // require a full cp+rm of the entire workspace. That defeats the
231
+ // zero-disk fast path and risks ENOSPC on the overlay for large
232
+ // teleports. Dot-prefixed names keep them out of the way of normal
233
+ // content; phase 1 of swapWorkspaceContents filters them out by exact
234
+ // basename so user entries that happen to start with these prefixes
235
+ // are still swept through the swap.
236
+ const tempWorkspaceDir = join(
237
+ realWorkspaceDir,
238
+ `${IMPORT_TEMP_PREFIX}${randomUUID()}`,
239
+ );
240
+
241
+ let manifest: ManifestType | null = null;
242
+ const importedFiles: ImportedFileReport[] = [];
243
+ const warnings: string[] = [];
244
+ const seen = new Set<string>();
245
+ // Credential bodies are small (API keys / tokens) — safe to buffer in
246
+ // memory. They intentionally never touch disk: DefaultPathResolver returns
247
+ // null for `credentials/*`, and CES is the only consumer.
248
+ const bufferedCredentials: Array<{ account: string; value: string }> = [];
249
+ // Track whether the bundle contains at least one `workspace/*` entry that
250
+ // resolves to a real disk path. The atomic swap path (which wipes anything
251
+ // outside WORKSPACE_PRESERVE_PATHS) is only safe to take when this is
252
+ // true — it matches commitImport's `hasWorkspaceEntries` gate. Legacy
253
+ // bundles (e.g. `data/db/*`, `config/*`, `prompts/*`, `skills/*` without a
254
+ // workspace/ prefix) fall through to the in-place write path below.
255
+ let hasWorkspaceNamespacedEntry = false;
256
+ // Accumulates the disk paths of files we staged into the temp workspace
257
+ // from legacy-format archive entries. If the bundle turns out to contain
258
+ // NO workspace/ entries we promote each of these into the live workspace
259
+ // with backup-before-overwrite semantics, matching commitImport's legacy
260
+ // handling. Each tuple carries (tempPath, livePath, archivePath, index).
261
+ const legacyStaged: Array<{
262
+ tempPath: string;
263
+ livePath: string;
264
+ archivePath: string;
265
+ importedFileIndex: number;
266
+ }> = [];
267
+ // Cumulative manifest-declared byte total, accumulated BEFORE each entry
268
+ // is read/written. Checked against `bundleByteCap` pre-write so an
269
+ // oversized entry never lands on disk. We count manifest-declared
270
+ // `expectedEntry.size` (the raw archive bytes) rather than on-disk size
271
+ // so a sanitized config still counts against the cap as originally
272
+ // declared.
273
+ let totalBytesStreamed = 0;
274
+ // Number of file/directory entries processed (not counting the manifest).
275
+ // Compared against `bundleEntryCap`.
276
+ let entryCount = 0;
277
+
278
+ // Create the temp workspace dir up front so any failure between here and
279
+ // the atomic swap can be cleaned up by the catch block below.
280
+ try {
281
+ await mkdir(tempWorkspaceDir, { recursive: true });
282
+ } catch (err) {
283
+ return {
284
+ ok: false,
285
+ reason: "write_failed",
286
+ message: `Failed to create temp workspace dir "${tempWorkspaceDir}": ${errMessage(err)}`,
287
+ };
288
+ }
289
+
290
+ const cleanupTempDir = async (): Promise<void> => {
291
+ try {
292
+ await rm(tempWorkspaceDir, { recursive: true, force: true });
293
+ } catch (err) {
294
+ log.warn(
295
+ { err, tempWorkspaceDir },
296
+ "Failed to clean up temp workspace dir after import failure",
297
+ );
298
+ }
299
+ };
300
+
301
+ // Iterate the tar stream. Any error from gzip/tar/source bubbles out of
302
+ // the generator and lands in the catch block below.
303
+ let entryIndex = 0;
304
+ try {
305
+ const entries = parseVBundleStream(source);
306
+ let expected: Map<string, { sha256: string; size: number }> | null = null;
307
+
308
+ for await (const entry of entries) {
309
+ if (entryIndex === 0) {
310
+ // First entry MUST be manifest.json — readAndValidateManifest
311
+ // enforces that and throws StreamingValidationError otherwise.
312
+ const manifestResult = await readAndValidateManifest(entry);
313
+ manifest = manifestResult.manifest;
314
+ expected = manifestResult.expected;
315
+ // Entry-count ceiling check. The manifest declares every file the
316
+ // bundle claims to contain, so one check here bounds the work the
317
+ // importer is willing to do for this bundle.
318
+ if (manifest.files.length > bundleEntryCap) {
319
+ throw new StreamingValidationError(
320
+ "bundle_too_many_entries",
321
+ `bundle contains more than ${bundleEntryCap} entries (declared: ${manifest.files.length})`,
322
+ );
323
+ }
324
+ entryIndex += 1;
325
+ continue;
326
+ }
327
+
328
+ // After the manifest we must have `expected` populated.
329
+ if (!manifest || !expected) {
330
+ throw new StreamingValidationError(
331
+ "manifest_not_first",
332
+ "Manifest processing did not complete before subsequent entries",
333
+ );
334
+ }
335
+
336
+ // Entry-count ceiling also applies to tar-level entries that arrive
337
+ // in the stream (pax headers, directories, extras). A bundle whose
338
+ // manifest stayed under the cap but whose tar carries padding-style
339
+ // extras is still bounded.
340
+ entryCount += 1;
341
+ if (entryCount > bundleEntryCap) {
342
+ entry.body.destroy();
343
+ throw new StreamingValidationError(
344
+ "bundle_too_many_entries",
345
+ `bundle contains more than ${bundleEntryCap} entries`,
346
+ );
347
+ }
348
+
349
+ const archivePath = entry.header.name;
350
+
351
+ // Non-file entries are either directory markers (empty body) or
352
+ // pax-header / other metadata payloads we don't consume. Apply the
353
+ // bundle byte cap to their tar-header size too — an attacker could
354
+ // otherwise keep `manifest.files` small while stuffing huge pax/other
355
+ // entry bodies, draining the importer for free. Directory bodies are
356
+ // reliably zero-sized; pax headers are measured in bytes, so this
357
+ // check is effectively free in the happy path.
358
+ if (entry.header.type !== "file") {
359
+ const nonFileSize = entry.header.size ?? 0;
360
+ if (totalBytesStreamed + nonFileSize > bundleByteCap) {
361
+ entry.body.destroy();
362
+ throw new StreamingValidationError(
363
+ "bundle_too_large",
364
+ `bundle exceeds ${bundleByteCap}-byte ceiling (non-file entry "${archivePath}" size ${nonFileSize})`,
365
+ archivePath,
366
+ );
367
+ }
368
+ totalBytesStreamed += nonFileSize;
369
+ }
370
+
371
+ if (entry.header.type === "directory") {
372
+ // Best-effort: create the directory inside the temp workspace if it
373
+ // resolves inside `workspaceDir`. Drain the empty body either way.
374
+ entry.body.resume();
375
+ const dirResolved = resolveInsideTempWorkspace(
376
+ archivePath,
377
+ pathResolver,
378
+ realWorkspaceDir,
379
+ tempWorkspaceDir,
380
+ );
381
+ if (dirResolved) {
382
+ try {
383
+ await mkdir(dirResolved, { recursive: true });
384
+ } catch (err) {
385
+ throw wrapWriteError(
386
+ `Failed to create directory "${dirResolved}"`,
387
+ err,
388
+ );
389
+ }
390
+ }
391
+ entryIndex += 1;
392
+ continue;
393
+ }
394
+
395
+ if (entry.header.type !== "file") {
396
+ // pax-header / other — drain and skip. Non-file payloads are
397
+ // metadata for the tar extractor itself, not user data.
398
+ entry.body.resume();
399
+ entryIndex += 1;
400
+ continue;
401
+ }
402
+
403
+ const expectedEntry = expected.get(archivePath);
404
+ if (!expectedEntry) {
405
+ // Bundle contains a file the manifest didn't declare. Destroy the
406
+ // body so the extractor aborts promptly.
407
+ entry.body.destroy();
408
+ throw new StreamingValidationError(
409
+ "manifest_mismatch",
410
+ `Archive entry "${archivePath}" is not declared in the manifest`,
411
+ archivePath,
412
+ );
413
+ }
414
+
415
+ // Reject tar entries whose declared size disagrees with the manifest.
416
+ // The bundle-size ceiling below trusts `expectedEntry.size`; if a
417
+ // crafted bundle declared a tiny size in `manifest.json` but carried a
418
+ // huge body in the tar header, the cap would pass and the oversized
419
+ // payload would still stream to disk. `createHashVerifier` already
420
+ // fails on size mismatch at stream end, but by then the bytes have
421
+ // already been written. Fail fast here so no oversized payload lands
422
+ // on disk.
423
+ if (entry.header.size !== expectedEntry.size) {
424
+ entry.body.destroy();
425
+ throw new StreamingValidationError(
426
+ "entry_size",
427
+ `Archive entry "${archivePath}" has tar-header size ${entry.header.size} but manifest declares ${expectedEntry.size}`,
428
+ archivePath,
429
+ );
430
+ }
431
+
432
+ // Enforce the bundle-size ceiling BEFORE writing/consuming the entry.
433
+ // Checking post-write would still let a single oversized file land on
434
+ // disk before we reject, defeating the cap as a resource guard. We
435
+ // check both the manifest-declared size (what we just verified the
436
+ // tar agrees with) AND the tar-header size directly, using whichever
437
+ // is larger, so a future header/manifest desync can't slip through.
438
+ const declaredSize = Math.max(entry.header.size, expectedEntry.size);
439
+ if (totalBytesStreamed + declaredSize > bundleByteCap) {
440
+ entry.body.destroy();
441
+ throw new StreamingValidationError(
442
+ "bundle_too_large",
443
+ `bundle exceeds ${bundleByteCap}-byte ceiling`,
444
+ archivePath,
445
+ );
446
+ }
447
+ totalBytesStreamed += declaredSize;
448
+
449
+ if (archivePath.startsWith("credentials/")) {
450
+ // Credentials are hash-verified against the manifest but collected
451
+ // in memory rather than written to disk. DefaultPathResolver
452
+ // deliberately returns null for these paths.
453
+ const buffered = await collectHashVerified(entry.body, {
454
+ sha256: expectedEntry.sha256,
455
+ size: expectedEntry.size,
456
+ archivePath,
457
+ });
458
+ const account = archivePath.slice("credentials/".length);
459
+ if (account) {
460
+ bufferedCredentials.push({
461
+ account,
462
+ value: new TextDecoder().decode(buffered),
463
+ });
464
+ }
465
+ seen.add(archivePath);
466
+ onProgress?.({
467
+ archivePath,
468
+ bytesWritten: expectedEntry.size,
469
+ entryIndex,
470
+ });
471
+ entryIndex += 1;
472
+ continue;
473
+ }
474
+
475
+ const diskPath = pathResolver.resolve(archivePath);
476
+ if (!diskPath) {
477
+ // Unknown destination. Consume bytes through the verifier anyway so
478
+ // we still catch manifest/content mismatches, but don't write.
479
+ // Tracking this in the report matches the buffer-based importer's
480
+ // "skipped" semantics.
481
+ await drainThroughVerifier(entry.body, {
482
+ sha256: expectedEntry.sha256,
483
+ size: expectedEntry.size,
484
+ archivePath,
485
+ });
486
+ importedFiles.push({
487
+ path: archivePath,
488
+ disk_path: "",
489
+ action: "skipped",
490
+ size: expectedEntry.size,
491
+ sha256: expectedEntry.sha256,
492
+ backup_path: null,
493
+ });
494
+ warnings.push(
495
+ `Skipped "${archivePath}": no known disk target for this archive path`,
496
+ );
497
+ seen.add(archivePath);
498
+ onProgress?.({
499
+ archivePath,
500
+ bytesWritten: expectedEntry.size,
501
+ entryIndex,
502
+ });
503
+ entryIndex += 1;
504
+ continue;
505
+ }
506
+
507
+ // Legacy guardian persona (prompts/USER.md) is translated to the
508
+ // current guardian's users/<slug>.md by DefaultPathResolver. If
509
+ // that target already holds user-authored content, skip rather
510
+ // than clobber — the user has curated their persona since the
511
+ // bundle was exported. We check against the LIVE workspace path
512
+ // (diskPath) because the swap hasn't happened yet.
513
+ if (
514
+ archivePath === LEGACY_USER_MD_ARCHIVE_PATH &&
515
+ isGuardianPersonaCustomized(diskPath)
516
+ ) {
517
+ log.warn(
518
+ { archivePath, diskPath },
519
+ "Skipping legacy prompts/USER.md import: guardian persona is already customized",
520
+ );
521
+ await drainThroughVerifier(entry.body, {
522
+ sha256: expectedEntry.sha256,
523
+ size: expectedEntry.size,
524
+ archivePath,
525
+ });
526
+ importedFiles.push({
527
+ path: archivePath,
528
+ disk_path: diskPath,
529
+ action: "skipped",
530
+ size: expectedEntry.size,
531
+ sha256: expectedEntry.sha256,
532
+ backup_path: null,
533
+ });
534
+ warnings.push(
535
+ `Skipped "${archivePath}": guardian persona at "${diskPath}" is already customized`,
536
+ );
537
+ seen.add(archivePath);
538
+ onProgress?.({
539
+ archivePath,
540
+ bytesWritten: expectedEntry.size,
541
+ entryIndex,
542
+ });
543
+ entryIndex += 1;
544
+ continue;
545
+ }
546
+
547
+ // Rebase the resolved path onto the temp workspace.
548
+ const tempDiskPath = rebaseOntoTempWorkspace(
549
+ diskPath,
550
+ realWorkspaceDir,
551
+ tempWorkspaceDir,
552
+ );
553
+ if (!tempDiskPath) {
554
+ // Resolved outside the workspace directory. Not supported for the
555
+ // streaming atomic-swap path — write through the verifier but flag
556
+ // as skipped.
557
+ await drainThroughVerifier(entry.body, {
558
+ sha256: expectedEntry.sha256,
559
+ size: expectedEntry.size,
560
+ archivePath,
561
+ });
562
+ importedFiles.push({
563
+ path: archivePath,
564
+ disk_path: diskPath,
565
+ action: "skipped",
566
+ size: expectedEntry.size,
567
+ sha256: expectedEntry.sha256,
568
+ backup_path: null,
569
+ });
570
+ warnings.push(
571
+ `Skipped "${archivePath}": disk target "${diskPath}" falls outside the workspace directory`,
572
+ );
573
+ seen.add(archivePath);
574
+ onProgress?.({
575
+ archivePath,
576
+ bytesWritten: expectedEntry.size,
577
+ entryIndex,
578
+ });
579
+ entryIndex += 1;
580
+ continue;
581
+ }
582
+
583
+ try {
584
+ await mkdir(dirname(tempDiskPath), { recursive: true });
585
+ } catch (err) {
586
+ throw wrapWriteError(
587
+ `Failed to create parent directory for "${tempDiskPath}"`,
588
+ err,
589
+ );
590
+ }
591
+
592
+ // Classify the entry as `workspace/*` (namespaced) vs legacy format.
593
+ // Namespaced entries flip the swap-gate flag; legacy entries are
594
+ // staged for an in-place promote after the stream completes.
595
+ const isWorkspaceNamespaced = archivePath.startsWith("workspace/");
596
+
597
+ // Config files need sanitization before writing to strip
598
+ // environment-specific fields (defense-in-depth; matches commitImport).
599
+ // Configs are small (KB-scale) so buffering them is fine. Hash
600
+ // verification still runs on the RAW bytes — the manifest declares the
601
+ // sha/size of the archive content, not the sanitized output.
602
+ if (CONFIG_ARCHIVE_PATHS.has(archivePath)) {
603
+ const rawBytes = await collectHashVerified(entry.body, {
604
+ sha256: expectedEntry.sha256,
605
+ size: expectedEntry.size,
606
+ archivePath,
607
+ });
608
+ const sanitized = sanitizeConfigForTransfer(
609
+ new TextDecoder().decode(rawBytes),
610
+ );
611
+ const sanitizedBytes = new TextEncoder().encode(sanitized);
612
+ try {
613
+ await writeFile(tempDiskPath, sanitizedBytes, { mode: 0o600 });
614
+ } catch (err) {
615
+ throw wrapWriteError(`Failed to write "${tempDiskPath}"`, err);
616
+ }
617
+ // commitImport reports the sha256 of the bytes actually written to
618
+ // disk (which differs from the manifest-declared sha once
619
+ // sanitization strips fields). Mirror that here so downstream
620
+ // integrity re-checks against the on-disk file succeed.
621
+ const onDiskSha = sha256Hex(sanitizedBytes);
622
+ const importedFileIndex = importedFiles.length;
623
+ importedFiles.push({
624
+ path: archivePath,
625
+ disk_path: diskPath,
626
+ action: "created",
627
+ // Report the sanitized on-disk size, not the archive's raw size —
628
+ // matches what commitImport reports.
629
+ size: sanitizedBytes.length,
630
+ sha256: onDiskSha,
631
+ backup_path: null,
632
+ });
633
+ if (isWorkspaceNamespaced) {
634
+ hasWorkspaceNamespacedEntry = true;
635
+ } else {
636
+ legacyStaged.push({
637
+ tempPath: tempDiskPath,
638
+ livePath: diskPath,
639
+ archivePath,
640
+ importedFileIndex,
641
+ });
642
+ }
643
+ seen.add(archivePath);
644
+ onProgress?.({
645
+ archivePath,
646
+ bytesWritten: expectedEntry.size,
647
+ entryIndex,
648
+ });
649
+ entryIndex += 1;
650
+ continue;
651
+ }
652
+
653
+ const verifier = createHashVerifier({
654
+ sha256: expectedEntry.sha256,
655
+ size: expectedEntry.size,
656
+ archivePath,
657
+ });
658
+ const writeStream = createWriteStream(tempDiskPath, { mode: 0o600 });
659
+ try {
660
+ await pipeline(entry.body, verifier, writeStream);
661
+ } catch (err) {
662
+ // Disambiguate between hash/size validation failures and raw disk
663
+ // write errors so the caller sees the right reason code.
664
+ if (err instanceof StreamingValidationError) {
665
+ throw err;
666
+ }
667
+ throw wrapWriteError(`Failed to write "${tempDiskPath}"`, err);
668
+ }
669
+
670
+ // Action is "created" for the in-temp-tree record. Whether the real
671
+ // workspace sees this as create vs overwrite is resolved later: the
672
+ // atomic-swap path wipes and replaces wholesale, while the legacy
673
+ // in-place promote checks against the live file and flips the action
674
+ // to "overwritten" with a backup.
675
+ const action: ImportFileAction = "created";
676
+ const importedFileIndex = importedFiles.length;
677
+ importedFiles.push({
678
+ path: archivePath,
679
+ disk_path: diskPath,
680
+ action,
681
+ size: expectedEntry.size,
682
+ sha256: expectedEntry.sha256,
683
+ backup_path: null,
684
+ });
685
+ if (isWorkspaceNamespaced) {
686
+ hasWorkspaceNamespacedEntry = true;
687
+ } else {
688
+ legacyStaged.push({
689
+ tempPath: tempDiskPath,
690
+ livePath: diskPath,
691
+ archivePath,
692
+ importedFileIndex,
693
+ });
694
+ }
695
+ seen.add(archivePath);
696
+ onProgress?.({
697
+ archivePath,
698
+ bytesWritten: expectedEntry.size,
699
+ entryIndex,
700
+ });
701
+ entryIndex += 1;
702
+ }
703
+
704
+ // Manifest must have been processed.
705
+ if (!manifest || !expected) {
706
+ throw new StreamingValidationError(
707
+ "manifest_not_first",
708
+ "Archive contained no entries",
709
+ );
710
+ }
711
+
712
+ // Every declared manifest path must have been seen in the tar stream.
713
+ const missing: string[] = [];
714
+ for (const path of expected.keys()) {
715
+ if (!seen.has(path)) missing.push(path);
716
+ }
717
+ if (missing.length > 0) {
718
+ throw new StreamingValidationError(
719
+ "missing_entry",
720
+ `Bundle is missing ${missing.length} declared entr${
721
+ missing.length === 1 ? "y" : "ies"
722
+ }: ${missing.slice(0, 5).join(", ")}${missing.length > 5 ? ", …" : ""}`,
723
+ missing[0],
724
+ );
725
+ }
726
+ } catch (err) {
727
+ await cleanupTempDir();
728
+ return mapThrownToResult(err);
729
+ }
730
+
731
+ // -------------------------------------------------------------------------
732
+ // Commit strategy selection
733
+ //
734
+ // commitImport's in-place path only clears the workspace when the bundle
735
+ // carries at least one `workspace/*` entry that resolves to a real disk
736
+ // path — legacy-format bundles (`data/db/*`, `config/*`, `prompts/*`,
737
+ // `skills/*`, `hooks/*` without a workspace/ prefix) write individual
738
+ // files in place without wiping siblings. The streaming importer's
739
+ // atomic-swap path is equivalent to the selective-clear-and-write path;
740
+ // it must therefore only fire when `hasWorkspaceNamespacedEntry` is
741
+ // true. For legacy-only bundles we promote staged temp files into the
742
+ // live workspace one by one with backup-before-overwrite semantics.
743
+ // -------------------------------------------------------------------------
744
+
745
+ // Empty result: no writable entries, no staged legacy files. Skip both
746
+ // commit paths — nothing can alter the live workspace. This matches
747
+ // commitImport's no-op behavior for all-credential or all-skipped
748
+ // bundles.
749
+ if (!hasWorkspaceNamespacedEntry && legacyStaged.length === 0) {
750
+ await cleanupTempDir();
751
+
752
+ // Post-commit side effects still run for things like credential import.
753
+ if (importCredentials && bufferedCredentials.length > 0) {
754
+ try {
755
+ await importCredentials(bufferedCredentials);
756
+ } catch (err) {
757
+ log.warn(
758
+ { err, count: bufferedCredentials.length },
759
+ "Post-commit credential import failed",
760
+ );
761
+ warnings.push(`Credential import failed: ${errMessage(err)}`);
762
+ }
763
+ }
764
+
765
+ const report = buildReport(manifest, importedFiles, warnings);
766
+ return { ok: true, report };
767
+ }
768
+
769
+ // Legacy-only bundle: we have files staged under the temp workspace but
770
+ // no `workspace/*` entries telling us the caller wants to replace the
771
+ // entire workspace. Promote each staged file into the live workspace in
772
+ // place, matching commitImport's legacy branch (backup-before-overwrite,
773
+ // parent-dir mkdir, no workspace-wide clear). The temp workspace is
774
+ // removed when done — it only served as a landing zone for the verified
775
+ // hash stream.
776
+ if (!hasWorkspaceNamespacedEntry) {
777
+ // Close the live SQLite connection before promoting staged files. A
778
+ // legacy bundle may carry `data/db/assistant.db`, and replacing the file
779
+ // with an open connection leaves the daemon pinned to the old inode —
780
+ // subsequent reads/writes would go against stale pre-import data until
781
+ // the process reset the connection. The singleton lazily reopens on next
782
+ // use, so closing here is safe even if no DB entry is in the bundle.
783
+ try {
784
+ resetDb();
785
+ } catch (err) {
786
+ log.warn(
787
+ { err },
788
+ "resetDb threw before legacy-format import promotion; continuing",
789
+ );
790
+ }
791
+
792
+ try {
793
+ await promoteLegacyStagedFiles(legacyStaged, importedFiles);
794
+ } catch (err) {
795
+ // Legacy promotion mutates live files one at a time, so a mid-loop
796
+ // failure leaves an observable partial import: every entry in
797
+ // `importedFiles` whose `action` has flipped from "created" (the
798
+ // temp-staged state) to "overwritten" or that now carries a
799
+ // `backup_path` has landed on live disk. Report that back so callers
800
+ // can tell what changed, matching commitImport's partial_report
801
+ // contract for its in-place path.
802
+ const partialReport = buildReport(manifest, importedFiles, warnings);
803
+ await cleanupTempDir();
804
+ return {
805
+ ok: false,
806
+ reason: "write_failed",
807
+ message: `Failed to promote legacy-format import into workspace: ${errMessage(err)}`,
808
+ partial_report: partialReport,
809
+ };
810
+ }
811
+
812
+ await cleanupTempDir();
813
+
814
+ // Post-commit side effects. Config/trust caches can still be stale
815
+ // from a legacy config/settings.json write, and credentials still
816
+ // need to flow through CES.
817
+ if (importCredentials && bufferedCredentials.length > 0) {
818
+ try {
819
+ await importCredentials(bufferedCredentials);
820
+ } catch (err) {
821
+ log.warn(
822
+ { err, count: bufferedCredentials.length },
823
+ "Post-commit credential import failed",
824
+ );
825
+ warnings.push(`Credential import failed: ${errMessage(err)}`);
826
+ }
827
+ }
828
+
829
+ try {
830
+ invalidateConfigCache();
831
+ } catch (err) {
832
+ log.warn({ err }, "invalidateConfigCache threw after legacy import");
833
+ }
834
+
835
+ try {
836
+ clearTrustCache();
837
+ } catch (err) {
838
+ log.warn({ err }, "clearTrustCache threw after legacy import");
839
+ }
840
+
841
+ const report = buildReport(manifest, importedFiles, warnings);
842
+ return { ok: true, report };
843
+ }
844
+
845
+ // Atomic swap path for workspace/*-carrying bundles.
846
+
847
+ // Close the live SQLite connection so the DB file inside the real
848
+ // workspace can be replaced. The singleton lazily reopens on next use.
849
+ try {
850
+ resetDb();
851
+ } catch (err) {
852
+ // resetDb close failure is extremely unlikely but not worth aborting
853
+ // over — log and continue.
854
+ log.warn({ err }, "resetDb threw before swap; continuing");
855
+ }
856
+
857
+ // Preserve the target's `vellum:*` credential metadata entries across
858
+ // the swap. Django's post-hatch provisioning on the platform writes
859
+ // `vellum:platform_base_url` / `assistant_api_key` / `platform_assistant_id`
860
+ // / `webhook_secret` via POST /v1/secrets, which upserts into the live
861
+ // workspace's `data/credentials/metadata.json`. Without this merge the
862
+ // swap would replace that file with the source's copy (which has no
863
+ // vellum entries on local sources), and the gateway's
864
+ // `readServiceCredentials` would stop finding the platform API key.
865
+ //
866
+ // Executes in the temp workspace only — no effect on the live workspace
867
+ // — so a failure here leaves pre-swap state untouched. Any filesystem
868
+ // error is logged and degraded to a warning rather than aborting the
869
+ // import (credential loss is recoverable via reprovision; an aborted
870
+ // swap is a larger regression).
871
+ const liveMetadataPath = join(
872
+ realWorkspaceDir,
873
+ "data",
874
+ "credentials",
875
+ "metadata.json",
876
+ );
877
+ const tempMetadataPath = join(
878
+ tempWorkspaceDir,
879
+ "data",
880
+ "credentials",
881
+ "metadata.json",
882
+ );
883
+ try {
884
+ await mergeCredentialMetadataIntoTemp(
885
+ liveMetadataPath,
886
+ tempMetadataPath,
887
+ warnings,
888
+ );
889
+ } catch (err) {
890
+ log.warn(
891
+ { err, liveMetadataPath, tempMetadataPath },
892
+ "Credential metadata merge failed before swap",
893
+ );
894
+ warnings.push(
895
+ `Credential metadata merge failed: ${errMessage(err)}; vellum:* entries may not survive the import`,
896
+ );
897
+ }
898
+
899
+ // Carry-over: for every path in WORKSPACE_PRESERVE_PATHS, if the bundle
900
+ // did NOT populate it inside the temp workspace but the LIVE workspace
901
+ // has it, move the live copy into the temp workspace at the same
902
+ // relative location. Without this step the atomic swap erases live
903
+ // user data (SQLite DB, Qdrant store, embedding-models cache,
904
+ // deprecated/ quarantine) whenever the bundle omits those paths —
905
+ // e.g. partial bundles carrying only prompts/config.
906
+ //
907
+ // Carry-over uses `rename` (not `cp`) to stay zero-disk on the happy
908
+ // path, which is critical on instances with multi-GB Qdrant stores or
909
+ // SQLite DBs and limited free space.
910
+ //
911
+ // Crash-safety is achieved in two phases:
912
+ // 1. `planCarryOverPreservedPaths` walks the live + temp trees WITHOUT
913
+ // mutating anything and produces the full intended `carried` list.
914
+ // 2. `writeImportMarker` persists that plan to disk BEFORE any rename
915
+ // runs. If the process dies during the subsequent
916
+ // `executeCarryOverPlan`, the marker already holds every
917
+ // (liveChild, tempChild) pair the next `recoverInterruptedImport`
918
+ // needs to replay. The marker is deleted only after the atomic
919
+ // swap pair succeeds (or in-process failure paths explicitly
920
+ // restore state).
921
+ let carried: CarriedPath[];
922
+ try {
923
+ carried = await planCarryOverPreservedPaths(
924
+ realWorkspaceDir,
925
+ tempWorkspaceDir,
926
+ );
927
+ } catch (err) {
928
+ await cleanupTempDir();
929
+ return {
930
+ ok: false,
931
+ reason: "write_failed",
932
+ message: `Failed to plan preserved-path carry-over: ${errMessage(err)}`,
933
+ };
934
+ }
935
+
936
+ // Ensure the workspace dir exists so writeImportMarker (which writes
937
+ // at `<realWorkspaceDir>/.import-marker.json`) can land the file on
938
+ // first-ever imports where the workspace has never been created.
939
+ // mkdir is idempotent via { recursive: true }.
940
+ await mkdir(realWorkspaceDir, { recursive: true });
941
+
942
+ const markerPath = importMarkerPathFor(realWorkspaceDir);
943
+ try {
944
+ await writeImportMarker(markerPath, {
945
+ tempWorkspaceDir,
946
+ carried: carried.map((c) => ({
947
+ liveChild: c.liveChild,
948
+ tempChild: c.tempChild,
949
+ })),
950
+ });
951
+ } catch (err) {
952
+ // Persisting the recovery plan is a prerequisite for crash-safe
953
+ // carry-over. If we can't write the marker, refuse to mutate the live
954
+ // workspace — a mid-carryover crash would otherwise be unrecoverable.
955
+ await cleanupTempDir();
956
+ return {
957
+ ok: false,
958
+ reason: "write_failed",
959
+ message: `Failed to persist import recovery marker: ${errMessage(err)}`,
960
+ };
961
+ }
962
+
963
+ try {
964
+ await executeCarryOverPlan(carried);
965
+ } catch (err) {
966
+ // A rename in the plan failed. Restore the already-moved entries so
967
+ // the live workspace is whole again, then delete the marker and temp
968
+ // dir. `restoreCarriedPaths` is a no-op on entries that were never
969
+ // moved (tempChild missing), so passing the full plan is safe.
970
+ await restoreCarriedPaths(carried);
971
+ await safelyDeleteMarker(markerPath);
972
+ await cleanupTempDir();
973
+ return {
974
+ ok: false,
975
+ reason: "write_failed",
976
+ message: `Failed to carry over preserved workspace paths: ${errMessage(err)}`,
977
+ };
978
+ }
979
+
980
+ // Workspace swap: content-level, not directory-level.
981
+ //
982
+ // We do NOT `rename(realWorkspaceDir, backupDir)` because in the
983
+ // production platform deployment `realWorkspaceDir` is a mounted volume
984
+ // (and the daemon's cwd / open subsystems pin it), so the kernel returns
985
+ // EBUSY on the parent-directory rename. Instead we swap the DIRECTORY'S
986
+ // CONTENTS: move every top-level entry from `realWorkspaceDir` into a
987
+ // peer `${realWorkspaceDir}.pre-import-<ts>/` backup dir, then move every
988
+ // top-level entry from the temp tree into the (now empty)
989
+ // `realWorkspaceDir`. `realWorkspaceDir` itself is never renamed, so
990
+ // mount-point / cwd pinning doesn't matter.
991
+ //
992
+ // Update the marker to record the backup dir BEFORE any move runs, so
993
+ // `recoverInterruptedImport` on a future boot can restore from backup
994
+ // even if the process is killed mid-swap.
995
+ // backupDir also lives INSIDE the workspace mount — same rationale as
996
+ // tempWorkspaceDir (keep all moves on the same filesystem, dot-prefix
997
+ // so workspace walkers skip it). Suffix with a UUID (not just a
998
+ // timestamp) so a malicious bundle can't guess the name and ship a
999
+ // top-level entry that collides with our active backup dir during
1000
+ // phase 2 — phase 2 also rejects any such collision defensively.
1001
+ const backupDir = join(
1002
+ realWorkspaceDir,
1003
+ `${IMPORT_BACKUP_PREFIX}${Date.now()}-${randomUUID()}`,
1004
+ );
1005
+ try {
1006
+ await writeImportMarker(markerPath, {
1007
+ tempWorkspaceDir,
1008
+ carried: carried.map((c) => ({
1009
+ liveChild: c.liveChild,
1010
+ tempChild: c.tempChild,
1011
+ })),
1012
+ backupDir,
1013
+ });
1014
+ } catch (err) {
1015
+ await restoreCarriedPaths(carried);
1016
+ await cleanupTempDir();
1017
+ await safelyDeleteMarker(markerPath);
1018
+ return {
1019
+ ok: false,
1020
+ reason: "write_failed",
1021
+ message: `Failed to persist pre-swap recovery marker: ${errMessage(err)}`,
1022
+ };
1023
+ }
1024
+
1025
+ try {
1026
+ await swapWorkspaceContents(realWorkspaceDir, tempWorkspaceDir, backupDir);
1027
+
1028
+ // Swap succeeded. Record that fact in the marker BEFORE deleting it —
1029
+ // otherwise a crash between `swapWorkspaceContents` returning and
1030
+ // `safelyDeleteMarker` completing would leave a marker with
1031
+ // `backupDir` populated, and `recoverInterruptedImport` on the next
1032
+ // boot would silently roll back the successful import by restoring
1033
+ // from backup. With `swapCompleted: true` the recovery path knows to
1034
+ // skip the backup restore and just clean up residual artifacts.
1035
+ try {
1036
+ await writeImportMarker(markerPath, {
1037
+ tempWorkspaceDir,
1038
+ carried: carried.map((c) => ({
1039
+ liveChild: c.liveChild,
1040
+ tempChild: c.tempChild,
1041
+ })),
1042
+ backupDir,
1043
+ swapCompleted: true,
1044
+ });
1045
+ } catch (err) {
1046
+ // Very unlikely (we wrote it a moment ago) and not worth failing
1047
+ // the whole import. A crash here would roll back via recovery, but
1048
+ // the import itself is already applied.
1049
+ log.warn(
1050
+ { err, markerPath },
1051
+ "Failed to mark import recovery marker as swapCompleted; crash window remains until safelyDeleteMarker",
1052
+ );
1053
+ }
1054
+ await safelyDeleteMarker(markerPath);
1055
+ } catch (err) {
1056
+ // Content-level swap either rolled back its own renames (best effort)
1057
+ // or left the workspace in an ambiguous state. Do a final restore pass
1058
+ // from backupDir into realWorkspaceDir so any entries that didn't
1059
+ // make it back end up whole again — the backup restore runs FIRST so
1060
+ // it doesn't later clobber preserved paths that restoreCarriedPaths
1061
+ // just put back. Pass the carried plan so restoreFromBackupDir can
1062
+ // avoid clobbering descendants (e.g. `data/db` already restored
1063
+ // under `data/`) when it replaces a top-level backup entry.
1064
+ const restoreResult = await restoreFromBackupDir(
1065
+ backupDir,
1066
+ realWorkspaceDir,
1067
+ carried,
1068
+ );
1069
+ await restoreCarriedPaths(carried);
1070
+ if (restoreResult.ok) {
1071
+ await cleanupTempDir();
1072
+ await rm(backupDir, { recursive: true, force: true }).catch(() => {
1073
+ /* best effort */
1074
+ });
1075
+ await safelyDeleteMarker(markerPath);
1076
+ } else {
1077
+ // Partial restore — preserve the backup dir, the temp tree, and the
1078
+ // marker so an operator (or the next boot-time
1079
+ // recoverInterruptedImport) can retry. The marker's `carried` plan
1080
+ // references tempChild paths; deleting the temp tree here would
1081
+ // break that plan. A backup dir with unresolved content is the last
1082
+ // recoverable copy of the pre-import state.
1083
+ log.error(
1084
+ {
1085
+ backupDir,
1086
+ tempWorkspaceDir,
1087
+ markerPath,
1088
+ failedCount: restoreResult.failedCount,
1089
+ },
1090
+ "Pre-import backup restore incomplete; leaving backup dir, temp tree, and marker on disk for manual/boot-time recovery",
1091
+ );
1092
+ }
1093
+ return {
1094
+ ok: false,
1095
+ reason: "write_failed",
1096
+ message: `Failed to swap workspace contents: ${errMessage(err)}`,
1097
+ };
1098
+ }
1099
+
1100
+ // -------------------------------------------------------------------------
1101
+ // Post-commit side effects (non-fatal)
1102
+ //
1103
+ // Past this point the real workspace is already replaced — failures here
1104
+ // do not justify reverting the whole import. Log loudly, surface warnings
1105
+ // in the report, return success.
1106
+ // -------------------------------------------------------------------------
1107
+
1108
+ if (importCredentials && bufferedCredentials.length > 0) {
1109
+ try {
1110
+ await importCredentials(bufferedCredentials);
1111
+ } catch (err) {
1112
+ log.warn(
1113
+ { err, count: bufferedCredentials.length },
1114
+ "Post-commit credential import failed",
1115
+ );
1116
+ warnings.push(`Credential import failed: ${errMessage(err)}`);
1117
+ }
1118
+ }
1119
+
1120
+ try {
1121
+ invalidateConfigCache();
1122
+ } catch (err) {
1123
+ log.warn({ err }, "invalidateConfigCache threw after import");
1124
+ }
1125
+
1126
+ try {
1127
+ clearTrustCache();
1128
+ } catch (err) {
1129
+ log.warn({ err }, "clearTrustCache threw after import");
1130
+ }
1131
+
1132
+ // Attempt to remove the backup dir (best-effort). Leaving it around is not
1133
+ // a correctness issue, only a disk-space one, so we swallow errors. The
1134
+ // backup dir now always exists once swap succeeds — we created it during
1135
+ // swapWorkspaceContents to hold the pre-import live entries.
1136
+ rm(backupDir, { recursive: true, force: true }).catch((err) => {
1137
+ log.warn({ err, backupDir }, "Failed to remove pre-import backup dir");
1138
+ });
1139
+
1140
+ const report = buildReport(manifest, importedFiles, warnings);
1141
+ return { ok: true, report };
1142
+ }
1143
+
1144
+ // ---------------------------------------------------------------------------
1145
+ // Helpers
1146
+ // ---------------------------------------------------------------------------
1147
+
1148
+ function sha256Hex(data: Uint8Array): string {
1149
+ return createHash("sha256").update(data).digest("hex");
1150
+ }
1151
+
1152
+ function generateBackupPath(diskPath: string): string {
1153
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1154
+ return `${diskPath}.backup-${timestamp}`;
1155
+ }
1156
+
1157
+ /**
1158
+ * Promote verified-into-temp files for a legacy-format bundle into the
1159
+ * live workspace in place. Mirrors commitImport's legacy write path:
1160
+ *
1161
+ * - If the live path already exists, copy it to a timestamped
1162
+ * `${livePath}.backup-<ts>` sibling first.
1163
+ * - Ensure the parent directory exists.
1164
+ * - `fs.rename` the temp file over the live path for per-file atomicity.
1165
+ * If that fails with EXDEV (cross-filesystem), fall back to `copyFile`
1166
+ * then `rm` of the temp source.
1167
+ * - Update the corresponding `ImportedFileReport` with the overwrite
1168
+ * action and backup path so the report matches commitImport's output.
1169
+ */
1170
+ async function promoteLegacyStagedFiles(
1171
+ staged: Array<{
1172
+ tempPath: string;
1173
+ livePath: string;
1174
+ archivePath: string;
1175
+ importedFileIndex: number;
1176
+ }>,
1177
+ importedFiles: ImportedFileReport[],
1178
+ ): Promise<void> {
1179
+ for (const entry of staged) {
1180
+ // Backup before overwrite, matching commitImport.
1181
+ let backupPath: string | null = null;
1182
+ if (existsSync(entry.livePath)) {
1183
+ backupPath = generateBackupPath(entry.livePath);
1184
+ await copyFile(entry.livePath, backupPath);
1185
+ }
1186
+
1187
+ await mkdir(dirname(entry.livePath), { recursive: true });
1188
+
1189
+ // If we're replacing a SQLite main database file, remove any sibling
1190
+ // `.db-wal`/`.db-shm`/`.db-journal` from live first. Those
1191
+ // auxiliary files are only valid with the exact `.db` that wrote
1192
+ // them — leaving them alongside the replacement DB causes SQLite to
1193
+ // replay incompatible WAL frames on the first open and report
1194
+ // "database disk image is malformed".
1195
+ if (entry.livePath.endsWith(".db")) {
1196
+ for (const suffix of [".db-wal", ".db-shm", ".db-journal"]) {
1197
+ const auxPath = `${entry.livePath.slice(0, -".db".length)}${suffix}`;
1198
+ await rm(auxPath, { force: true }).catch(() => {
1199
+ /* best effort */
1200
+ });
1201
+ }
1202
+ }
1203
+
1204
+ try {
1205
+ await rename(entry.tempPath, entry.livePath);
1206
+ } catch (err) {
1207
+ if (isEXDEV(err)) {
1208
+ await copyFile(entry.tempPath, entry.livePath);
1209
+ await rm(entry.tempPath, { force: true });
1210
+ } else {
1211
+ throw err;
1212
+ }
1213
+ }
1214
+
1215
+ const report = importedFiles[entry.importedFileIndex];
1216
+ if (report) {
1217
+ if (backupPath) {
1218
+ report.action = "overwritten";
1219
+ report.backup_path = backupPath;
1220
+ } else {
1221
+ report.action = "created";
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Rewrite the temp workspace's `data/credentials/metadata.json` so the
1229
+ * target's live `vellum:*` entries survive the swap. Exits silently if
1230
+ * there is nothing to merge.
1231
+ *
1232
+ * Four cases:
1233
+ * - No live metadata, no temp metadata → no-op.
1234
+ * - Live metadata present, temp metadata missing → if the live metadata
1235
+ * contains vellum entries, synthesize a minimal v5 metadata file in
1236
+ * the temp tree containing only those preserved entries. If it has
1237
+ * none, no-op (no entries to preserve).
1238
+ * - Live metadata missing, temp metadata present → no-op (nothing to
1239
+ * preserve; the bundle's copy lands as-is).
1240
+ * - Both present → run the merge helper and rewrite the temp copy.
1241
+ *
1242
+ * Invoked under a try/catch by the caller; thrown errors surface as
1243
+ * warnings but don't abort the import.
1244
+ */
1245
+ async function mergeCredentialMetadataIntoTemp(
1246
+ liveMetadataPath: string,
1247
+ tempMetadataPath: string,
1248
+ warnings: string[],
1249
+ ): Promise<void> {
1250
+ let liveJson: string | null = null;
1251
+ try {
1252
+ liveJson = await readFile(liveMetadataPath, "utf-8");
1253
+ } catch (err) {
1254
+ if (!isENOENT(err)) throw err;
1255
+ }
1256
+
1257
+ let tempJson: string | null = null;
1258
+ try {
1259
+ tempJson = await readFile(tempMetadataPath, "utf-8");
1260
+ } catch (err) {
1261
+ if (!isENOENT(err)) throw err;
1262
+ }
1263
+
1264
+ if (liveJson == null && tempJson == null) return;
1265
+
1266
+ if (tempJson != null) {
1267
+ const merged = mergeMetadataPreservingVellum(tempJson, liveJson);
1268
+ if (merged !== tempJson) {
1269
+ await writeFile(tempMetadataPath, merged, { mode: 0o600 });
1270
+ }
1271
+ return;
1272
+ }
1273
+
1274
+ // Live-only path: synthesize a v5 file with just the preserved vellum
1275
+ // entries so the gateway can still locate them after the swap.
1276
+ const synthesized = mergeMetadataPreservingVellum(
1277
+ JSON.stringify({ version: 5, credentials: [] }),
1278
+ liveJson,
1279
+ );
1280
+ const parsed = JSON.parse(synthesized) as {
1281
+ credentials?: unknown[];
1282
+ };
1283
+ if (!parsed.credentials || parsed.credentials.length === 0) {
1284
+ // Live file exists but had no vellum entries worth preserving.
1285
+ return;
1286
+ }
1287
+
1288
+ try {
1289
+ await mkdir(dirname(tempMetadataPath), { recursive: true });
1290
+ await writeFile(tempMetadataPath, synthesized, { mode: 0o600 });
1291
+ } catch (err) {
1292
+ warnings.push(
1293
+ `Failed to write preserved vellum:* metadata into temp workspace: ${errMessage(err)}`,
1294
+ );
1295
+ }
1296
+ }
1297
+
1298
+ function buildReport(
1299
+ manifest: ManifestType,
1300
+ files: ImportedFileReport[],
1301
+ warnings: string[],
1302
+ ): ImportCommitReport {
1303
+ return {
1304
+ success: true,
1305
+ summary: {
1306
+ total_files: files.length,
1307
+ files_created: files.filter((f) => f.action === "created").length,
1308
+ files_overwritten: files.filter((f) => f.action === "overwritten").length,
1309
+ files_skipped: files.filter((f) => f.action === "skipped").length,
1310
+ backups_created: files.filter((f) => f.backup_path !== null).length,
1311
+ },
1312
+ files,
1313
+ manifest,
1314
+ warnings,
1315
+ };
1316
+ }
1317
+
1318
+ /**
1319
+ * Copy any WORKSPACE_PRESERVE_PATHS entries from the live workspace into
1320
+ * the temp workspace when the bundle did not already populate them. Runs
1321
+ * immediately before the atomic swap so the swap-in tree has the union
1322
+ * of bundle-provided files and live-preserved files.
1323
+ *
1324
+ * Per-file merge semantics (critical): a bundle that touches a SINGLE file
1325
+ * under a preserved directory (e.g. writes `workspace/data/qdrant/config.json`)
1326
+ * must NOT cause the rest of that directory to be wiped. We therefore walk
1327
+ * each preserved path recursively and carry over any live file or
1328
+ * subdirectory the bundle did not itself write. A whole-directory short-
1329
+ * circuit would mis-handle that case by erasing unrelated qdrant segments,
1330
+ * DB WALs, embedding-model shards, etc.
1331
+ *
1332
+ * For each preserved relative path:
1333
+ * - If the preserved path is a FILE in the live workspace and the temp
1334
+ * tree already has that exact path, the bundle populated it — leave
1335
+ * it alone. Otherwise rename/copy the live file over.
1336
+ * - If the preserved path is a DIRECTORY in the live workspace, walk
1337
+ * it recursively. For each entry:
1338
+ * * If the temp tree has a matching entry at the same relative
1339
+ * path, the bundle wrote it — skip.
1340
+ * * If not, carry the live entry over (rename with EXDEV fallback
1341
+ * to recursive copy).
1342
+ * The walk stops descending on any subtree the bundle has completely
1343
+ * populated, since we only need to fill gaps.
1344
+ */
1345
+ /**
1346
+ * Pre-compute the full `CarriedPath[]` that `carryOverPreservedPaths` will
1347
+ * move, WITHOUT mutating the live workspace. The result lets us write the
1348
+ * crash-recovery marker before any rename runs, so a crash mid-carry-over
1349
+ * still leaves a complete restoration plan for the next
1350
+ * `recoverInterruptedImport` call.
1351
+ *
1352
+ * The walk mirrors `carryOverPreservedPaths` exactly — if the two were to
1353
+ * disagree, recovery would be incomplete. Directory subtrees that the
1354
+ * bundle didn't populate are recorded as a single top-level move (matches
1355
+ * the one-shot rename the executor does); per-file merges happen otherwise.
1356
+ */
1357
+ async function planCarryOverPreservedPaths(
1358
+ realWorkspaceDir: string,
1359
+ tempWorkspaceDir: string,
1360
+ ): Promise<CarriedPath[]> {
1361
+ const plan: CarriedPath[] = [];
1362
+ for (const rel of WORKSPACE_PRESERVE_PATHS) {
1363
+ const livePath = join(realWorkspaceDir, rel);
1364
+ const tempPath = join(tempWorkspaceDir, rel);
1365
+
1366
+ let liveStat;
1367
+ try {
1368
+ liveStat = await stat(livePath);
1369
+ } catch (err) {
1370
+ if (isENOENT(err)) continue;
1371
+ throw err;
1372
+ }
1373
+
1374
+ if (!liveStat.isDirectory()) {
1375
+ if (existsSync(tempPath)) continue;
1376
+ plan.push({ liveChild: livePath, tempChild: tempPath });
1377
+ continue;
1378
+ }
1379
+
1380
+ await planMergeLiveIntoTempDir(livePath, tempPath, plan);
1381
+ }
1382
+ return plan;
1383
+ }
1384
+
1385
+ /**
1386
+ * Same walk as `mergeLiveIntoTempDir` but only records the would-be moves
1387
+ * in `plan`. Intentionally side-effect-free apart from appending to the
1388
+ * plan array.
1389
+ */
1390
+ async function planMergeLiveIntoTempDir(
1391
+ liveDir: string,
1392
+ tempDir: string,
1393
+ plan: CarriedPath[],
1394
+ ): Promise<void> {
1395
+ let entries;
1396
+ try {
1397
+ entries = await readdir(liveDir, { withFileTypes: true });
1398
+ } catch (err) {
1399
+ if (isENOENT(err)) return;
1400
+ throw err;
1401
+ }
1402
+
1403
+ for (const entry of entries) {
1404
+ const liveChild = join(liveDir, entry.name);
1405
+ const tempChild = join(tempDir, entry.name);
1406
+ const existsInTemp = existsSync(tempChild);
1407
+
1408
+ if (entry.isDirectory()) {
1409
+ if (!existsInTemp) {
1410
+ plan.push({ liveChild, tempChild });
1411
+ continue;
1412
+ }
1413
+ await planMergeLiveIntoTempDir(liveChild, tempChild, plan);
1414
+ continue;
1415
+ }
1416
+
1417
+ if (existsInTemp) continue;
1418
+
1419
+ // SQLite auxiliary files (WAL / SHM / journal) are only valid as a
1420
+ // pair with the exact `.db` they were written by. If the bundle
1421
+ // replaced the sibling `.db` in this dir, carrying the live `.db-wal`
1422
+ // forward pairs stale WAL frames with a different DB and SQLite
1423
+ // reports "database disk image is malformed" on first open. Drop
1424
+ // them — SQLite recreates a fresh WAL lazily on next connection,
1425
+ // and the export already checkpointed the source WAL into the main
1426
+ // DB before the bundle was built.
1427
+ //
1428
+ // When the bundle does NOT carry a replacement DB (bundle is
1429
+ // config-only etc.), the live `.db` is preserved and the live WAL
1430
+ // stays paired with it.
1431
+ if (
1432
+ isSqliteAuxiliaryFile(entry.name) &&
1433
+ hasSiblingDbInTemp(tempDir, entry.name)
1434
+ ) {
1435
+ continue;
1436
+ }
1437
+
1438
+ plan.push({ liveChild, tempChild });
1439
+ }
1440
+ }
1441
+
1442
+ /**
1443
+ * SQLite writes `<name>.db-wal`, `<name>.db-shm`, `<name>.db-journal`
1444
+ * alongside its main `<name>.db` file. These are only consistent with
1445
+ * the exact `.db` they were created for.
1446
+ */
1447
+ function isSqliteAuxiliaryFile(name: string): boolean {
1448
+ return (
1449
+ name.endsWith(".db-wal") ||
1450
+ name.endsWith(".db-shm") ||
1451
+ name.endsWith(".db-journal")
1452
+ );
1453
+ }
1454
+
1455
+ /**
1456
+ * Does the temp dir contain the main `.db` file that owns this auxiliary
1457
+ * file? Given e.g. `assistant.db-wal`, checks for `tempDir/assistant.db`.
1458
+ */
1459
+ function hasSiblingDbInTemp(tempDir: string, auxName: string): boolean {
1460
+ const dbName = auxName
1461
+ .replace(/\.db-wal$/, ".db")
1462
+ .replace(/\.db-shm$/, ".db")
1463
+ .replace(/\.db-journal$/, ".db");
1464
+ return existsSync(join(tempDir, dbName));
1465
+ }
1466
+
1467
+ /**
1468
+ * Execute a carry-over plan produced by `planCarryOverPreservedPaths`.
1469
+ * Each entry is moved with `carryOverEntry`; directories that are plan
1470
+ * roots have their parent created so `rename` can land them.
1471
+ *
1472
+ * Per-entry failures abort the loop and throw — the caller is expected to
1473
+ * run `restoreCarriedPaths` on the already-moved entries (a subset of the
1474
+ * plan) on its in-process failure path.
1475
+ */
1476
+ async function executeCarryOverPlan(plan: CarriedPath[]): Promise<void> {
1477
+ for (const { liveChild, tempChild } of plan) {
1478
+ await mkdir(dirname(tempChild), { recursive: true });
1479
+ await carryOverEntry(liveChild, tempChild);
1480
+ }
1481
+ }
1482
+
1483
+ /**
1484
+ * Move a single live workspace entry (file or directory) into the temp
1485
+ * workspace. Uses `rename` for the fast path (same-filesystem, zero copy)
1486
+ * so we don't duplicate potentially multi-GB preserved trees like
1487
+ * `data/qdrant` or `data/db`. Falls back to `cp` + `rm` on EXDEV (different
1488
+ * filesystems) — rare in practice since live and temp share a parent dir.
1489
+ *
1490
+ * Live data is moved, not copied, so the atomic swap must restore it on
1491
+ * failure. `streamCommitImport` tracks every carry-over via `CarriedPath`
1492
+ * and calls `restoreCarriedPaths` on any swap-pair error so the live
1493
+ * workspace ends up whole even if the import aborts.
1494
+ */
1495
+ async function carryOverEntry(
1496
+ liveChild: string,
1497
+ tempChild: string,
1498
+ ): Promise<void> {
1499
+ try {
1500
+ await rename(liveChild, tempChild);
1501
+ } catch (err) {
1502
+ if (isEXDEV(err)) {
1503
+ await copyTreeSkippingTransient(liveChild, tempChild);
1504
+ await rm(liveChild, { recursive: true, force: true });
1505
+ } else {
1506
+ throw err;
1507
+ }
1508
+ }
1509
+ }
1510
+
1511
+ /**
1512
+ * Every preserved entry that was moved out of the live workspace during
1513
+ * carry-over. Used to undo the move if the atomic swap fails, so we never
1514
+ * leave the daemon with SQLite/Qdrant/embedding-model data stranded in a
1515
+ * temp tree that's about to be deleted.
1516
+ */
1517
+ interface CarriedPath {
1518
+ /** Original location inside the live workspace (real path before swap). */
1519
+ liveChild: string;
1520
+ /** Landing location inside the temp workspace. */
1521
+ tempChild: string;
1522
+ }
1523
+
1524
+ /**
1525
+ * Undo a set of carry-over moves by renaming each carried path back to its
1526
+ * original live location. Best-effort: logs and continues on per-entry
1527
+ * failures rather than throwing, since the caller is already handling a
1528
+ * swap-pair failure and needs to restore as much state as possible.
1529
+ */
1530
+ async function restoreCarriedPaths(
1531
+ carried: readonly CarriedPath[],
1532
+ ): Promise<void> {
1533
+ for (const { liveChild, tempChild } of carried) {
1534
+ try {
1535
+ await mkdir(dirname(liveChild), { recursive: true });
1536
+ await rename(tempChild, liveChild);
1537
+ } catch (err) {
1538
+ if (isEXDEV(err)) {
1539
+ try {
1540
+ await copyTreeSkippingTransient(tempChild, liveChild);
1541
+ await rm(tempChild, { recursive: true, force: true });
1542
+ continue;
1543
+ } catch (cpErr) {
1544
+ log.error(
1545
+ { err: cpErr, liveChild, tempChild },
1546
+ "Failed to restore carried preserved path via cp fallback; manual recovery may be required",
1547
+ );
1548
+ continue;
1549
+ }
1550
+ }
1551
+ if (isENOENT(err)) {
1552
+ // The entry may have already moved (rename-pair partially succeeded)
1553
+ // or never existed. Nothing to restore.
1554
+ continue;
1555
+ }
1556
+ log.error(
1557
+ { err, liveChild, tempChild },
1558
+ "Failed to restore carried preserved path; manual recovery may be required",
1559
+ );
1560
+ }
1561
+ }
1562
+ }
1563
+
1564
+ /**
1565
+ * Swap the CONTENTS of the workspace without ever renaming `realWorkspaceDir`
1566
+ * itself. The production platform pod has `realWorkspaceDir` as a mounted
1567
+ * volume (and the daemon's subsystems pin file handles inside it), so the
1568
+ * kernel returns `EBUSY` if we `rename()` the directory. Moving individual
1569
+ * top-level entries sidesteps that: a mount point's children usually aren't
1570
+ * themselves mount points, and individual-file EBUSY is much rarer than
1571
+ * directory-rename EBUSY.
1572
+ *
1573
+ * Semantics:
1574
+ *
1575
+ * 1. Create `backupDir` (peer of `realWorkspaceDir`, different parent entry).
1576
+ * 2. For each top-level entry currently in `realWorkspaceDir`, `rename()`
1577
+ * it into `backupDir`.
1578
+ * 3. For each top-level entry in `tempWorkspaceDir`, `rename()` it into
1579
+ * `realWorkspaceDir`.
1580
+ * 4. Remove the (now empty) temp dir.
1581
+ *
1582
+ * On a per-entry rename failure during phase 2, move what was already moved
1583
+ * back into `realWorkspaceDir` and throw. On a failure during phase 3, move
1584
+ * the already-moved temp entries back to `tempWorkspaceDir`, then move
1585
+ * backup entries back into `realWorkspaceDir`, and throw.
1586
+ *
1587
+ * This function is NOT atomic — a reader that opens `realWorkspaceDir`
1588
+ * mid-swap will see a half-emptied state. The daemon's SQLite connection is
1589
+ * already closed (`resetDb()` ran before this), and the async import is
1590
+ * running in a background job from the external caller's perspective, so
1591
+ * transient readers aren't expected. `recoverInterruptedImport` uses the
1592
+ * `backupDir` recorded in the marker to finish the rollback if a crash hits
1593
+ * mid-swap.
1594
+ */
1595
+ async function swapWorkspaceContents(
1596
+ realWorkspaceDir: string,
1597
+ tempWorkspaceDir: string,
1598
+ backupDir: string,
1599
+ ): Promise<void> {
1600
+ await mkdir(backupDir, { recursive: true });
1601
+
1602
+ // Phase 1: move every top-level entry out of real into backup. Skip
1603
+ // ONLY the exact scratch dirs this import owns (backupDir itself, and
1604
+ // the tempWorkspaceDir passed in) — NOT everything that happens to
1605
+ // start with the `.import-`/`.pre-import-` prefix. A user workspace
1606
+ // that legitimately contains an entry with one of those prefixes
1607
+ // would otherwise leak state across imports, and a bundle carrying
1608
+ // the same name would collide on phase-2 rename-in.
1609
+ //
1610
+ // The recovery marker (`.import-marker.json`) is also reserved — it
1611
+ // lives inside the workspace, must stay put across the swap so
1612
+ // recovery can read it if the process dies mid-swap, and must not be
1613
+ // overwritten by a bundle entry of the same name.
1614
+ const scratchBasenames = new Set<string>([
1615
+ basename(backupDir),
1616
+ basename(tempWorkspaceDir),
1617
+ IMPORT_MARKER_BASENAME,
1618
+ ]);
1619
+ let liveEntries: string[];
1620
+ try {
1621
+ liveEntries = (await readdir(realWorkspaceDir)).filter(
1622
+ (name) => !scratchBasenames.has(name),
1623
+ );
1624
+ } catch (err) {
1625
+ if (isENOENT(err)) {
1626
+ liveEntries = [];
1627
+ } else {
1628
+ throw err;
1629
+ }
1630
+ }
1631
+
1632
+ const movedToBackup: string[] = [];
1633
+ try {
1634
+ for (const name of liveEntries) {
1635
+ await moveEntryWithExdevFallback(
1636
+ join(realWorkspaceDir, name),
1637
+ join(backupDir, name),
1638
+ );
1639
+ movedToBackup.push(name);
1640
+ }
1641
+ } catch (err) {
1642
+ // Partial move-out. Reverse what we moved so realWorkspaceDir ends up
1643
+ // back to its original content before we throw.
1644
+ for (const name of movedToBackup.reverse()) {
1645
+ try {
1646
+ await moveEntryWithExdevFallback(
1647
+ join(backupDir, name),
1648
+ join(realWorkspaceDir, name),
1649
+ );
1650
+ } catch (restoreErr) {
1651
+ log.error(
1652
+ { err: restoreErr, name, realWorkspaceDir, backupDir },
1653
+ "Failed to restore entry from backup during swap-out rollback",
1654
+ );
1655
+ }
1656
+ }
1657
+ throw err;
1658
+ }
1659
+
1660
+ // Phase 2: move every top-level entry from temp into real. `rename`
1661
+ // requires the destination's parent to exist, so ensure realWorkspaceDir
1662
+ // exists even if phase 1 found no entries (first-ever import into a
1663
+ // fresh workspace dir that hasn't been created yet).
1664
+ await mkdir(realWorkspaceDir, { recursive: true });
1665
+
1666
+ let tempEntries: string[];
1667
+ try {
1668
+ tempEntries = await readdir(tempWorkspaceDir);
1669
+ } catch (err) {
1670
+ // A missing temp tree here is a hard failure — phase 1 has already
1671
+ // emptied realWorkspaceDir into backup, so treating temp as an empty
1672
+ // import would commit an empty workspace and the backup would be
1673
+ // deleted in the success path. That's silent data loss. Throw so the
1674
+ // caller's rollback restores backup → real.
1675
+ if (isENOENT(err)) {
1676
+ throw new Error(
1677
+ `Temp workspace dir disappeared before swap-in (${tempWorkspaceDir})`,
1678
+ );
1679
+ }
1680
+ throw err;
1681
+ }
1682
+
1683
+ // Defend against a bundle whose top-level entries collide with this
1684
+ // swap's scratch basenames. The UUID suffix on `backupDir` makes an
1685
+ // accidental collision astronomically unlikely, but a malicious or
1686
+ // corrupted bundle carrying e.g. `.pre-import-<exact-match>` could
1687
+ // otherwise replace the (empty) active backup dir via rename on an
1688
+ // empty live workspace, and the success-path `rm(backupDir)` would
1689
+ // then silently delete the imported content. Fail fast before any
1690
+ // rename so real ends up rolled back to pre-import state.
1691
+ const collidingName = tempEntries.find((name) => scratchBasenames.has(name));
1692
+ if (collidingName !== undefined) {
1693
+ throw new Error(
1694
+ `Bundle top-level entry "${collidingName}" collides with an import scratch dir basename — refusing to swap to avoid accidental deletion of imported content`,
1695
+ );
1696
+ }
1697
+
1698
+ const movedToReal: string[] = [];
1699
+ try {
1700
+ for (const name of tempEntries) {
1701
+ await moveEntryWithExdevFallback(
1702
+ join(tempWorkspaceDir, name),
1703
+ join(realWorkspaceDir, name),
1704
+ );
1705
+ movedToReal.push(name);
1706
+ }
1707
+ } catch (err) {
1708
+ // Partial move-in. Reverse the partial fill-in first (real → temp),
1709
+ // then restore from backup (backup → real), so real ends up back at
1710
+ // its pre-swap state.
1711
+ for (const name of movedToReal.reverse()) {
1712
+ try {
1713
+ await moveEntryWithExdevFallback(
1714
+ join(realWorkspaceDir, name),
1715
+ join(tempWorkspaceDir, name),
1716
+ );
1717
+ } catch (restoreErr) {
1718
+ log.error(
1719
+ { err: restoreErr, name, realWorkspaceDir, tempWorkspaceDir },
1720
+ "Failed to undo partial swap-in during rollback",
1721
+ );
1722
+ }
1723
+ }
1724
+ for (const name of movedToBackup.reverse()) {
1725
+ try {
1726
+ await moveEntryWithExdevFallback(
1727
+ join(backupDir, name),
1728
+ join(realWorkspaceDir, name),
1729
+ );
1730
+ } catch (restoreErr) {
1731
+ log.error(
1732
+ { err: restoreErr, name, realWorkspaceDir, backupDir },
1733
+ "Failed to restore entry from backup during swap-in rollback",
1734
+ );
1735
+ }
1736
+ }
1737
+ throw err;
1738
+ }
1739
+
1740
+ // Phase 3: remove the now-empty temp dir. If it still has stragglers
1741
+ // (pax headers, etc. we didn't move) take them down too.
1742
+ await rm(tempWorkspaceDir, { recursive: true, force: true }).catch(() => {
1743
+ /* best effort — caller will log if it matters */
1744
+ });
1745
+ }
1746
+
1747
+ /**
1748
+ * Move a single filesystem entry from `src` to `dst`, falling back to
1749
+ * `cp` + `rm` when `rename` returns EXDEV (cross-filesystem move).
1750
+ *
1751
+ * In the production container, `realWorkspaceDir` is typically a mounted
1752
+ * volume on a separate filesystem from the backup / temp dirs that live on
1753
+ * the overlay root — so every move in `swapWorkspaceContents` crosses a
1754
+ * filesystem boundary and would fail with EXDEV without this fallback.
1755
+ * Every other move helper in this file (`carryOverEntry`,
1756
+ * `restoreCarriedPaths`, `restoreFromBackupDir`, `mergeBackupIntoLive`)
1757
+ * already handles EXDEV the same way; this helper centralises that
1758
+ * behaviour for the swap path.
1759
+ */
1760
+ async function moveEntryWithExdevFallback(
1761
+ src: string,
1762
+ dst: string,
1763
+ ): Promise<void> {
1764
+ try {
1765
+ await rename(src, dst);
1766
+ } catch (err) {
1767
+ if (isEXDEV(err)) {
1768
+ try {
1769
+ await copyTreeSkippingTransient(src, dst);
1770
+ } catch (cpErr) {
1771
+ // Partial cp could leave incomplete content at `dst`. Remove it so
1772
+ // `restoreFromBackupDir` (running on a later error path) doesn't
1773
+ // mistake half-a-tree for a valid backup entry and clobber the
1774
+ // still-intact source with it. Leave `src` alone — we never got
1775
+ // to the rm step, so it's whole.
1776
+ await rm(dst, { recursive: true, force: true }).catch((rmErr) => {
1777
+ log.warn(
1778
+ { err: rmErr, dst },
1779
+ "Failed to clean up partial cp destination after EXDEV fallback failure",
1780
+ );
1781
+ });
1782
+ throw cpErr;
1783
+ }
1784
+ await rm(src, { recursive: true, force: true });
1785
+ return;
1786
+ }
1787
+ throw err;
1788
+ }
1789
+ }
1790
+
1791
+ /**
1792
+ * `fs.cp(..., { recursive: true })` throws `ERR_FS_CP_SOCKET` (and
1793
+ * similar for FIFOs / other special files) in newer Node versions, which
1794
+ * breaks imports in real deployments — most concretely, the meet-join
1795
+ * skill creates unix sockets under `meets/<id>/sockets/` that end up
1796
+ * inside the workspace. Special files are session-scoped, always safe to
1797
+ * drop across an import. This wrapper asks `fs.cp` to skip anything that
1798
+ * isn't a regular file / directory / symlink, and falls back to a manual
1799
+ * walk if `fs.cp` still trips over something we couldn't filter ahead of
1800
+ * time.
1801
+ */
1802
+ async function copyTreeSkippingTransient(
1803
+ src: string,
1804
+ dst: string,
1805
+ ): Promise<void> {
1806
+ try {
1807
+ await cp(src, dst, {
1808
+ recursive: true,
1809
+ preserveTimestamps: true,
1810
+ filter: async (source) => {
1811
+ try {
1812
+ const info = await lstat(source);
1813
+ // Keep regular files, directories, and symlinks. Skip sockets,
1814
+ // FIFOs, block/char devices — transient / non-portable content
1815
+ // that `fs.cp` refuses to replicate anyway.
1816
+ return info.isFile() || info.isDirectory() || info.isSymbolicLink();
1817
+ } catch {
1818
+ // If we can't stat, let `fs.cp` try and surface the real error.
1819
+ return true;
1820
+ }
1821
+ },
1822
+ });
1823
+ } catch (err) {
1824
+ if (!isCpUnsupportedFileType(err)) throw err;
1825
+ // Fall back to a manual walk that skips anything that isn't a file,
1826
+ // dir, or symlink. `fs.cp` on Node can still occasionally surface
1827
+ // ERR_FS_CP_SOCKET despite the filter (races where the socket
1828
+ // appears between filter call and read), so the manual walk is the
1829
+ // last-resort path.
1830
+ log.warn(
1831
+ { err, src, dst },
1832
+ "cp filter still surfaced unsupported file type; falling back to manual walk",
1833
+ );
1834
+ await manualCopyTreeSkippingTransient(src, dst);
1835
+ }
1836
+ }
1837
+
1838
+ function isCpUnsupportedFileType(err: unknown): boolean {
1839
+ if (!err || typeof err !== "object") return false;
1840
+ const code = (err as { code?: string }).code;
1841
+ return (
1842
+ code === "ERR_FS_CP_SOCKET" ||
1843
+ code === "ERR_FS_CP_FIFO_PIPE" ||
1844
+ code === "ERR_FS_CP_UNKNOWN"
1845
+ );
1846
+ }
1847
+
1848
+ async function manualCopyTreeSkippingTransient(
1849
+ src: string,
1850
+ dst: string,
1851
+ ): Promise<void> {
1852
+ const info = await lstat(src);
1853
+ if (info.isSymbolicLink()) {
1854
+ const target = await readlink(src);
1855
+ await mkdir(dirname(dst), { recursive: true });
1856
+ // `symlink` throws EEXIST if `dst` already exists. We may be running
1857
+ // as a fallback after `fs.cp` partially populated `dst` (including
1858
+ // creating this symlink itself), so clear it first — unlike
1859
+ // `copyFile` / recursive `mkdir`, `symlink` has no replace-mode.
1860
+ await rm(dst, { force: true }).catch(() => {
1861
+ /* best effort — a subsequent symlink error will surface any real issue */
1862
+ });
1863
+ await symlink(target, dst);
1864
+ return;
1865
+ }
1866
+ if (info.isFile()) {
1867
+ await mkdir(dirname(dst), { recursive: true });
1868
+ await copyFile(src, dst);
1869
+ return;
1870
+ }
1871
+ if (info.isDirectory()) {
1872
+ await mkdir(dst, { recursive: true });
1873
+ for (const name of await readdir(src)) {
1874
+ await manualCopyTreeSkippingTransient(join(src, name), join(dst, name));
1875
+ }
1876
+ return;
1877
+ }
1878
+ // Anything else (socket, FIFO, device) — intentionally skip.
1879
+ log.debug(
1880
+ { src },
1881
+ "Skipping transient/special filesystem entry during cross-fs copy",
1882
+ );
1883
+ }
1884
+
1885
+ interface RestoreFromBackupResult {
1886
+ ok: boolean;
1887
+ /** Entries that could not be restored; backup must be preserved if non-zero. */
1888
+ failedCount: number;
1889
+ }
1890
+
1891
+ /**
1892
+ * Move every top-level entry from `backupDir` back into `realWorkspaceDir`,
1893
+ * overwriting partial swap-in leftovers from a crashed import.
1894
+ *
1895
+ * `carried` is the carry-over plan. Any entry in `carried` whose
1896
+ * `liveChild` is a descendant of a backup entry protects that subtree from
1897
+ * being rm'd — if the backup captured only part of a directory (because
1898
+ * carry-over already moved `data/db` out before the swap started), we must
1899
+ * not clobber a `data/db` that recovery already restored into
1900
+ * `realWorkspaceDir/data/`. In that case we merge the backup's `data/`
1901
+ * into `realWorkspaceDir/data/` per-entry instead of replacing it.
1902
+ *
1903
+ * Used by the in-process rollback path (failed `swapWorkspaceContents`)
1904
+ * and by `recoverInterruptedImport` at boot.
1905
+ *
1906
+ * Best-effort per-entry: logs failures and continues rather than
1907
+ * throwing, and returns a status with `failedCount` so callers can decide
1908
+ * whether to preserve the backup dir for manual recovery. A missing
1909
+ * backup dir is a clean no-op (`{ ok: true, failedCount: 0 }`).
1910
+ */
1911
+ async function restoreFromBackupDir(
1912
+ backupDir: string,
1913
+ realWorkspaceDir: string,
1914
+ carried: readonly CarriedPath[],
1915
+ ): Promise<RestoreFromBackupResult> {
1916
+ let backupEntries: string[];
1917
+ try {
1918
+ backupEntries = await readdir(backupDir);
1919
+ } catch (err) {
1920
+ if (isENOENT(err)) return { ok: true, failedCount: 0 };
1921
+ log.error(
1922
+ { err, backupDir },
1923
+ "Failed to read backup dir during restore; skipping backup restoration",
1924
+ );
1925
+ return { ok: false, failedCount: 1 };
1926
+ }
1927
+
1928
+ const carriedLivePaths = carried.map((c) => resolve(c.liveChild));
1929
+
1930
+ let failedCount = 0;
1931
+
1932
+ for (const name of backupEntries) {
1933
+ const src = join(backupDir, name);
1934
+ const dst = join(realWorkspaceDir, name);
1935
+ const dstAbs = resolve(dst);
1936
+
1937
+ // If any carried path lives strictly inside `dst` (e.g., dst is
1938
+ // `real/data/` and a carried path is `real/data/db`), we can't
1939
+ // wholesale `rm(dst) + rename(src)` — that would destroy the carried
1940
+ // content that recovery has already put back. Merge instead.
1941
+ const hasProtectedDescendant = carriedLivePaths.some((carriedAbs) => {
1942
+ if (carriedAbs === dstAbs) return false;
1943
+ return carriedAbs.startsWith(dstAbs + sep);
1944
+ });
1945
+
1946
+ if (hasProtectedDescendant) {
1947
+ try {
1948
+ await mergeBackupIntoLive(src, dst, carriedLivePaths);
1949
+ } catch (err) {
1950
+ failedCount += 1;
1951
+ log.error(
1952
+ { err, src, dst },
1953
+ "Failed to merge backup subtree into live workspace during restore",
1954
+ );
1955
+ }
1956
+ continue;
1957
+ }
1958
+
1959
+ // No carried descendants — safe to replace wholesale. If real already
1960
+ // has this entry (partial swap-in), remove it first.
1961
+ try {
1962
+ await rm(dst, { recursive: true, force: true });
1963
+ } catch (err) {
1964
+ log.warn(
1965
+ { err, dst },
1966
+ "Failed to clear partial-swap entry before restoring from backup",
1967
+ );
1968
+ }
1969
+ try {
1970
+ await rename(src, dst);
1971
+ } catch (err) {
1972
+ if (isEXDEV(err)) {
1973
+ try {
1974
+ await copyTreeSkippingTransient(src, dst);
1975
+ await rm(src, { recursive: true, force: true });
1976
+ continue;
1977
+ } catch (cpErr) {
1978
+ failedCount += 1;
1979
+ log.error(
1980
+ { err: cpErr, src, dst },
1981
+ "Failed to restore backup entry via cp fallback; manual recovery may be required",
1982
+ );
1983
+ continue;
1984
+ }
1985
+ }
1986
+ failedCount += 1;
1987
+ log.error(
1988
+ { err, src, dst },
1989
+ "Failed to restore backup entry; manual recovery may be required",
1990
+ );
1991
+ }
1992
+ }
1993
+
1994
+ return { ok: failedCount === 0, failedCount };
1995
+ }
1996
+
1997
+ /**
1998
+ * Copy `src` (a backup subtree) into `dst` (the live-workspace subtree,
1999
+ * which already exists and may already contain carried descendants we
2000
+ * must not clobber). Each child in `src` that doesn't collide with an
2001
+ * existing entry in `dst` is moved in; children that DO collide recurse
2002
+ * so carried files deeper in the tree survive.
2003
+ */
2004
+ async function mergeBackupIntoLive(
2005
+ src: string,
2006
+ dst: string,
2007
+ carriedLivePaths: readonly string[],
2008
+ ): Promise<void> {
2009
+ await mkdir(dst, { recursive: true });
2010
+
2011
+ let children: string[];
2012
+ try {
2013
+ children = await readdir(src);
2014
+ } catch (err) {
2015
+ if (isENOENT(err)) return;
2016
+ throw err;
2017
+ }
2018
+
2019
+ for (const childName of children) {
2020
+ const childSrc = join(src, childName);
2021
+ const childDst = join(dst, childName);
2022
+ const childDstAbs = resolve(childDst);
2023
+
2024
+ let dstExists = false;
2025
+ try {
2026
+ await stat(childDst);
2027
+ dstExists = true;
2028
+ } catch (err) {
2029
+ if (!isENOENT(err)) throw err;
2030
+ }
2031
+
2032
+ if (!dstExists) {
2033
+ try {
2034
+ await rename(childSrc, childDst);
2035
+ } catch (err) {
2036
+ if (isEXDEV(err)) {
2037
+ await copyTreeSkippingTransient(childSrc, childDst);
2038
+ await rm(childSrc, { recursive: true, force: true });
2039
+ } else {
2040
+ throw err;
2041
+ }
2042
+ }
2043
+ continue;
2044
+ }
2045
+
2046
+ // dst child exists — check whether it IS a carried entry or CONTAINS
2047
+ // one. If it IS carried, backup's version is stale (carried is
2048
+ // canonical). If it CONTAINS carried, recurse.
2049
+ const isCarriedLeaf = carriedLivePaths.includes(childDstAbs);
2050
+ if (isCarriedLeaf) {
2051
+ // Skip — keep carried version that was already restored.
2052
+ continue;
2053
+ }
2054
+ const containsCarried = carriedLivePaths.some(
2055
+ (c) => c !== childDstAbs && c.startsWith(childDstAbs + sep),
2056
+ );
2057
+ if (containsCarried) {
2058
+ await mergeBackupIntoLive(childSrc, childDst, carriedLivePaths);
2059
+ continue;
2060
+ }
2061
+
2062
+ // No carried conflict — backup's version should win over whatever
2063
+ // the partial-swap-in put here.
2064
+ await rm(childDst, { recursive: true, force: true });
2065
+ try {
2066
+ await rename(childSrc, childDst);
2067
+ } catch (err) {
2068
+ if (isEXDEV(err)) {
2069
+ await copyTreeSkippingTransient(childSrc, childDst);
2070
+ await rm(childSrc, { recursive: true, force: true });
2071
+ } else {
2072
+ throw err;
2073
+ }
2074
+ }
2075
+ }
2076
+ }
2077
+
2078
+ /**
2079
+ * Resolve an archive path through the caller's resolver, then rebase the
2080
+ * returned disk path onto the temp workspace. Returns `null` when the path
2081
+ * cannot be resolved or lands outside `realWorkspaceDir`.
2082
+ */
2083
+ function resolveInsideTempWorkspace(
2084
+ archivePath: string,
2085
+ pathResolver: PathResolver,
2086
+ realWorkspaceDir: string,
2087
+ tempWorkspaceDir: string,
2088
+ ): string | null {
2089
+ const resolved = pathResolver.resolve(archivePath);
2090
+ if (!resolved) return null;
2091
+ return rebaseOntoTempWorkspace(resolved, realWorkspaceDir, tempWorkspaceDir);
2092
+ }
2093
+
2094
+ /**
2095
+ * Replace the `realWorkspaceDir` prefix of `diskPath` with `tempWorkspaceDir`.
2096
+ * Returns null if `diskPath` is not inside `realWorkspaceDir`.
2097
+ */
2098
+ function rebaseOntoTempWorkspace(
2099
+ diskPath: string,
2100
+ realWorkspaceDir: string,
2101
+ tempWorkspaceDir: string,
2102
+ ): string | null {
2103
+ const resolved = resolve(diskPath);
2104
+ const root = resolve(realWorkspaceDir);
2105
+ if (resolved === root) return resolve(tempWorkspaceDir);
2106
+ const prefix = root + sep;
2107
+ if (!resolved.startsWith(prefix)) return null;
2108
+ return resolve(tempWorkspaceDir, resolved.slice(prefix.length));
2109
+ }
2110
+
2111
+ /**
2112
+ * Drain an entry body through the hash verifier, discarding the output.
2113
+ *
2114
+ * Uses `pipeline` (not `.pipe()`) so that if `body` is destroyed mid-stream
2115
+ * — e.g. the upstream fetch body is torn down during a URL import — the
2116
+ * verifier is destroyed too, and this call rejects promptly instead of
2117
+ * hanging on a `for await` that never terminates.
2118
+ *
2119
+ * A `/dev/null` Writable sink terminates the chain so the verifier's
2120
+ * readable side is continuously drained. Without this sink, a Transform as
2121
+ * the last pipeline stage would stall once its internal buffer reached
2122
+ * `highWaterMark` (16 KB default), since nothing would pull its output,
2123
+ * and `pipeline` would hang indefinitely on any skipped entry >~16 KB.
2124
+ */
2125
+ async function drainThroughVerifier(
2126
+ body: Readable,
2127
+ expected: { sha256: string; size: number; archivePath: string },
2128
+ ): Promise<void> {
2129
+ const verifier = createHashVerifier(expected);
2130
+ const devNull = new Writable({
2131
+ write(_chunk, _enc, cb) {
2132
+ cb();
2133
+ },
2134
+ });
2135
+ await pipeline(body, verifier, devNull);
2136
+ }
2137
+
2138
+ /**
2139
+ * Hard cap on the per-entry size that `collectHashVerified` is willing to
2140
+ * buffer in memory. Applied to credential bodies and config files — both
2141
+ * are expected to be KB-scale in practice. Exceeding this cap signals a
2142
+ * crafted or corrupted bundle and is rejected before any bytes are read,
2143
+ * so the streaming importer's memory guarantees still hold on a 3 GB pod
2144
+ * even when the URL import is attacker-controlled.
2145
+ */
2146
+ const MAX_BUFFERED_ENTRY_BYTES = 16 * 1024 * 1024;
2147
+
2148
+ /**
2149
+ * Collect an entry body into a Buffer, verifying hash+size along the way.
2150
+ *
2151
+ * Uses `pipeline` + a sink writable that accumulates chunks, so destroy
2152
+ * signals propagate the same way as `drainThroughVerifier` and the hash
2153
+ * verifier's `_flush` (which asserts size+sha256) always runs.
2154
+ *
2155
+ * Rejects entries whose manifest-declared size exceeds
2156
+ * `MAX_BUFFERED_ENTRY_BYTES` BEFORE reading any bytes, so an oversized
2157
+ * credential or config file cannot drive RSS up by `expected.size` on a
2158
+ * memory-limited pod.
2159
+ */
2160
+ async function collectHashVerified(
2161
+ body: Readable,
2162
+ expected: { sha256: string; size: number; archivePath: string },
2163
+ ): Promise<Buffer> {
2164
+ if (expected.size > MAX_BUFFERED_ENTRY_BYTES) {
2165
+ body.destroy();
2166
+ throw new StreamingValidationError(
2167
+ "entry_too_large_to_buffer",
2168
+ `Archive entry "${expected.archivePath}" declares ${expected.size} bytes, exceeding the ${MAX_BUFFERED_ENTRY_BYTES}-byte in-memory buffer cap for credentials/configs`,
2169
+ expected.archivePath,
2170
+ );
2171
+ }
2172
+ const verifier = createHashVerifier(expected);
2173
+ const chunks: Buffer[] = [];
2174
+ const sink = new Writable({
2175
+ write(chunk, _enc, cb) {
2176
+ chunks.push(chunk instanceof Buffer ? chunk : Buffer.from(chunk));
2177
+ cb();
2178
+ },
2179
+ });
2180
+ await pipeline(body, verifier, sink);
2181
+ return Buffer.concat(chunks);
2182
+ }
2183
+
2184
+ /** Map a thrown error from streaming orchestration into an ImportCommitResult. */
2185
+ function mapThrownToResult(err: unknown): ImportCommitResult {
2186
+ if (err instanceof StreamingValidationError) {
2187
+ return {
2188
+ ok: false,
2189
+ reason: "validation_failed",
2190
+ errors: [
2191
+ {
2192
+ code: err.code,
2193
+ message: err.message,
2194
+ ...(err.archivePath !== undefined ? { path: err.archivePath } : {}),
2195
+ },
2196
+ ],
2197
+ };
2198
+ }
2199
+
2200
+ // Errors we raised ourselves for disk-side failures.
2201
+ if (err instanceof WriteFailedError) {
2202
+ return {
2203
+ ok: false,
2204
+ reason: "write_failed",
2205
+ message: err.message,
2206
+ };
2207
+ }
2208
+
2209
+ // Anything else bubbling out of the tar / gunzip / HTTP stream pipeline:
2210
+ // treat as extraction_failed. This matches the buffer-based validator's
2211
+ // gzip/tar parse errors.
2212
+ return {
2213
+ ok: false,
2214
+ reason: "extraction_failed",
2215
+ message: errMessage(err),
2216
+ };
2217
+ }
2218
+
2219
+ /** Sentinel error for disk I/O failures during streaming. */
2220
+ class WriteFailedError extends Error {
2221
+ constructor(message: string) {
2222
+ super(message);
2223
+ this.name = "WriteFailedError";
2224
+ }
2225
+ }
2226
+
2227
+ function wrapWriteError(prefix: string, cause: unknown): WriteFailedError {
2228
+ return new WriteFailedError(`${prefix}: ${errMessage(cause)}`);
2229
+ }
2230
+
2231
+ function errMessage(err: unknown): string {
2232
+ return err instanceof Error ? err.message : String(err);
2233
+ }
2234
+
2235
+ function isENOENT(err: unknown): boolean {
2236
+ return (
2237
+ typeof err === "object" &&
2238
+ err !== null &&
2239
+ (err as { code?: string }).code === "ENOENT"
2240
+ );
2241
+ }
2242
+
2243
+ function isEXDEV(err: unknown): boolean {
2244
+ return (
2245
+ typeof err === "object" &&
2246
+ err !== null &&
2247
+ (err as { code?: string }).code === "EXDEV"
2248
+ );
2249
+ }
2250
+
2251
+ // ---------------------------------------------------------------------------
2252
+ // Crash-recovery marker
2253
+ //
2254
+ // `streamCommitImport` moves preserved paths (SQLite DB, Qdrant, etc.) from
2255
+ // the live workspace into a temp tree before the atomic rename pair. If the
2256
+ // process is killed between those two phases the live workspace comes up
2257
+ // missing the preserved paths. The marker written here persists the state
2258
+ // needed to replay the recovery on the next start-up.
2259
+ //
2260
+ // Schema stays deliberately small so a partially-written marker is easy to
2261
+ // detect (JSON parse failure → skip recovery rather than act on garbage).
2262
+ // ---------------------------------------------------------------------------
2263
+
2264
+ interface ImportMarker {
2265
+ /** Absolute path of the `.import-<uuid>` temp tree. */
2266
+ tempWorkspaceDir: string;
2267
+ /** Preserved paths moved out of the live workspace pre-swap. */
2268
+ carried: Array<{ liveChild: string; tempChild: string }>;
2269
+ /**
2270
+ * Absolute path of the `${realWorkspaceDir}.pre-import-<ts>` backup dir
2271
+ * (optional — only present once the content-level swap phase has started).
2272
+ * `recoverInterruptedImport` moves entries from here back into
2273
+ * `realWorkspaceDir` if it's populated, reversing any partial swap.
2274
+ */
2275
+ backupDir?: string;
2276
+ /**
2277
+ * `true` once `swapWorkspaceContents` has returned successfully.
2278
+ * `recoverInterruptedImport` checks this before restoring from
2279
+ * `backupDir`: if the swap already completed, the backup is the OLD
2280
+ * pre-import state and restoring it would silently undo the successful
2281
+ * import. Instead, recovery just cleans up residual backup / temp
2282
+ * artifacts.
2283
+ */
2284
+ swapCompleted?: boolean;
2285
+ }
2286
+
2287
+ /** Basename of the recovery marker inside `realWorkspaceDir`. */
2288
+ const IMPORT_MARKER_BASENAME = ".import-marker.json";
2289
+
2290
+ /**
2291
+ * Deterministic marker location INSIDE `realWorkspaceDir`.
2292
+ *
2293
+ * The marker must live on the same persistent volume as the scratch
2294
+ * dirs (`.pre-import-<ts-uuid>`, `.import-<uuid>`). In Docker/Kubernetes
2295
+ * the workspace is typically a mounted persistent volume while the
2296
+ * container rootfs is ephemeral — a pod restart can drop files on
2297
+ * rootfs while preserving the workspace, so a marker stored at
2298
+ * `dirname(realWorkspaceDir)` could vanish across restart while the
2299
+ * scratch dirs survive, leaving `recoverInterruptedImport` with
2300
+ * nothing to act on and orphaning the interrupted state.
2301
+ *
2302
+ * The dot-prefix keeps it out of the way of normal content; phase 1 of
2303
+ * `swapWorkspaceContents` filters it out via `scratchBasenames`, and
2304
+ * the swap's content move also skips it so the marker stays in place
2305
+ * across the workspace swap itself.
2306
+ */
2307
+ function importMarkerPathFor(realWorkspaceDir: string): string {
2308
+ return join(realWorkspaceDir, IMPORT_MARKER_BASENAME);
2309
+ }
2310
+
2311
+ async function writeImportMarker(
2312
+ markerPath: string,
2313
+ marker: ImportMarker,
2314
+ ): Promise<void> {
2315
+ const serialized = JSON.stringify(marker);
2316
+ const tmp = `${markerPath}.tmp-${randomUUID()}`;
2317
+ // Write+rename so a crash mid-write leaves either the old marker (or
2318
+ // nothing) rather than a truncated JSON blob.
2319
+ await writeFile(tmp, serialized, { mode: 0o600 });
2320
+ await rename(tmp, markerPath);
2321
+ }
2322
+
2323
+ async function safelyDeleteMarker(markerPath: string): Promise<void> {
2324
+ try {
2325
+ await unlink(markerPath);
2326
+ } catch (err) {
2327
+ if (isENOENT(err)) return;
2328
+ log.warn({ err, markerPath }, "Failed to delete import-recovery marker");
2329
+ }
2330
+ }
2331
+
2332
+ export interface RecoveryResult {
2333
+ /**
2334
+ * `true` when there's no leftover rollback state blocking a new
2335
+ * import: no marker, successful restore, or a recorded
2336
+ * `swapCompleted` fast-path cleanup. Callers (`streamCommitImport`,
2337
+ * daemon start-up) can proceed safely.
2338
+ *
2339
+ * `false` when the rollback is incomplete — the marker / backup /
2340
+ * temp tree are intentionally preserved on disk for a future retry,
2341
+ * so any caller about to rewrite the marker must refuse to proceed
2342
+ * to avoid orphaning the unresolved state.
2343
+ */
2344
+ ok: boolean;
2345
+ /** Number of entries that couldn't be restored in the partial case. */
2346
+ failedCount: number;
2347
+ }
2348
+
2349
+ /**
2350
+ * Replay any crash-interrupted import against `realWorkspaceDir`.
2351
+ *
2352
+ * Call at daemon start-up (and implicitly at the start of every
2353
+ * `streamCommitImport` as a self-healing belt) so a prior killed import
2354
+ * doesn't leave the live workspace missing `data/db` / `data/qdrant` /
2355
+ * `embedding-models` / `deprecated`.
2356
+ *
2357
+ * Best-effort: logs per-entry failures and keeps going rather than
2358
+ * throwing. If no marker exists this is a cheap no-op. Returns a
2359
+ * `RecoveryResult` so callers can distinguish "nothing to recover /
2360
+ * recovered cleanly" from "rollback still pending — don't start
2361
+ * anything new."
2362
+ */
2363
+ export async function recoverInterruptedImport(
2364
+ realWorkspaceDir: string,
2365
+ ): Promise<RecoveryResult> {
2366
+ const markerPath = importMarkerPathFor(resolve(realWorkspaceDir));
2367
+ let raw: string;
2368
+ try {
2369
+ raw = await readFile(markerPath, "utf8");
2370
+ } catch (err) {
2371
+ if (isENOENT(err)) return { ok: true, failedCount: 0 };
2372
+ log.warn({ err, markerPath }, "Unable to read import-recovery marker");
2373
+ return { ok: true, failedCount: 0 };
2374
+ }
2375
+
2376
+ let marker: ImportMarker;
2377
+ try {
2378
+ marker = JSON.parse(raw) as ImportMarker;
2379
+ } catch (err) {
2380
+ log.warn(
2381
+ { err, markerPath },
2382
+ "Import-recovery marker is malformed; deleting without acting on it",
2383
+ );
2384
+ await safelyDeleteMarker(markerPath);
2385
+ return { ok: true, failedCount: 0 };
2386
+ }
2387
+
2388
+ if (
2389
+ !Array.isArray(marker.carried) ||
2390
+ typeof marker.tempWorkspaceDir !== "string"
2391
+ ) {
2392
+ log.warn(
2393
+ { markerPath, marker },
2394
+ "Import-recovery marker has unexpected shape; deleting",
2395
+ );
2396
+ await safelyDeleteMarker(markerPath);
2397
+ return { ok: true, failedCount: 0 };
2398
+ }
2399
+
2400
+ log.info(
2401
+ {
2402
+ markerPath,
2403
+ tempWorkspaceDir: marker.tempWorkspaceDir,
2404
+ carriedCount: marker.carried.length,
2405
+ swapCompleted: marker.swapCompleted === true,
2406
+ },
2407
+ "Recovering from interrupted import",
2408
+ );
2409
+
2410
+ const carriedEntries = marker.carried.map((c) => ({
2411
+ liveChild: c.liveChild,
2412
+ tempChild: c.tempChild,
2413
+ }));
2414
+
2415
+ // FAST PATH: the previous process completed the swap but crashed before
2416
+ // deleting the marker. Backup is the OLD pre-import state — restoring it
2417
+ // would silently undo the successful import. Skip backup restore, skip
2418
+ // carried restore (everything is already in live), just clean up
2419
+ // artifacts.
2420
+ if (marker.swapCompleted === true) {
2421
+ if (typeof marker.backupDir === "string" && marker.backupDir.length > 0) {
2422
+ await rm(marker.backupDir, { recursive: true, force: true }).catch(
2423
+ (err) => {
2424
+ log.warn(
2425
+ { err, backupDir: marker.backupDir },
2426
+ "Failed to clean up backup dir after completed import",
2427
+ );
2428
+ },
2429
+ );
2430
+ }
2431
+ await rm(marker.tempWorkspaceDir, { recursive: true, force: true }).catch(
2432
+ (err) => {
2433
+ log.warn(
2434
+ { err, tempWorkspaceDir: marker.tempWorkspaceDir },
2435
+ "Failed to clean up temp workspace after completed import",
2436
+ );
2437
+ },
2438
+ );
2439
+ await safelyDeleteMarker(markerPath);
2440
+ return { ok: true, failedCount: 0 };
2441
+ }
2442
+
2443
+ // SLOW PATH: swap did not complete. Roll back to pre-import state.
2444
+ //
2445
+ // Order matters: restore from backup FIRST, then restore carried
2446
+ // entries. If carried ran first, a subsequent `restoreFromBackupDir`
2447
+ // call that owns a parent dir (`data/`) would clobber the just-restored
2448
+ // carried entries (`data/db`, `data/qdrant`). Backup-first + carrier-
2449
+ // aware merge in `restoreFromBackupDir` preserves both.
2450
+ let restoreResult: RestoreFromBackupResult = { ok: true, failedCount: 0 };
2451
+ if (typeof marker.backupDir === "string" && marker.backupDir.length > 0) {
2452
+ try {
2453
+ restoreResult = await restoreFromBackupDir(
2454
+ marker.backupDir,
2455
+ resolve(realWorkspaceDir),
2456
+ carriedEntries,
2457
+ );
2458
+ } catch (err) {
2459
+ log.error(
2460
+ { err, backupDir: marker.backupDir },
2461
+ "Failed to restore from backup dir during import recovery; manual intervention may be required",
2462
+ );
2463
+ restoreResult = { ok: false, failedCount: 1 };
2464
+ }
2465
+ }
2466
+
2467
+ await restoreCarriedPaths(carriedEntries);
2468
+
2469
+ // Only drop the backup dir if the restore completed cleanly. A partial
2470
+ // restore means there's still content in `backupDir` that no other
2471
+ // state holds — keep it for manual / next-boot recovery.
2472
+ if (
2473
+ restoreResult.ok &&
2474
+ typeof marker.backupDir === "string" &&
2475
+ marker.backupDir.length > 0
2476
+ ) {
2477
+ await rm(marker.backupDir, { recursive: true, force: true }).catch(
2478
+ (err) => {
2479
+ log.warn(
2480
+ { err, backupDir: marker.backupDir },
2481
+ "Failed to clean up backup dir during import recovery",
2482
+ );
2483
+ },
2484
+ );
2485
+ } else if (!restoreResult.ok) {
2486
+ log.error(
2487
+ {
2488
+ backupDir: marker.backupDir,
2489
+ failedCount: restoreResult.failedCount,
2490
+ },
2491
+ "Backup restore had failures; preserving backup dir and marker for next-boot retry",
2492
+ );
2493
+ }
2494
+
2495
+ // Only clean up the temp tree + marker when the restore completed
2496
+ // cleanly. On a partial restore the marker's `carried` plan still
2497
+ // references tempChild paths, so deleting the temp tree would break
2498
+ // the next boot's recovery attempt — leave both in place for retry.
2499
+ if (restoreResult.ok) {
2500
+ try {
2501
+ await rm(marker.tempWorkspaceDir, { recursive: true, force: true });
2502
+ } catch (err) {
2503
+ log.warn(
2504
+ { err, tempWorkspaceDir: marker.tempWorkspaceDir },
2505
+ "Failed to clean up temp workspace during import recovery",
2506
+ );
2507
+ }
2508
+ await safelyDeleteMarker(markerPath);
2509
+ } else {
2510
+ log.warn(
2511
+ {
2512
+ tempWorkspaceDir: marker.tempWorkspaceDir,
2513
+ markerPath,
2514
+ },
2515
+ "Preserving temp tree + marker for next-boot recovery retry",
2516
+ );
2517
+ }
2518
+
2519
+ return restoreResult.ok
2520
+ ? { ok: true, failedCount: 0 }
2521
+ : { ok: false, failedCount: restoreResult.failedCount };
2522
+ }