@vellumai/assistant 0.6.3 → 0.6.4

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 (667) hide show
  1. package/ARCHITECTURE.md +273 -10
  2. package/Dockerfile +2 -3
  3. package/bun.lock +5 -13
  4. package/docs/backup-troubleshooting.md +52 -0
  5. package/docs/browser-use-architecture-phase2.md +174 -0
  6. package/docs/stt-provider-onboarding.md +120 -0
  7. package/knip.json +12 -2
  8. package/node_modules/@vellumai/ces-contracts/bun.lock +8 -6
  9. package/node_modules/@vellumai/ces-contracts/package.json +3 -3
  10. package/openapi.yaml +982 -72
  11. package/package.json +4 -6
  12. package/scripts/generate-openapi.ts +0 -1
  13. package/scripts/test.sh +73 -18
  14. package/src/__tests__/agent-image-optimize.test.ts +28 -0
  15. package/src/__tests__/agent-loop.test.ts +123 -0
  16. package/src/__tests__/anthropic-provider.test.ts +263 -10
  17. package/src/__tests__/auto-analysis-end-to-end.test.ts +550 -0
  18. package/src/__tests__/auto-analysis-prompt.test.ts +50 -0
  19. package/src/__tests__/browser-fill-credential.test.ts +11 -0
  20. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +2 -2
  21. package/src/__tests__/browser-skill-endstate.test.ts +31 -7
  22. package/src/__tests__/btw-routes.test.ts +7 -0
  23. package/src/__tests__/call-controller.test.ts +581 -20
  24. package/src/__tests__/catalog-files.test.ts +138 -0
  25. package/src/__tests__/channel-invite-transport.test.ts +2 -2
  26. package/src/__tests__/channel-readiness-routes.test.ts +16 -20
  27. package/src/__tests__/channel-readiness-service.test.ts +12 -7
  28. package/src/__tests__/checker.test.ts +157 -10
  29. package/src/__tests__/clawhub-files.test.ts +347 -0
  30. package/src/__tests__/commit-message-enrichment-service.test.ts +36 -19
  31. package/src/__tests__/config-analysis.test.ts +100 -0
  32. package/src/__tests__/config-schema.test.ts +1013 -66
  33. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +339 -0
  34. package/src/__tests__/config-watcher.test.ts +43 -8
  35. package/src/__tests__/contact-store-user-file.test.ts +512 -0
  36. package/src/__tests__/contacts-write.test.ts +197 -0
  37. package/src/__tests__/context-window-manager.test.ts +88 -0
  38. package/src/__tests__/conversation-abort-tool-results.test.ts +2 -0
  39. package/src/__tests__/conversation-agent-loop-overflow.test.ts +1 -0
  40. package/src/__tests__/conversation-agent-loop.test.ts +98 -2
  41. package/src/__tests__/conversation-confirmation-signals.test.ts +135 -0
  42. package/src/__tests__/conversation-error.test.ts +70 -0
  43. package/src/__tests__/conversation-history-web-search.test.ts +11 -4
  44. package/src/__tests__/conversation-init.benchmark.test.ts +6 -1
  45. package/src/__tests__/conversation-launcher-skill-regression.test.ts +51 -0
  46. package/src/__tests__/conversation-list-source.test.ts +145 -0
  47. package/src/__tests__/conversation-pre-run-repair.test.ts +2 -0
  48. package/src/__tests__/conversation-provider-retry-repair.test.ts +2 -0
  49. package/src/__tests__/conversation-queue.test.ts +901 -60
  50. package/src/__tests__/conversation-routes-disk-view.test.ts +270 -0
  51. package/src/__tests__/conversation-runtime-assembly.test.ts +55 -0
  52. package/src/__tests__/conversation-skill-tools.test.ts +7 -4
  53. package/src/__tests__/conversation-slash-commands.test.ts +33 -0
  54. package/src/__tests__/conversation-slash-queue.test.ts +89 -18
  55. package/src/__tests__/conversation-slash-unknown.test.ts +2 -0
  56. package/src/__tests__/conversation-tool-setup-batch-authorized.test.ts +226 -0
  57. package/src/__tests__/conversation-workspace-injection.test.ts +2 -0
  58. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +2 -0
  59. package/src/__tests__/credential-health-service.test.ts +352 -0
  60. package/src/__tests__/credential-security-invariants.test.ts +5 -3
  61. package/src/__tests__/credential-vault-unit.test.ts +379 -3
  62. package/src/__tests__/credentials-cli.test.ts +40 -16
  63. package/src/__tests__/cross-provider-web-search.test.ts +146 -35
  64. package/src/__tests__/deterministic-verification-control-plane.test.ts +10 -1
  65. package/src/__tests__/device-id.test.ts +112 -0
  66. package/src/__tests__/docker-signing-key-bootstrap.test.ts +167 -4
  67. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +1 -3
  68. package/src/__tests__/email-html-renderer.test.ts +71 -0
  69. package/src/__tests__/email-invite-adapter.test.ts +36 -32
  70. package/src/__tests__/emit-event-signal.test.ts +71 -0
  71. package/src/__tests__/extension-id-sync-guard.test.ts +75 -8
  72. package/src/__tests__/fixtures/mock-chrome-extension.ts +11 -0
  73. package/src/__tests__/gateway-only-enforcement.test.ts +206 -1
  74. package/src/__tests__/gateway-only-guard.test.ts +0 -1
  75. package/src/__tests__/gemini-provider.test.ts +64 -0
  76. package/src/__tests__/get-skill-detail-audit.test.ts +325 -0
  77. package/src/__tests__/gmail-archive-fallback.test.ts +193 -0
  78. package/src/__tests__/gmail-archive-gate.test.ts +246 -0
  79. package/src/__tests__/gmail-preferences.test.ts +117 -0
  80. package/src/__tests__/headless-browser-interactions.test.ts +43 -0
  81. package/src/__tests__/headless-browser-mode.test.ts +614 -0
  82. package/src/__tests__/headless-browser-navigate.test.ts +142 -5
  83. package/src/__tests__/headless-browser-read-tools.test.ts +11 -0
  84. package/src/__tests__/headless-browser-snapshot.test.ts +10 -0
  85. package/src/__tests__/heartbeat-service.test.ts +70 -17
  86. package/src/__tests__/home-state-routes.test.ts +162 -0
  87. package/src/__tests__/host-bash-proxy.test.ts +0 -5
  88. package/src/__tests__/host-browser-e2e-cloud.test.ts +138 -4
  89. package/src/__tests__/host-browser-e2e-self-hosted.test.ts +4 -4
  90. package/src/__tests__/host-browser-ws-events-e2e.test.ts +103 -0
  91. package/src/__tests__/host-cu-proxy.test.ts +0 -5
  92. package/src/__tests__/identity-intro-cache.test.ts +40 -10
  93. package/src/__tests__/init-feature-flag-overrides.test.ts +38 -112
  94. package/src/__tests__/jobs-store-upsert-debounced.test.ts +141 -0
  95. package/src/__tests__/llm-context-normalization.test.ts +488 -0
  96. package/src/__tests__/llm-context-route-provider.test.ts +86 -5
  97. package/src/__tests__/llm-usage-store.test.ts +363 -0
  98. package/src/__tests__/media-stream-output.test.ts +555 -0
  99. package/src/__tests__/media-stream-parser.test.ts +374 -0
  100. package/src/__tests__/media-stream-server-integration.test.ts +1234 -0
  101. package/src/__tests__/media-stream-stt-session.test.ts +588 -0
  102. package/src/__tests__/media-turn-detector.test.ts +440 -0
  103. package/src/__tests__/message-queue.test.ts +125 -0
  104. package/src/__tests__/migration-export-http.test.ts +6 -6
  105. package/src/__tests__/migration-import-commit-http.test.ts +8 -6
  106. package/src/__tests__/migration-import-preflight-http.test.ts +6 -5
  107. package/src/__tests__/migration-validate-http.test.ts +3 -3
  108. package/src/__tests__/mock-gateway-ipc.ts +151 -0
  109. package/src/__tests__/model-intents.test.ts +2 -2
  110. package/src/__tests__/oauth-apps-routes.test.ts +1 -0
  111. package/src/__tests__/oauth-cli.test.ts +2 -0
  112. package/src/__tests__/oauth-connect-orchestrator.test.ts +2 -0
  113. package/src/__tests__/oauth-provider-serializer.test.ts +1 -0
  114. package/src/__tests__/oauth-providers-routes.test.ts +2 -0
  115. package/src/__tests__/oauth-store.test.ts +85 -0
  116. package/src/__tests__/oauth2-gateway-transport.test.ts +249 -6
  117. package/src/__tests__/onboarding-template-contract.test.ts +6 -13
  118. package/src/__tests__/openai-provider.test.ts +176 -0
  119. package/src/__tests__/openai-responses-cutover-guard.test.ts +184 -0
  120. package/src/__tests__/openai-responses-provider.test.ts +1105 -0
  121. package/src/__tests__/openrouter-token-estimation.test.ts +100 -0
  122. package/src/__tests__/outlook-unsubscribe.test.ts +31 -2
  123. package/src/__tests__/persona-resolver.test.ts +251 -0
  124. package/src/__tests__/platform-bash-auto-approve.test.ts +4 -0
  125. package/src/__tests__/platform.test.ts +92 -1
  126. package/src/__tests__/post-turn-tool-result-truncation.test.ts +47 -0
  127. package/src/__tests__/prechat-onboarding-contract.test.ts +267 -0
  128. package/src/__tests__/pricing.test.ts +174 -0
  129. package/src/__tests__/qdrant-manager.test.ts +29 -8
  130. package/src/__tests__/regenerate-fire-and-forget-trace.test.ts +194 -0
  131. package/src/__tests__/relationship-state-contract.test.ts +175 -0
  132. package/src/__tests__/relay-server.test.ts +423 -5
  133. package/src/__tests__/search-skills-unified.test.ts +118 -0
  134. package/src/__tests__/secret-scanner-executor.test.ts +4 -0
  135. package/src/__tests__/secure-keys.test.ts +107 -0
  136. package/src/__tests__/send-endpoint-busy.test.ts +5 -1
  137. package/src/__tests__/sequence-store.test.ts +1 -1
  138. package/src/__tests__/server-history-render.test.ts +49 -0
  139. package/src/__tests__/settings-routes.test.ts +201 -0
  140. package/src/__tests__/skill-load-feature-flag.test.ts +1 -0
  141. package/src/__tests__/skills-file-content-endpoint.test.ts +276 -145
  142. package/src/__tests__/skills-files-catalog-fallback.test.ts +381 -93
  143. package/src/__tests__/skills.test.ts +5 -2
  144. package/src/__tests__/skillssh-files.test.ts +446 -0
  145. package/src/__tests__/slack-block-formatting.test.ts +110 -0
  146. package/src/__tests__/slack-channel-config.test.ts +564 -1
  147. package/src/__tests__/stt-catalog-parity.test.ts +282 -0
  148. package/src/__tests__/stt-stream-session.test.ts +535 -0
  149. package/src/__tests__/system-prompt.test.ts +112 -26
  150. package/src/__tests__/telephony-stt-routing.test.ts +329 -0
  151. package/src/__tests__/terminal-tools.test.ts +18 -7
  152. package/src/__tests__/test-preload.ts +18 -0
  153. package/src/__tests__/test-support/browser-skill-harness.ts +4 -1
  154. package/src/__tests__/tool-executor-lifecycle-events.test.ts +9 -5
  155. package/src/__tests__/tool-executor-shell-integration.test.ts +4 -0
  156. package/src/__tests__/tool-executor.test.ts +33 -24
  157. package/src/__tests__/tool-result-truncation.test.ts +36 -0
  158. package/src/__tests__/trust-store.test.ts +7 -1
  159. package/src/__tests__/trusted-contact-approval-notifier.test.ts +1 -1
  160. package/src/__tests__/tts-catalog-parity.test.ts +345 -0
  161. package/src/__tests__/twilio-routes-twiml.test.ts +512 -114
  162. package/src/__tests__/twilio-routes.test.ts +376 -0
  163. package/src/__tests__/unicode.test.ts +293 -0
  164. package/src/__tests__/update-bulletin-format.test.ts +59 -0
  165. package/src/__tests__/update-bulletin.test.ts +206 -5
  166. package/src/__tests__/usage-routes.test.ts +25 -4
  167. package/src/__tests__/user-reference.test.ts +46 -61
  168. package/src/__tests__/verification-control-plane-policy.test.ts +4 -0
  169. package/src/__tests__/voice-config-update.test.ts +403 -0
  170. package/src/__tests__/voice-quality.test.ts +434 -19
  171. package/src/__tests__/workspace-heartbeat-service.test.ts +7 -0
  172. package/src/__tests__/workspace-migration-033-stt-service-explicit-config.test.ts +547 -0
  173. package/src/__tests__/workspace-migration-034-remove-calls-voice-transcription-provider.test.ts +596 -0
  174. package/src/__tests__/workspace-migration-drop-user-md.test.ts +368 -0
  175. package/src/__tests__/workspace-migration-meets.test.ts +244 -0
  176. package/src/__tests__/workspace-migration-seed-device-id.test.ts +14 -20
  177. package/src/__tests__/workspace-policy.test.ts +2 -0
  178. package/src/agent/image-optimize.ts +24 -12
  179. package/src/agent/loop.ts +43 -3
  180. package/src/backup/__tests__/backup-key.test.ts +152 -0
  181. package/src/backup/__tests__/backup-worker.test.ts +767 -0
  182. package/src/backup/__tests__/list-snapshots.test.ts +87 -0
  183. package/src/backup/__tests__/local-writer.test.ts +218 -0
  184. package/src/backup/__tests__/offsite-writer.test.ts +641 -0
  185. package/src/backup/__tests__/paths.test.ts +300 -0
  186. package/src/backup/__tests__/restore.test.ts +498 -0
  187. package/src/backup/__tests__/snapshot-lock.test.ts +352 -0
  188. package/src/backup/__tests__/stream-crypt.test.ts +228 -0
  189. package/src/backup/backup-key.ts +137 -0
  190. package/src/backup/backup-worker.ts +459 -0
  191. package/src/backup/list-snapshots.ts +147 -0
  192. package/src/backup/local-writer.ts +133 -0
  193. package/src/backup/offsite-writer.ts +222 -0
  194. package/src/backup/paths.ts +226 -0
  195. package/src/backup/restore.ts +322 -0
  196. package/src/backup/snapshot-lock.ts +431 -0
  197. package/src/backup/stream-crypt.ts +263 -0
  198. package/src/bundler/package-resolver.ts +4 -0
  199. package/src/calls/audio-store.ts +11 -5
  200. package/src/calls/call-controller.ts +226 -71
  201. package/src/calls/call-domain.ts +9 -0
  202. package/src/calls/call-speech-output.ts +190 -0
  203. package/src/calls/call-transport.ts +77 -0
  204. package/src/calls/media-stream-audio-transcode.ts +173 -0
  205. package/src/calls/media-stream-output.ts +660 -0
  206. package/src/calls/media-stream-parser.ts +300 -0
  207. package/src/calls/media-stream-protocol.ts +166 -0
  208. package/src/calls/media-stream-server.ts +592 -0
  209. package/src/calls/media-stream-stt-session.ts +460 -0
  210. package/src/calls/media-turn-detector.ts +230 -0
  211. package/src/calls/relay-server.ts +90 -75
  212. package/src/calls/resolve-call-tts-provider.ts +136 -0
  213. package/src/calls/telephony-stt-routing.ts +145 -0
  214. package/src/calls/tts-call-strategy.ts +161 -0
  215. package/src/calls/tts-text-sanitizer.ts +32 -16
  216. package/src/calls/twilio-routes.ts +281 -17
  217. package/src/calls/voice-quality.ts +78 -35
  218. package/src/calls/voice-session-bridge.ts +8 -1
  219. package/src/channels/types.ts +16 -0
  220. package/src/cli/__tests__/run-assistant-command.ts +11 -1
  221. package/src/cli/commands/__tests__/backup.test.ts +1165 -0
  222. package/src/cli/commands/__tests__/domain-register.test.ts +234 -0
  223. package/src/cli/commands/__tests__/domain-status.test.ts +132 -0
  224. package/src/cli/commands/__tests__/email-attachment.test.ts +422 -0
  225. package/src/cli/commands/__tests__/email-download.test.ts +16 -1
  226. package/src/cli/commands/__tests__/email-list.test.ts +22 -4
  227. package/src/cli/commands/__tests__/email-register.test.ts +4 -4
  228. package/src/cli/commands/__tests__/email-send.test.ts +37 -4
  229. package/src/cli/commands/__tests__/email-status.test.ts +5 -1
  230. package/src/cli/commands/__tests__/email-unregister.test.ts +34 -5
  231. package/src/cli/commands/backup.ts +993 -0
  232. package/src/cli/commands/conversations.ts +77 -0
  233. package/src/cli/commands/credentials.ts +0 -1
  234. package/src/cli/commands/domain.ts +210 -0
  235. package/src/cli/commands/email.ts +255 -3
  236. package/src/cli/commands/oauth/__tests__/connect.test.ts +12 -0
  237. package/src/cli/commands/oauth/__tests__/providers-delete.test.ts +1 -0
  238. package/src/cli/commands/oauth/__tests__/providers-register.test.ts +1 -0
  239. package/src/cli/commands/oauth/__tests__/providers-update.test.ts +1 -0
  240. package/src/cli/commands/oauth/mode.ts +12 -3
  241. package/src/cli/commands/oauth/providers.ts +15 -0
  242. package/src/cli/commands/oauth/shared.ts +2 -1
  243. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +4 -9
  244. package/src/cli/commands/platform/__tests__/connect.test.ts +6 -0
  245. package/src/cli/commands/platform/__tests__/disconnect.test.ts +7 -1
  246. package/src/cli/commands/platform/__tests__/status.test.ts +6 -0
  247. package/src/cli/program.ts +30 -4
  248. package/src/config/__tests__/backup-schema.test.ts +134 -0
  249. package/src/config/assistant-feature-flags.ts +61 -62
  250. package/src/config/bundled-skills/app-builder/references/CUSTOM_ROUTES.md +37 -1
  251. package/src/config/bundled-skills/browser/SKILL.md +30 -5
  252. package/src/config/bundled-skills/browser/TOOLS.json +123 -0
  253. package/src/config/bundled-skills/browser/tools/browser-attach.ts +12 -0
  254. package/src/config/bundled-skills/browser/tools/browser-detach.ts +12 -0
  255. package/src/config/bundled-skills/browser/tools/browser-status.ts +12 -0
  256. package/src/config/bundled-skills/browser/tools/browser-wait-for-download.ts +17 -0
  257. package/src/config/bundled-skills/contacts/SKILL.md +2 -2
  258. package/src/config/bundled-skills/gmail/SKILL.md +53 -7
  259. package/src/config/bundled-skills/gmail/TOOLS.json +33 -3
  260. package/src/config/bundled-skills/gmail/tools/gmail-archive.ts +116 -9
  261. package/src/config/bundled-skills/gmail/tools/gmail-outreach-scan.ts +138 -11
  262. package/src/config/bundled-skills/gmail/tools/gmail-preferences-tool.ts +59 -0
  263. package/src/config/bundled-skills/gmail/tools/gmail-preferences.ts +82 -0
  264. package/src/config/bundled-skills/gmail/tools/gmail-sender-digest.ts +113 -17
  265. package/src/config/bundled-skills/gmail/tools/gmail-unsubscribe.ts +2 -2
  266. package/src/config/bundled-skills/media-processing/SKILL.md +3 -9
  267. package/src/config/bundled-skills/media-processing/TOOLS.json +1 -6
  268. package/src/config/bundled-skills/media-processing/__tests__/audio-transcribe.test.ts +125 -0
  269. package/src/config/bundled-skills/media-processing/__tests__/extract-keyframes.test.ts +181 -0
  270. package/src/config/bundled-skills/media-processing/__tests__/preprocess-audio.test.ts +141 -0
  271. package/src/config/bundled-skills/media-processing/services/audio-transcribe.ts +32 -87
  272. package/src/config/bundled-skills/media-processing/services/preprocess.ts +8 -4
  273. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +0 -10
  274. package/src/config/bundled-skills/messaging/SKILL.md +3 -3
  275. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +2 -2
  276. package/src/config/bundled-skills/outlook/SKILL.md +2 -2
  277. package/src/config/bundled-skills/outlook/tools/outlook-unsubscribe.ts +2 -2
  278. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  279. package/src/config/bundled-skills/phone-calls/references/CONFIG.md +27 -18
  280. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +3 -3
  281. package/src/config/bundled-skills/settings/TOOLS.json +3 -3
  282. package/src/config/bundled-skills/settings/tools/voice-config-update.ts +26 -22
  283. package/src/config/bundled-skills/slack/SKILL.md +1 -0
  284. package/src/config/bundled-skills/transcribe/SKILL.md +9 -14
  285. package/src/config/bundled-skills/transcribe/TOOLS.json +2 -7
  286. package/src/config/bundled-skills/transcribe/tools/transcribe-media.test.ts +256 -0
  287. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +38 -188
  288. package/src/config/bundled-tool-registry.ts +8 -0
  289. package/src/config/env-registry.ts +24 -0
  290. package/src/config/env.ts +34 -10
  291. package/src/config/feature-flag-registry.json +46 -14
  292. package/src/config/loader.ts +26 -12
  293. package/src/config/schema.ts +35 -10
  294. package/src/config/schemas/__tests__/stt.test.ts +43 -0
  295. package/src/config/schemas/analysis.ts +51 -0
  296. package/src/config/schemas/backup.ts +72 -0
  297. package/src/config/schemas/calls.ts +1 -26
  298. package/src/config/schemas/elevenlabs.ts +0 -59
  299. package/src/config/schemas/filing.ts +47 -7
  300. package/src/config/schemas/heartbeat.ts +27 -5
  301. package/src/config/schemas/host-browser.ts +47 -1
  302. package/src/config/schemas/inference.ts +1 -1
  303. package/src/config/schemas/memory-lifecycle.ts +14 -2
  304. package/src/config/schemas/services.ts +44 -0
  305. package/src/config/schemas/stt.ts +59 -0
  306. package/src/config/schemas/tts.ts +230 -0
  307. package/src/config/schemas/updates.ts +14 -0
  308. package/src/config/skills.ts +4 -0
  309. package/src/config/types.ts +4 -0
  310. package/src/contacts/contact-store.ts +56 -11
  311. package/src/contacts/contacts-write.ts +38 -1
  312. package/src/context/post-turn-tool-result-truncation.ts +3 -2
  313. package/src/context/tool-result-truncation.ts +2 -1
  314. package/src/context/window-manager.ts +45 -12
  315. package/src/credential-execution/executable-discovery.ts +12 -2
  316. package/src/credential-execution/process-manager.ts +33 -2
  317. package/src/credential-health/credential-health-service.ts +366 -0
  318. package/src/daemon/__tests__/conversation-lifecycle-auto-analyze.test.ts +324 -0
  319. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +497 -0
  320. package/src/daemon/__tests__/conversation-tool-setup.test.ts +17 -8
  321. package/src/daemon/__tests__/lifecycle-startup-ordering.test.ts +127 -0
  322. package/src/daemon/config-watcher.ts +99 -5
  323. package/src/daemon/conversation-agent-loop-handlers.ts +6 -0
  324. package/src/daemon/conversation-agent-loop.ts +101 -24
  325. package/src/daemon/conversation-error.ts +11 -0
  326. package/src/daemon/conversation-history.ts +40 -6
  327. package/src/daemon/conversation-launch.ts +220 -0
  328. package/src/daemon/conversation-lifecycle.ts +59 -9
  329. package/src/daemon/conversation-messaging.ts +37 -3
  330. package/src/daemon/conversation-notifiers.ts +5 -0
  331. package/src/daemon/conversation-process.ts +581 -19
  332. package/src/daemon/conversation-queue-manager.ts +24 -0
  333. package/src/daemon/conversation-runtime-assembly.ts +11 -1
  334. package/src/daemon/conversation-slash.ts +36 -0
  335. package/src/daemon/conversation-surfaces.ts +94 -4
  336. package/src/daemon/conversation-tool-setup.ts +25 -0
  337. package/src/daemon/conversation-usage.ts +7 -4
  338. package/src/daemon/conversation.ts +86 -28
  339. package/src/daemon/handlers/config-slack-channel.ts +269 -94
  340. package/src/daemon/handlers/conversations.ts +4 -1
  341. package/src/daemon/handlers/shared.ts +22 -0
  342. package/src/daemon/handlers/skills.ts +321 -77
  343. package/src/daemon/host-browser-proxy.ts +2 -1
  344. package/src/daemon/lifecycle.ts +122 -25
  345. package/src/daemon/message-protocol.ts +6 -0
  346. package/src/daemon/message-types/conversations.ts +34 -1
  347. package/src/daemon/message-types/home.ts +40 -0
  348. package/src/daemon/message-types/meet.ts +143 -0
  349. package/src/daemon/message-types/messages.ts +14 -0
  350. package/src/daemon/message-types/schedules.ts +34 -2
  351. package/src/daemon/message-types/skills.ts +16 -0
  352. package/src/daemon/message-types/surfaces.ts +2 -0
  353. package/src/daemon/server.ts +347 -2
  354. package/src/daemon/shutdown-handlers.ts +32 -4
  355. package/src/daemon/shutdown-registry.ts +40 -0
  356. package/src/daemon/tool-side-effects.ts +9 -0
  357. package/src/email/html-renderer.ts +76 -0
  358. package/src/heartbeat/heartbeat-service.ts +93 -7
  359. package/src/home/__tests__/assistant-feed-authoring.test.ts +156 -0
  360. package/src/home/__tests__/emit-feed-event.test.ts +169 -0
  361. package/src/home/__tests__/feed-scheduler.test.ts +194 -0
  362. package/src/home/__tests__/feed-types.test.ts +275 -0
  363. package/src/home/__tests__/feed-writer.test.ts +688 -0
  364. package/src/home/__tests__/phase5-exit-criteria.test.ts +212 -0
  365. package/src/home/__tests__/platform-gmail-digest.test.ts +222 -0
  366. package/src/home/__tests__/progress-formula.test.ts +213 -0
  367. package/src/home/__tests__/relationship-state-writer.test.ts +740 -0
  368. package/src/home/__tests__/rollup-producer.test.ts +398 -0
  369. package/src/home/assistant-feed-authoring.ts +124 -0
  370. package/src/home/emit-feed-event.ts +158 -0
  371. package/src/home/feed-scheduler.ts +247 -0
  372. package/src/home/feed-types.ts +181 -0
  373. package/src/home/feed-writer.ts +469 -0
  374. package/src/home/platform-gmail-digest.ts +163 -0
  375. package/src/home/progress-formula.ts +86 -0
  376. package/src/home/relationship-state-writer.ts +824 -0
  377. package/src/home/relationship-state.ts +143 -0
  378. package/src/home/rollup-producer.ts +384 -0
  379. package/src/hooks/runner.ts +7 -0
  380. package/src/inbound/platform-callback-registration.ts +12 -3
  381. package/src/inbound/public-ingress-urls.ts +12 -0
  382. package/src/instrument.ts +1 -1
  383. package/src/ipc/__tests__/cli-ipc.test.ts +200 -0
  384. package/src/ipc/cli-client.ts +151 -0
  385. package/src/ipc/cli-server.ts +234 -0
  386. package/src/ipc/gateway-client.ts +180 -0
  387. package/src/ipc/routes/index.ts +5 -0
  388. package/src/ipc/routes/wake-conversation.ts +19 -0
  389. package/src/memory/__tests__/auto-analysis-enqueue.test.ts +356 -0
  390. package/src/memory/__tests__/auto-analysis-guard.test.ts +57 -0
  391. package/src/memory/__tests__/conversation-analyze-job.test.ts +232 -0
  392. package/src/memory/__tests__/find-analysis-conversation.test.ts +196 -0
  393. package/src/memory/app-store.ts +1 -1
  394. package/src/memory/attachments-store.ts +70 -0
  395. package/src/memory/auto-analysis-enqueue.ts +127 -0
  396. package/src/memory/auto-analysis-guard.ts +27 -0
  397. package/src/memory/cleanup-schedule-state.ts +37 -0
  398. package/src/memory/conversation-analyze-job.ts +73 -0
  399. package/src/memory/conversation-crud.ts +99 -0
  400. package/src/memory/conversation-disk-view.ts +7 -0
  401. package/src/memory/conversation-group-migration.ts +34 -2
  402. package/src/memory/conversation-queries.ts +6 -5
  403. package/src/memory/db-init.ts +6 -0
  404. package/src/memory/db-maintenance.ts +108 -0
  405. package/src/memory/db.ts +1 -0
  406. package/src/memory/graph/conversation-graph-memory.ts +15 -0
  407. package/src/memory/graph/extraction.test.ts +23 -0
  408. package/src/memory/graph/extraction.ts +8 -0
  409. package/src/memory/graph/retriever.ts +27 -18
  410. package/src/memory/graph/scoring.test.ts +186 -0
  411. package/src/memory/graph/scoring.ts +31 -1
  412. package/src/memory/graph/tools.ts +1 -1
  413. package/src/memory/group-crud.ts +6 -1
  414. package/src/memory/indexer.ts +95 -16
  415. package/src/memory/job-handlers/cleanup.ts +11 -8
  416. package/src/memory/job-handlers/conversation-starters.ts +16 -10
  417. package/src/memory/jobs-store.ts +64 -4
  418. package/src/memory/jobs-worker.ts +22 -9
  419. package/src/memory/llm-usage-store.ts +92 -56
  420. package/src/memory/migrations/219-oauth-providers-token-exchange-body-format.ts +15 -0
  421. package/src/memory/migrations/220-normalize-user-file-by-principal.ts +190 -0
  422. package/src/memory/migrations/221-conversations-archived-at.ts +16 -0
  423. package/src/memory/migrations/index.ts +6 -0
  424. package/src/memory/migrations/registry.ts +8 -0
  425. package/src/memory/qdrant-manager.ts +43 -16
  426. package/src/memory/schema/conversations.ts +2 -0
  427. package/src/memory/schema/oauth.ts +3 -0
  428. package/src/memory/usage-buckets.ts +396 -0
  429. package/src/messaging/providers/gmail/client.ts +57 -6
  430. package/src/messaging/providers/slack/__tests__/adapter-token-routing.test.ts +282 -0
  431. package/src/messaging/providers/slack/adapter.ts +143 -38
  432. package/src/messaging/providers/slack/client.ts +16 -0
  433. package/src/messaging/providers/slack/types.ts +4 -0
  434. package/src/notifications/decision-engine.ts +3 -3
  435. package/src/notifications/signal.ts +5 -0
  436. package/src/oauth/__tests__/identity-verifier.test.ts +1 -0
  437. package/src/oauth/byo-connection.test.ts +18 -1
  438. package/src/oauth/byo-connection.ts +3 -1
  439. package/src/oauth/connect-orchestrator.ts +2 -0
  440. package/src/oauth/connection-resolver.ts +6 -2
  441. package/src/oauth/connection.ts +2 -0
  442. package/src/oauth/oauth-store.ts +9 -0
  443. package/src/oauth/platform-connection.test.ts +98 -0
  444. package/src/oauth/platform-connection.ts +52 -31
  445. package/src/oauth/seed-providers.ts +7 -0
  446. package/src/permissions/checker.ts +16 -6
  447. package/src/permissions/defaults.ts +49 -1
  448. package/src/permissions/trust-store.ts +3 -3
  449. package/src/permissions/workspace-policy.ts +3 -0
  450. package/src/platform/client.test.ts +10 -0
  451. package/src/platform/sync-identity.ts +129 -0
  452. package/src/prompts/persona-resolver.ts +126 -2
  453. package/src/prompts/system-prompt.ts +59 -18
  454. package/src/prompts/templates/BOOTSTRAP.md +5 -5
  455. package/src/prompts/templates/SOUL.md +3 -1
  456. package/src/prompts/templates/UPDATES.md +12 -0
  457. package/src/prompts/templates/channels/slack.md +20 -0
  458. package/src/prompts/update-bulletin-format.ts +26 -9
  459. package/src/prompts/update-bulletin.ts +34 -23
  460. package/src/prompts/user-reference.ts +20 -17
  461. package/src/providers/__tests__/provider-secret-catalog.test.ts +42 -0
  462. package/src/providers/anthropic/client.ts +157 -61
  463. package/src/providers/fireworks/client.ts +2 -2
  464. package/src/providers/gemini/client.ts +9 -1
  465. package/src/providers/model-catalog.ts +6 -0
  466. package/src/providers/model-intents.ts +4 -4
  467. package/src/providers/ollama/client.ts +2 -2
  468. package/src/providers/openai/chat-completions-provider.ts +474 -0
  469. package/src/providers/openai/client.ts +25 -440
  470. package/src/providers/openai/responses-provider.ts +502 -0
  471. package/src/providers/openrouter/client.ts +101 -4
  472. package/src/providers/provider-secret-catalog.ts +139 -0
  473. package/src/providers/registry.ts +2 -2
  474. package/src/providers/retry.ts +14 -3
  475. package/src/providers/speech-to-text/__tests__/provider-catalog.test.ts +251 -0
  476. package/src/providers/speech-to-text/__tests__/resolve.test.ts +828 -0
  477. package/src/providers/speech-to-text/deepgram-realtime.test.ts +980 -0
  478. package/src/providers/speech-to-text/deepgram-realtime.ts +767 -0
  479. package/src/providers/speech-to-text/deepgram.test.ts +332 -0
  480. package/src/providers/speech-to-text/deepgram.ts +115 -0
  481. package/src/providers/speech-to-text/google-gemini-live-stream.test.ts +743 -0
  482. package/src/providers/speech-to-text/google-gemini-live-stream.ts +625 -0
  483. package/src/providers/speech-to-text/google-gemini.test.ts +226 -0
  484. package/src/providers/speech-to-text/google-gemini.ts +101 -0
  485. package/src/providers/speech-to-text/openai-whisper-stream.test.ts +564 -0
  486. package/src/providers/speech-to-text/openai-whisper-stream.ts +381 -0
  487. package/src/providers/speech-to-text/openai-whisper.test.ts +1 -37
  488. package/src/providers/speech-to-text/openai-whisper.ts +63 -33
  489. package/src/providers/speech-to-text/provider-catalog.ts +306 -0
  490. package/src/providers/speech-to-text/resolve.ts +386 -6
  491. package/src/providers/types.ts +9 -0
  492. package/src/runtime/AGENTS.md +43 -1
  493. package/src/runtime/__tests__/agent-wake.test.ts +831 -0
  494. package/src/runtime/__tests__/runtime-mode.test.ts +62 -0
  495. package/src/runtime/__tests__/slack-block-formatting.test.ts +481 -0
  496. package/src/runtime/agent-wake.ts +512 -0
  497. package/src/runtime/auth/__tests__/route-policy.test.ts +40 -0
  498. package/src/runtime/auth/route-policy.ts +30 -5
  499. package/src/runtime/auth/token-service.ts +56 -1
  500. package/src/runtime/btw-sidechain.ts +2 -0
  501. package/src/runtime/capability-tokens.ts +10 -10
  502. package/src/runtime/channel-invite-transport.ts +1 -1
  503. package/src/runtime/channel-invite-transports/email.ts +14 -6
  504. package/src/runtime/channel-readiness-service.ts +12 -22
  505. package/src/runtime/chrome-extension-registry.ts +38 -2
  506. package/src/runtime/http-server.ts +395 -10
  507. package/src/runtime/http-types.ts +6 -2
  508. package/src/runtime/migrations/__tests__/vbundle-import-credentials.test.ts +36 -0
  509. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +360 -0
  510. package/src/runtime/migrations/migration-transport.ts +1 -0
  511. package/src/runtime/migrations/migration-wizard.ts +1 -0
  512. package/src/runtime/migrations/vbundle-import-analyzer.ts +77 -1
  513. package/src/runtime/migrations/vbundle-importer.ts +34 -0
  514. package/src/runtime/pending-interactions.ts +0 -11
  515. package/src/runtime/routes/__tests__/backup-routes.test.ts +967 -0
  516. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +507 -0
  517. package/src/runtime/routes/__tests__/migration-import-credential-filter.test.ts +208 -0
  518. package/src/runtime/routes/__tests__/stt-routes.test.ts +406 -0
  519. package/src/runtime/routes/__tests__/tts-routes.test.ts +474 -0
  520. package/src/runtime/routes/__tests__/user-route-dispatcher.test.ts +148 -17
  521. package/src/runtime/routes/app-management-routes.ts +12 -18
  522. package/src/runtime/routes/attachment-routes.test.ts +9 -3
  523. package/src/runtime/routes/attachment-routes.ts +216 -17
  524. package/src/runtime/routes/backup-routes.ts +519 -0
  525. package/src/runtime/routes/browser-extension-pair-routes.ts +82 -23
  526. package/src/runtime/routes/btw-routes.ts +8 -6
  527. package/src/runtime/routes/contact-routes.test.ts +298 -0
  528. package/src/runtime/routes/contact-routes.ts +132 -5
  529. package/src/runtime/routes/conversation-analysis-routes.ts +22 -142
  530. package/src/runtime/routes/conversation-management-routes.ts +115 -0
  531. package/src/runtime/routes/conversation-routes.ts +367 -146
  532. package/src/runtime/routes/filing-routes.ts +93 -0
  533. package/src/runtime/routes/home-feed-routes.ts +334 -0
  534. package/src/runtime/routes/home-state-routes.ts +138 -0
  535. package/src/runtime/routes/host-browser-routes.ts +3 -14
  536. package/src/runtime/routes/identity-intro-cache.ts +7 -3
  537. package/src/runtime/routes/identity-routes.ts +3 -17
  538. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +46 -39
  539. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +15 -15
  540. package/src/runtime/routes/integrations/slack/__tests__/channel.test.ts +137 -0
  541. package/src/runtime/routes/integrations/slack/__tests__/share.test.ts +179 -0
  542. package/src/runtime/routes/integrations/slack/channel.ts +11 -3
  543. package/src/runtime/routes/integrations/slack/share.ts +45 -7
  544. package/src/runtime/routes/llm-context-normalization.ts +303 -0
  545. package/src/runtime/routes/memory-item-routes.test.ts +3 -2
  546. package/src/runtime/routes/migration-routes.ts +40 -5
  547. package/src/runtime/routes/settings-routes.ts +22 -5
  548. package/src/runtime/routes/skills-routes.ts +76 -7
  549. package/src/runtime/routes/stt-routes.ts +233 -0
  550. package/src/runtime/routes/surface-action-routes.ts +41 -2
  551. package/src/runtime/routes/tts-routes.ts +108 -24
  552. package/src/runtime/routes/usage-routes.ts +30 -2
  553. package/src/runtime/routes/user-route-dispatcher.ts +50 -5
  554. package/src/runtime/routes/user-routes.ts +13 -1
  555. package/src/runtime/routes/work-items-routes.ts +8 -1
  556. package/src/runtime/runtime-mode.ts +33 -0
  557. package/src/runtime/services/__tests__/analyze-conversation.test.ts +444 -0
  558. package/src/runtime/services/__tests__/analyze-deps-singleton.test.ts +67 -0
  559. package/src/runtime/services/__tests__/auto-analysis-prompt.test.ts +53 -0
  560. package/src/runtime/services/__tests__/manual-analysis-prompt.test.ts +41 -0
  561. package/src/runtime/services/analyze-conversation.ts +344 -0
  562. package/src/runtime/services/analyze-deps-singleton.ts +32 -0
  563. package/src/runtime/services/auto-analysis-prompt.ts +55 -0
  564. package/src/runtime/skill-route-registry.ts +49 -0
  565. package/src/runtime/slack-block-formatting.ts +437 -10
  566. package/src/schedule/scheduler.ts +50 -0
  567. package/src/security/oauth2.ts +26 -4
  568. package/src/security/secure-keys.ts +25 -2
  569. package/src/security/token-manager.ts +8 -0
  570. package/src/sequence/engine.ts +23 -0
  571. package/src/sequence/types.ts +1 -1
  572. package/src/skills/catalog-files.ts +64 -2
  573. package/src/skills/category-inference.ts +122 -0
  574. package/src/skills/clawhub-files.ts +213 -0
  575. package/src/skills/clawhub.ts +84 -23
  576. package/src/skills/skill-file-provider.ts +40 -0
  577. package/src/skills/skillssh-files.ts +395 -0
  578. package/src/skills/skillssh-registry.ts +4 -4
  579. package/src/stt/__tests__/daemon-batch-transcriber.test.ts +392 -0
  580. package/src/stt/__tests__/types.test.ts +89 -0
  581. package/src/stt/daemon-batch-transcriber.ts +195 -0
  582. package/src/stt/stt-stream-session.ts +499 -0
  583. package/src/stt/types.ts +330 -0
  584. package/src/stt/wav-encoder.test.ts +373 -0
  585. package/src/stt/wav-encoder.ts +175 -0
  586. package/src/subagent/manager.ts +38 -14
  587. package/src/tools/browser/__tests__/browser-mode.test.ts +119 -0
  588. package/src/tools/browser/__tests__/browser-status.test.ts +123 -0
  589. package/src/tools/browser/browser-execution.ts +1163 -23
  590. package/src/tools/browser/browser-manager.ts +45 -0
  591. package/src/tools/browser/browser-mode-constants.ts +12 -0
  592. package/src/tools/browser/browser-mode.ts +92 -0
  593. package/src/tools/browser/browser-status-constants.ts +33 -0
  594. package/src/tools/browser/cdp-client/__tests__/cdp-inspect-client.test.ts +393 -0
  595. package/src/tools/browser/cdp-client/__tests__/extension-cdp-client.test.ts +29 -0
  596. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +1648 -32
  597. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/discovery.test.ts +264 -0
  598. package/src/tools/browser/cdp-client/cdp-inspect/discovery.ts +183 -17
  599. package/src/tools/browser/cdp-client/cdp-inspect-client.ts +254 -21
  600. package/src/tools/browser/cdp-client/errors.ts +15 -0
  601. package/src/tools/browser/cdp-client/extension-cdp-client.ts +39 -16
  602. package/src/tools/browser/cdp-client/factory.ts +797 -87
  603. package/src/tools/browser/cdp-client/index.ts +16 -2
  604. package/src/tools/browser/cdp-client/types.ts +68 -0
  605. package/src/tools/credentials/vault.ts +35 -6
  606. package/src/tools/network/web-fetch.ts +5 -2
  607. package/src/tools/network/web-search.ts +5 -2
  608. package/src/tools/shared/shell-output.ts +3 -1
  609. package/src/tools/side-effects.ts +2 -0
  610. package/src/tools/skills/sandbox-runner.ts +3 -2
  611. package/src/tools/terminal/safe-env.ts +10 -2
  612. package/src/tools/terminal/shell.ts +15 -4
  613. package/src/tools/tool-manifest.ts +21 -0
  614. package/src/tools/types.ts +17 -0
  615. package/src/tools/ui-surface/definitions.ts +6 -1
  616. package/src/tts/__tests__/provider-adapters.test.ts +834 -0
  617. package/src/tts/__tests__/provider-catalog-consistency.test.ts +196 -0
  618. package/src/tts/__tests__/provider-catalog.test.ts +183 -0
  619. package/src/tts/__tests__/provider-registry.test.ts +90 -0
  620. package/src/tts/provider-catalog.ts +201 -0
  621. package/src/tts/provider-registry.ts +73 -0
  622. package/src/tts/providers/deepgram-provider.ts +219 -0
  623. package/src/tts/providers/elevenlabs-provider.ts +211 -0
  624. package/src/tts/providers/fish-audio-provider.ts +183 -0
  625. package/src/tts/providers/index.ts +42 -0
  626. package/src/tts/providers/register-builtins.ts +130 -0
  627. package/src/tts/synthesize-text.ts +110 -0
  628. package/src/tts/tts-config-resolver.ts +78 -0
  629. package/src/tts/types.ts +153 -0
  630. package/src/types/onboarding-context.ts +7 -0
  631. package/src/util/abort-reasons.ts +58 -0
  632. package/src/util/device-id.ts +32 -16
  633. package/src/util/errors.ts +9 -1
  634. package/src/util/platform.ts +54 -10
  635. package/src/util/pricing.ts +66 -3
  636. package/src/util/spawn.ts +1 -1
  637. package/src/util/truncate.ts +4 -2
  638. package/src/util/unicode.ts +201 -0
  639. package/src/version.ts +19 -24
  640. package/src/watcher/engine.ts +23 -0
  641. package/src/watcher/watcher-store.ts +31 -0
  642. package/src/workspace/migrations/003-seed-device-id.ts +9 -3
  643. package/src/workspace/migrations/017-seed-persona-dirs.ts +68 -4
  644. package/src/workspace/migrations/029-seed-pkb.ts +1 -1
  645. package/src/workspace/migrations/031-drop-user-md.ts +317 -0
  646. package/src/workspace/migrations/031-llm-log-retention-zero-to-null.ts +73 -0
  647. package/src/workspace/migrations/032-tts-provider-unification.ts +227 -0
  648. package/src/workspace/migrations/033-stt-service-explicit-config.ts +122 -0
  649. package/src/workspace/migrations/034-remove-calls-voice-transcription-provider.ts +215 -0
  650. package/src/workspace/migrations/035-seed-slack-channel-persona.ts +50 -0
  651. package/src/workspace/migrations/036-update-pkb-index-bar.ts +37 -0
  652. package/src/workspace/migrations/037-create-meets-dir.ts +61 -0
  653. package/src/workspace/migrations/registry.ts +16 -0
  654. package/src/workspace/top-level-renderer.ts +13 -1
  655. package/src/workspace/turn-commit.ts +31 -0
  656. package/src/__tests__/email-cli.test.ts +0 -297
  657. package/src/__tests__/email-service-config-fallback.test.ts +0 -102
  658. package/src/cli/commands/browser-relay.ts +0 -466
  659. package/src/email/guardrails.ts +0 -221
  660. package/src/email/provider.ts +0 -117
  661. package/src/email/providers/agentmail.ts +0 -361
  662. package/src/email/providers/index.ts +0 -65
  663. package/src/email/service.ts +0 -384
  664. package/src/email/types.ts +0 -126
  665. package/src/prompts/templates/USER.md +0 -13
  666. package/src/providers/speech-to-text/types.ts +0 -17
  667. package/src/runtime/routes/browser-cdp-routes.ts +0 -229
@@ -0,0 +1,767 @@
1
+ /**
2
+ * Tests for the periodic backup worker. Drives `runBackupTick` and
3
+ * `createSnapshotNow` directly with fake dependencies so the whole pipeline
4
+ * runs against a temp directory with an in-memory checkpoint store and
5
+ * no real database.
6
+ *
7
+ * `streamExportVBundle` is stubbed to write a tiny byte blob to a temp
8
+ * file — the worker never validates bundle contents, it just hands the
9
+ * path to `writeLocalSnapshot` which renames it into place.
10
+ */
11
+
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readdirSync,
17
+ rmSync,
18
+ writeFileSync,
19
+ } from "node:fs";
20
+ import { unlink, writeFile } from "node:fs/promises";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+ import {
24
+ afterEach,
25
+ beforeEach,
26
+ describe,
27
+ expect,
28
+ mock,
29
+ test,
30
+ } from "bun:test";
31
+
32
+ import type { BackupConfig, BackupDestination } from "../../config/schema.js";
33
+ import { BackupConfigSchema } from "../../config/schema.js";
34
+ import type { StreamExportVBundleResult } from "../../runtime/migrations/vbundle-builder.js";
35
+ import type { BackupDeps } from "../backup-worker.js";
36
+ import {
37
+ createSnapshotNow,
38
+ runBackupTick,
39
+ } from "../backup-worker.js";
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Test fixtures
43
+ // ---------------------------------------------------------------------------
44
+
45
+ let ROOT: string;
46
+
47
+ beforeEach(() => {
48
+ ROOT = mkdtempSync(join(tmpdir(), "vellum-backup-worker-"));
49
+ });
50
+
51
+ afterEach(() => {
52
+ try {
53
+ rmSync(ROOT, { recursive: true, force: true });
54
+ } catch {
55
+ // best-effort
56
+ }
57
+ });
58
+
59
+ /** Build a valid BackupConfig with overrides. Starts from schema defaults. */
60
+ function makeConfig(overrides?: {
61
+ enabled?: boolean;
62
+ intervalHours?: number;
63
+ retention?: number;
64
+ localDirectory?: string | null;
65
+ offsite?: {
66
+ enabled?: boolean;
67
+ destinations?: BackupDestination[] | null;
68
+ };
69
+ }): BackupConfig {
70
+ const base = BackupConfigSchema.parse({});
71
+ return {
72
+ ...base,
73
+ enabled: overrides?.enabled ?? base.enabled,
74
+ intervalHours: overrides?.intervalHours ?? base.intervalHours,
75
+ retention: overrides?.retention ?? base.retention,
76
+ localDirectory: overrides?.localDirectory ?? base.localDirectory,
77
+ offsite: {
78
+ enabled: overrides?.offsite?.enabled ?? base.offsite.enabled,
79
+ destinations:
80
+ overrides?.offsite?.destinations === undefined
81
+ ? base.offsite.destinations
82
+ : overrides.offsite.destinations,
83
+ },
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Build an in-memory checkpoint store. Returns fake getters/setters and a
89
+ * plain object so tests can inspect or preload entries.
90
+ */
91
+ function makeCheckpointStore(initial: Record<string, string> = {}) {
92
+ const store: Record<string, string> = { ...initial };
93
+ return {
94
+ store,
95
+ get: (key: string): string | null => store[key] ?? null,
96
+ set: (key: string, value: string): void => {
97
+ store[key] = value;
98
+ },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Build a stub `streamExportVBundle` that writes a tiny payload to a fresh
104
+ * temp file on every call. The worker only cares that `tempPath` exists and
105
+ * can be renamed — the bundle content is never introspected.
106
+ */
107
+ function makeStreamExportStub(): {
108
+ fn: BackupDeps["streamExportVBundle"];
109
+ calls: Array<Parameters<NonNullable<BackupDeps["streamExportVBundle"]>>[0]>;
110
+ } {
111
+ const calls: Array<
112
+ Parameters<NonNullable<BackupDeps["streamExportVBundle"]>>[0]
113
+ > = [];
114
+ let counter = 0;
115
+ const fn: BackupDeps["streamExportVBundle"] = async (opts) => {
116
+ calls.push(opts);
117
+ // Deliberately do NOT fire opts.checkpoint?.() here — the real checkpoint
118
+ // callback opens a fresh DB handle at `getDbPath()` which does not exist
119
+ // in the test environment. Tests don't care about the WAL side-effect;
120
+ // they just need the stub to return a valid temp bundle path.
121
+ counter += 1;
122
+ const tempPath = join(ROOT, `stub-bundle-${counter}.tmp`);
123
+ await writeFile(tempPath, `fake bundle ${counter}`);
124
+ const result: StreamExportVBundleResult = {
125
+ tempPath,
126
+ size: 16,
127
+ manifest: {
128
+ schema_version: "1.0",
129
+ created_at: new Date().toISOString(),
130
+ source: "test",
131
+ description: "test stub",
132
+ files: [],
133
+ manifest_sha256: "0".repeat(64),
134
+ },
135
+ cleanup: async () => {
136
+ try {
137
+ await unlink(tempPath);
138
+ } catch {
139
+ // best-effort
140
+ }
141
+ },
142
+ };
143
+ return result;
144
+ };
145
+ return { fn, calls };
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // runBackupTick — gating
150
+ // ---------------------------------------------------------------------------
151
+
152
+ describe("runBackupTick — gating", () => {
153
+ test("returns null when config.enabled is false", async () => {
154
+ const checkpoints = makeCheckpointStore();
155
+ const streamStub = makeStreamExportStub();
156
+ const config = makeConfig({ enabled: false });
157
+ const localDir = join(ROOT, "local");
158
+
159
+ const result = await runBackupTick(config, new Date(), {
160
+ streamExportVBundle: streamStub.fn,
161
+ getMemoryCheckpoint: checkpoints.get,
162
+ setMemoryCheckpoint: checkpoints.set,
163
+ workspaceDir: ROOT,
164
+ localDir,
165
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
166
+ });
167
+
168
+ expect(result).toBeNull();
169
+ expect(streamStub.calls).toHaveLength(0);
170
+ expect(Object.keys(checkpoints.store)).toHaveLength(0);
171
+ expect(existsSync(localDir)).toBe(false);
172
+ });
173
+
174
+ test("returns null when last_run_at is within the interval window", async () => {
175
+ const now = new Date("2026-04-11T10:00:00Z");
176
+ const oneHourAgoMs = now.getTime() - 1 * 3600 * 1000;
177
+ const checkpoints = makeCheckpointStore({
178
+ "backup:last_run_at": String(oneHourAgoMs),
179
+ });
180
+ const streamStub = makeStreamExportStub();
181
+ const config = makeConfig({ enabled: true, intervalHours: 6 });
182
+ const localDir = join(ROOT, "local");
183
+
184
+ const result = await runBackupTick(config, now, {
185
+ streamExportVBundle: streamStub.fn,
186
+ getMemoryCheckpoint: checkpoints.get,
187
+ setMemoryCheckpoint: checkpoints.set,
188
+ workspaceDir: ROOT,
189
+ localDir,
190
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
191
+ // Explicit plaintext to avoid touching the key file
192
+ trustPath: join(ROOT, "trust.json"),
193
+ hooksDir: join(ROOT, "hooks"),
194
+ });
195
+
196
+ expect(result).toBeNull();
197
+ expect(streamStub.calls).toHaveLength(0);
198
+ // Checkpoint unchanged
199
+ expect(checkpoints.store["backup:last_run_at"]).toBe(String(oneHourAgoMs));
200
+ });
201
+
202
+ test("runs when last_run_at is older than the interval", async () => {
203
+ const now = new Date("2026-04-11T10:00:00Z");
204
+ const sevenHoursAgoMs = now.getTime() - 7 * 3600 * 1000;
205
+ const checkpoints = makeCheckpointStore({
206
+ "backup:last_run_at": String(sevenHoursAgoMs),
207
+ });
208
+ const streamStub = makeStreamExportStub();
209
+ const config = makeConfig({
210
+ enabled: true,
211
+ intervalHours: 6,
212
+ offsite: { enabled: false, destinations: null },
213
+ });
214
+ const localDir = join(ROOT, "local");
215
+
216
+ const result = await runBackupTick(config, now, {
217
+ streamExportVBundle: streamStub.fn,
218
+ getMemoryCheckpoint: checkpoints.get,
219
+ setMemoryCheckpoint: checkpoints.set,
220
+ workspaceDir: ROOT,
221
+ localDir,
222
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
223
+ });
224
+
225
+ expect(result).not.toBeNull();
226
+ expect(streamStub.calls).toHaveLength(1);
227
+ expect(checkpoints.store["backup:last_run_at"]).toBe(String(now.getTime()));
228
+ // Local snapshot file was created
229
+ expect(result!.local.path).toContain("backup-20260411-100000-000.vbundle");
230
+ expect(existsSync(result!.local.path)).toBe(true);
231
+ expect(result!.offsite).toEqual([]);
232
+ });
233
+
234
+ test("runs when last_run_at checkpoint is missing (first-ever run)", async () => {
235
+ const now = new Date("2026-04-11T10:00:00Z");
236
+ const checkpoints = makeCheckpointStore();
237
+ const streamStub = makeStreamExportStub();
238
+ const config = makeConfig({
239
+ enabled: true,
240
+ offsite: { enabled: false, destinations: null },
241
+ });
242
+ const localDir = join(ROOT, "local");
243
+
244
+ const result = await runBackupTick(config, now, {
245
+ streamExportVBundle: streamStub.fn,
246
+ getMemoryCheckpoint: checkpoints.get,
247
+ setMemoryCheckpoint: checkpoints.set,
248
+ workspaceDir: ROOT,
249
+ localDir,
250
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
251
+ });
252
+
253
+ expect(result).not.toBeNull();
254
+ expect(checkpoints.store["backup:last_run_at"]).toBe(String(now.getTime()));
255
+ });
256
+ });
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // runBackupTick — offsite destinations
260
+ // ---------------------------------------------------------------------------
261
+
262
+ describe("runBackupTick — offsite destinations", () => {
263
+ test("config.offsite.enabled === false: offsite is empty and key is not loaded", async () => {
264
+ const checkpoints = makeCheckpointStore();
265
+ const streamStub = makeStreamExportStub();
266
+ const ensureKey = mock(async () => Buffer.alloc(32, 1));
267
+ const config = makeConfig({
268
+ enabled: true,
269
+ offsite: { enabled: false, destinations: null },
270
+ });
271
+ const localDir = join(ROOT, "local");
272
+
273
+ const result = await runBackupTick(config, new Date(), {
274
+ streamExportVBundle: streamStub.fn,
275
+ getMemoryCheckpoint: checkpoints.get,
276
+ setMemoryCheckpoint: checkpoints.set,
277
+ ensureBackupKey: ensureKey,
278
+ workspaceDir: ROOT,
279
+ localDir,
280
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
281
+ });
282
+
283
+ expect(result).not.toBeNull();
284
+ expect(result!.offsite).toEqual([]);
285
+ expect(ensureKey).not.toHaveBeenCalled();
286
+ });
287
+
288
+ test("single plaintext destination: key is not loaded, file has .vbundle extension", async () => {
289
+ const checkpoints = makeCheckpointStore();
290
+ const streamStub = makeStreamExportStub();
291
+ const ensureKey = mock(async () => Buffer.alloc(32, 1));
292
+ const offsiteDir = join(ROOT, "offsite", "plain");
293
+ // Parent must exist — writer probes for parent before mkdir of dest.
294
+ mkdirSync(join(ROOT, "offsite"), { recursive: true });
295
+ const config = makeConfig({
296
+ enabled: true,
297
+ offsite: {
298
+ enabled: true,
299
+ destinations: [{ path: offsiteDir, encrypt: false }],
300
+ },
301
+ });
302
+ const localDir = join(ROOT, "local");
303
+ const now = new Date("2026-04-11T12:00:00Z");
304
+
305
+ const result = await runBackupTick(config, now, {
306
+ streamExportVBundle: streamStub.fn,
307
+ getMemoryCheckpoint: checkpoints.get,
308
+ setMemoryCheckpoint: checkpoints.set,
309
+ ensureBackupKey: ensureKey,
310
+ workspaceDir: ROOT,
311
+ localDir,
312
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
313
+ });
314
+
315
+ expect(result).not.toBeNull();
316
+ expect(ensureKey).not.toHaveBeenCalled();
317
+ expect(result!.offsite).toHaveLength(1);
318
+ expect(result!.offsite[0].entry).not.toBeNull();
319
+ expect(result!.offsite[0].entry!.filename).toBe(
320
+ "backup-20260411-120000-000.vbundle",
321
+ );
322
+ expect(result!.offsite[0].entry!.encrypted).toBe(false);
323
+ expect(existsSync(result!.offsite[0].entry!.path)).toBe(true);
324
+ });
325
+
326
+ test("single encrypted destination: key is loaded, file has .vbundle.enc extension", async () => {
327
+ const checkpoints = makeCheckpointStore();
328
+ const streamStub = makeStreamExportStub();
329
+ const ensureKey = mock(async () => Buffer.alloc(32, 0xab));
330
+ const offsiteDir = join(ROOT, "offsite", "enc");
331
+ mkdirSync(join(ROOT, "offsite"), { recursive: true });
332
+ const keyPath = join(ROOT, "backup.key");
333
+ const config = makeConfig({
334
+ enabled: true,
335
+ offsite: {
336
+ enabled: true,
337
+ destinations: [{ path: offsiteDir, encrypt: true }],
338
+ },
339
+ });
340
+ const localDir = join(ROOT, "local");
341
+ const now = new Date("2026-04-11T13:00:00Z");
342
+
343
+ const result = await runBackupTick(config, now, {
344
+ streamExportVBundle: streamStub.fn,
345
+ getMemoryCheckpoint: checkpoints.get,
346
+ setMemoryCheckpoint: checkpoints.set,
347
+ ensureBackupKey: ensureKey,
348
+ backupKeyPath: keyPath,
349
+ workspaceDir: ROOT,
350
+ localDir,
351
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
352
+ });
353
+
354
+ expect(result).not.toBeNull();
355
+ expect(ensureKey).toHaveBeenCalledTimes(1);
356
+ expect(ensureKey).toHaveBeenCalledWith(keyPath);
357
+ expect(result!.offsite).toHaveLength(1);
358
+ expect(result!.offsite[0].entry).not.toBeNull();
359
+ expect(result!.offsite[0].entry!.filename).toBe(
360
+ "backup-20260411-130000-000.vbundle.enc",
361
+ );
362
+ expect(result!.offsite[0].entry!.encrypted).toBe(true);
363
+ expect(existsSync(result!.offsite[0].entry!.path)).toBe(true);
364
+ });
365
+
366
+ test("mixed destinations: key is loaded once (because A needs it), both files written", async () => {
367
+ const checkpoints = makeCheckpointStore();
368
+ const streamStub = makeStreamExportStub();
369
+ const ensureKey = mock(async () => Buffer.alloc(32, 0xcd));
370
+ const encDir = join(ROOT, "offsite", "enc");
371
+ const plainDir = join(ROOT, "offsite", "plain");
372
+ mkdirSync(join(ROOT, "offsite"), { recursive: true });
373
+ const config = makeConfig({
374
+ enabled: true,
375
+ offsite: {
376
+ enabled: true,
377
+ destinations: [
378
+ { path: encDir, encrypt: true },
379
+ { path: plainDir, encrypt: false },
380
+ ],
381
+ },
382
+ });
383
+ const localDir = join(ROOT, "local");
384
+
385
+ const result = await runBackupTick(config, new Date(), {
386
+ streamExportVBundle: streamStub.fn,
387
+ getMemoryCheckpoint: checkpoints.get,
388
+ setMemoryCheckpoint: checkpoints.set,
389
+ ensureBackupKey: ensureKey,
390
+ workspaceDir: ROOT,
391
+ localDir,
392
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
393
+ });
394
+
395
+ expect(result).not.toBeNull();
396
+ expect(ensureKey).toHaveBeenCalledTimes(1);
397
+ expect(result!.offsite).toHaveLength(2);
398
+ expect(result!.offsite[0].entry).not.toBeNull();
399
+ expect(result!.offsite[0].entry!.encrypted).toBe(true);
400
+ expect(result!.offsite[1].entry).not.toBeNull();
401
+ expect(result!.offsite[1].entry!.encrypted).toBe(false);
402
+ });
403
+
404
+ test("mixed reachability: one ok + one parent-missing skip, local succeeds, checkpoint updated", async () => {
405
+ const checkpoints = makeCheckpointStore();
406
+ const streamStub = makeStreamExportStub();
407
+ const reachableDir = join(ROOT, "offsite", "reachable");
408
+ // Nested parent-missing: the parent directory is /nope/deeper which is
409
+ // unreachable because /nope itself does not exist.
410
+ const unreachableDir = join(ROOT, "nope", "deeper", "backups");
411
+ mkdirSync(join(ROOT, "offsite"), { recursive: true });
412
+ const config = makeConfig({
413
+ enabled: true,
414
+ offsite: {
415
+ enabled: true,
416
+ destinations: [
417
+ { path: reachableDir, encrypt: false },
418
+ { path: unreachableDir, encrypt: false },
419
+ ],
420
+ },
421
+ });
422
+ const localDir = join(ROOT, "local");
423
+ const now = new Date("2026-04-11T14:00:00Z");
424
+
425
+ const result = await runBackupTick(config, now, {
426
+ streamExportVBundle: streamStub.fn,
427
+ getMemoryCheckpoint: checkpoints.get,
428
+ setMemoryCheckpoint: checkpoints.set,
429
+ workspaceDir: ROOT,
430
+ localDir,
431
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
432
+ });
433
+
434
+ expect(result).not.toBeNull();
435
+ expect(result!.offsite).toHaveLength(2);
436
+ expect(result!.offsite[0].entry).not.toBeNull();
437
+ expect(result!.offsite[1].entry).toBeNull();
438
+ expect(result!.offsite[1].skipped).toBe("parent-missing");
439
+ // Local still succeeded
440
+ expect(existsSync(result!.local.path)).toBe(true);
441
+ // Checkpoint updated because performBackup returned successfully
442
+ expect(checkpoints.store["backup:last_run_at"]).toBe(String(now.getTime()));
443
+ });
444
+ });
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // runBackupTick — error propagation
448
+ // ---------------------------------------------------------------------------
449
+
450
+ describe("runBackupTick — error propagation", () => {
451
+ test("throws when streamExportVBundle throws and leaves checkpoint untouched", async () => {
452
+ const checkpoints = makeCheckpointStore();
453
+ const throwingStream: BackupDeps["streamExportVBundle"] = async () => {
454
+ throw new Error("boom");
455
+ };
456
+ const config = makeConfig({
457
+ enabled: true,
458
+ offsite: { enabled: false, destinations: null },
459
+ });
460
+ const localDir = join(ROOT, "local");
461
+
462
+ await expect(
463
+ runBackupTick(config, new Date(), {
464
+ streamExportVBundle: throwingStream,
465
+ getMemoryCheckpoint: checkpoints.get,
466
+ setMemoryCheckpoint: checkpoints.set,
467
+ workspaceDir: ROOT,
468
+ localDir,
469
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
470
+ }),
471
+ ).rejects.toThrow("boom");
472
+ expect(checkpoints.store["backup:last_run_at"]).toBeUndefined();
473
+ });
474
+ });
475
+
476
+ // ---------------------------------------------------------------------------
477
+ // createSnapshotNow — manual trigger
478
+ // ---------------------------------------------------------------------------
479
+
480
+ describe("createSnapshotNow", () => {
481
+ test("bypasses enabled check (snapshot created even when enabled is false)", async () => {
482
+ const checkpoints = makeCheckpointStore();
483
+ const streamStub = makeStreamExportStub();
484
+ const config = makeConfig({
485
+ enabled: false,
486
+ offsite: { enabled: false, destinations: null },
487
+ });
488
+ const localDir = join(ROOT, "local");
489
+
490
+ const result = await createSnapshotNow(config, new Date(), {
491
+ streamExportVBundle: streamStub.fn,
492
+ getMemoryCheckpoint: checkpoints.get,
493
+ setMemoryCheckpoint: checkpoints.set,
494
+ workspaceDir: ROOT,
495
+ localDir,
496
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
497
+ });
498
+
499
+ expect(result).not.toBeNull();
500
+ expect(streamStub.calls).toHaveLength(1);
501
+ // Manual runs do NOT update the automatic cadence checkpoint
502
+ expect(checkpoints.store["backup:last_run_at"]).toBeUndefined();
503
+ });
504
+
505
+ test("bypasses interval check even when a recent run was recorded", async () => {
506
+ const now = new Date("2026-04-11T10:00:00Z");
507
+ const checkpoints = makeCheckpointStore({
508
+ "backup:last_run_at": String(now.getTime() - 60_000),
509
+ });
510
+ const streamStub = makeStreamExportStub();
511
+ const config = makeConfig({
512
+ enabled: true,
513
+ intervalHours: 6,
514
+ offsite: { enabled: false, destinations: null },
515
+ });
516
+ const localDir = join(ROOT, "local");
517
+
518
+ const result = await createSnapshotNow(config, now, {
519
+ streamExportVBundle: streamStub.fn,
520
+ getMemoryCheckpoint: checkpoints.get,
521
+ setMemoryCheckpoint: checkpoints.set,
522
+ workspaceDir: ROOT,
523
+ localDir,
524
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
525
+ });
526
+
527
+ expect(result).not.toBeNull();
528
+ expect(streamStub.calls).toHaveLength(1);
529
+ // The pre-existing checkpoint is preserved — manual runs do not touch it.
530
+ expect(checkpoints.store["backup:last_run_at"]).toBe(
531
+ String(now.getTime() - 60_000),
532
+ );
533
+ });
534
+
535
+ test("two concurrent calls: second throws 'snapshot in progress'", async () => {
536
+ const checkpoints = makeCheckpointStore();
537
+ // Stub that holds the first caller indefinitely until we release it,
538
+ // giving the test a clean window to observe the mutex from a second call.
539
+ let release: () => void = () => {};
540
+ const holdPromise = new Promise<void>((resolve) => {
541
+ release = resolve;
542
+ });
543
+ let callCount = 0;
544
+ const holdingStream: BackupDeps["streamExportVBundle"] = async (_opts) => {
545
+ callCount += 1;
546
+ if (callCount === 1) {
547
+ await holdPromise;
548
+ }
549
+ const tempPath = join(ROOT, `hold-${callCount}.tmp`);
550
+ writeFileSync(tempPath, "payload");
551
+ return {
552
+ tempPath,
553
+ size: 7,
554
+ manifest: {
555
+ schema_version: "1.0",
556
+ created_at: new Date().toISOString(),
557
+ files: [],
558
+ manifest_sha256: "0".repeat(64),
559
+ },
560
+ cleanup: async () => {
561
+ try {
562
+ await unlink(tempPath);
563
+ } catch {
564
+ // best-effort
565
+ }
566
+ },
567
+ };
568
+ };
569
+ const config = makeConfig({
570
+ enabled: true,
571
+ offsite: { enabled: false, destinations: null },
572
+ });
573
+ const localDir = join(ROOT, "local");
574
+
575
+ // Start the first call — it will park inside `streamExportVBundle`
576
+ // waiting on holdPromise.
577
+ const first = createSnapshotNow(config, new Date(), {
578
+ streamExportVBundle: holdingStream,
579
+ getMemoryCheckpoint: checkpoints.get,
580
+ setMemoryCheckpoint: checkpoints.set,
581
+ workspaceDir: ROOT,
582
+ localDir,
583
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
584
+ });
585
+
586
+ // Yield once so the first call has a chance to enter the mutex + the
587
+ // stream stub before we kick off the second call.
588
+ await Promise.resolve();
589
+ await Promise.resolve();
590
+
591
+ await expect(
592
+ createSnapshotNow(config, new Date(), {
593
+ streamExportVBundle: holdingStream,
594
+ getMemoryCheckpoint: checkpoints.get,
595
+ setMemoryCheckpoint: checkpoints.set,
596
+ workspaceDir: ROOT,
597
+ localDir,
598
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
599
+ }),
600
+ ).rejects.toThrow("snapshot in progress");
601
+
602
+ release();
603
+ await first;
604
+ // Only the first call should have been executed by the stub.
605
+ expect(callCount).toBe(1);
606
+ });
607
+ });
608
+
609
+ // ---------------------------------------------------------------------------
610
+ // Cross-process lock (simulates a second process holding the lock)
611
+ // ---------------------------------------------------------------------------
612
+
613
+ describe("cross-process snapshot lock", () => {
614
+ test("after performBackup succeeds, the lock file no longer exists", async () => {
615
+ const checkpoints = makeCheckpointStore();
616
+ const streamStub = makeStreamExportStub();
617
+ const config = makeConfig({
618
+ enabled: true,
619
+ offsite: { enabled: false, destinations: null },
620
+ });
621
+ const localDir = join(ROOT, "local");
622
+ const lockPath = join(ROOT, ".snapshot.lock");
623
+
624
+ const result = await createSnapshotNow(config, new Date(), {
625
+ streamExportVBundle: streamStub.fn,
626
+ getMemoryCheckpoint: checkpoints.get,
627
+ setMemoryCheckpoint: checkpoints.set,
628
+ workspaceDir: ROOT,
629
+ localDir,
630
+ snapshotLockPath: lockPath,
631
+ });
632
+
633
+ expect(result).not.toBeNull();
634
+ // Lock file released on the finally path — must not linger on disk.
635
+ expect(existsSync(lockPath)).toBe(false);
636
+ });
637
+
638
+ test("another process holds the lock: createSnapshotNow throws 'snapshot in progress'", async () => {
639
+ const checkpoints = makeCheckpointStore();
640
+ const streamStub = makeStreamExportStub();
641
+ const config = makeConfig({
642
+ enabled: true,
643
+ offsite: { enabled: false, destinations: null },
644
+ });
645
+ const localDir = join(ROOT, "local");
646
+ const lockPath = join(ROOT, ".snapshot.lock");
647
+
648
+ // Simulate a concurrent CLI invocation by writing a lock file with the
649
+ // CURRENT pid (which is definitely alive — it's us). Because the lock
650
+ // file pre-exists and the PID probes as alive, the in-process flag will
651
+ // pass (it's reset after the previous test) but the cross-process lock
652
+ // will reject with "snapshot in progress (locked by pid N)".
653
+ writeFileSync(lockPath, `${process.pid} ${Date.now()}\n`, { mode: 0o600 });
654
+
655
+ await expect(
656
+ createSnapshotNow(config, new Date(), {
657
+ streamExportVBundle: streamStub.fn,
658
+ getMemoryCheckpoint: checkpoints.get,
659
+ setMemoryCheckpoint: checkpoints.set,
660
+ workspaceDir: ROOT,
661
+ localDir,
662
+ snapshotLockPath: lockPath,
663
+ }),
664
+ ).rejects.toThrow(/snapshot in progress/);
665
+
666
+ // The stream stub must not have been invoked because acquisition failed
667
+ // before performBackup ran.
668
+ expect(streamStub.calls).toHaveLength(0);
669
+ // The pre-existing lock file is preserved — we did not own it, so we
670
+ // must not have removed it on the failed-acquisition path.
671
+ expect(existsSync(lockPath)).toBe(true);
672
+ });
673
+
674
+ test("runBackupTick defers silently when another process holds the lock", async () => {
675
+ const now = new Date("2026-04-11T10:00:00Z");
676
+ const checkpoints = makeCheckpointStore();
677
+ const streamStub = makeStreamExportStub();
678
+ const config = makeConfig({
679
+ enabled: true,
680
+ offsite: { enabled: false, destinations: null },
681
+ });
682
+ const localDir = join(ROOT, "local");
683
+ const lockPath = join(ROOT, ".snapshot.lock");
684
+
685
+ // Pre-seed the lock file with the live PID so the worker observes a
686
+ // conflict on its cross-process check.
687
+ writeFileSync(lockPath, `${process.pid} ${Date.now()}\n`, { mode: 0o600 });
688
+
689
+ const result = await runBackupTick(config, now, {
690
+ streamExportVBundle: streamStub.fn,
691
+ getMemoryCheckpoint: checkpoints.get,
692
+ setMemoryCheckpoint: checkpoints.set,
693
+ workspaceDir: ROOT,
694
+ localDir,
695
+ snapshotLockPath: lockPath,
696
+ });
697
+
698
+ // Scheduled tick defers silently on conflict rather than throwing — the
699
+ // next interval will retry.
700
+ expect(result).toBeNull();
701
+ expect(streamStub.calls).toHaveLength(0);
702
+ // Checkpoint must not advance when the tick defers.
703
+ expect(checkpoints.store["backup:last_run_at"]).toBeUndefined();
704
+ // Pre-existing lock file is preserved.
705
+ expect(existsSync(lockPath)).toBe(true);
706
+ });
707
+ });
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Retention — integration across multiple ticks
711
+ // ---------------------------------------------------------------------------
712
+
713
+ describe("retention across successive ticks", () => {
714
+ test("three ticks past the interval with retention=2 leaves 2 local + 2 offsite", async () => {
715
+ const checkpoints = makeCheckpointStore();
716
+ const streamStub = makeStreamExportStub();
717
+ const offsiteDir = join(ROOT, "offsite", "plain");
718
+ mkdirSync(join(ROOT, "offsite"), { recursive: true });
719
+ const config = makeConfig({
720
+ enabled: true,
721
+ intervalHours: 1,
722
+ retention: 2,
723
+ offsite: {
724
+ enabled: true,
725
+ destinations: [{ path: offsiteDir, encrypt: false }],
726
+ },
727
+ });
728
+ const localDir = join(ROOT, "local");
729
+
730
+ // Three successive runs, each 2 hours apart (past the 1-hour interval).
731
+ const t1 = new Date("2026-04-11T10:00:00Z");
732
+ const t2 = new Date("2026-04-11T12:00:00Z");
733
+ const t3 = new Date("2026-04-11T14:00:00Z");
734
+
735
+ for (const t of [t1, t2, t3]) {
736
+ const result = await runBackupTick(config, t, {
737
+ streamExportVBundle: streamStub.fn,
738
+ getMemoryCheckpoint: checkpoints.get,
739
+ setMemoryCheckpoint: checkpoints.set,
740
+ workspaceDir: ROOT,
741
+ localDir,
742
+ snapshotLockPath: join(ROOT, ".snapshot.lock"),
743
+ });
744
+ expect(result).not.toBeNull();
745
+ }
746
+
747
+ // After three runs with retention=2, only the two newest survive in
748
+ // both local and offsite pools.
749
+ const localFiles = readdirSync(localDir)
750
+ .filter((f) => f.startsWith("backup-"))
751
+ .sort();
752
+ expect(localFiles).toHaveLength(2);
753
+ expect(localFiles).toEqual([
754
+ "backup-20260411-120000-000.vbundle",
755
+ "backup-20260411-140000-000.vbundle",
756
+ ]);
757
+
758
+ const offsiteFiles = readdirSync(offsiteDir)
759
+ .filter((f) => f.startsWith("backup-"))
760
+ .sort();
761
+ expect(offsiteFiles).toHaveLength(2);
762
+ expect(offsiteFiles).toEqual([
763
+ "backup-20260411-120000-000.vbundle",
764
+ "backup-20260411-140000-000.vbundle",
765
+ ]);
766
+ });
767
+ });