@vellumai/assistant 0.7.1 → 0.7.2

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 (535) hide show
  1. package/ARCHITECTURE.md +32 -49
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -2
  4. package/__tests__/permissions/gateway-threshold-reader.test.ts +9 -3
  5. package/bun.lock +26 -26
  6. package/docs/architecture/security.md +20 -0
  7. package/docs/plugins.md +7 -9
  8. package/knip.json +1 -0
  9. package/node_modules/@vellumai/gateway-client/src/index.ts +1 -0
  10. package/node_modules/@vellumai/gateway-client/src/ipc-client.ts +39 -1
  11. package/node_modules/@vellumai/gateway-client/src/types.ts +11 -0
  12. package/node_modules/@vellumai/service-contracts/package.json +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +4 -0
  14. package/node_modules/@vellumai/service-contracts/src/__tests__/ingress.test.ts +107 -0
  15. package/node_modules/@vellumai/service-contracts/src/index.ts +5 -1
  16. package/node_modules/@vellumai/service-contracts/src/ingress.ts +24 -0
  17. package/node_modules/@vellumai/service-contracts/src/twilio-ingress.ts +84 -0
  18. package/node_modules/@vellumai/skill-host-contracts/src/assistant-event.ts +9 -0
  19. package/node_modules/@vellumai/twilio-client/bun.lock +24 -0
  20. package/node_modules/@vellumai/twilio-client/package.json +18 -0
  21. package/node_modules/@vellumai/twilio-client/src/__tests__/twilio-client.test.ts +128 -0
  22. package/node_modules/@vellumai/twilio-client/src/index.ts +179 -0
  23. package/node_modules/@vellumai/twilio-client/tsconfig.json +20 -0
  24. package/openapi.yaml +565 -12
  25. package/package.json +6 -3
  26. package/src/__tests__/app-builder-tool-scripts.test.ts +3 -3
  27. package/src/__tests__/app-bundler.test.ts +170 -1
  28. package/src/__tests__/app-control-flow.test.ts +374 -0
  29. package/src/__tests__/app-control-no-global-cgevent.test.ts +98 -0
  30. package/src/__tests__/app-control-tool-schemas.test.ts +621 -0
  31. package/src/__tests__/app-executors.test.ts +30 -43
  32. package/src/__tests__/approval-routes-http.test.ts +23 -6
  33. package/src/__tests__/assistant-event-hub-machine-name.test.ts +146 -0
  34. package/src/__tests__/assistant-event-hub-targeted.test.ts +257 -0
  35. package/src/__tests__/assistant-event-hub.test.ts +109 -2
  36. package/src/__tests__/assistant-event.test.ts +10 -0
  37. package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -2
  38. package/src/__tests__/assistant-feature-flags-integration.test.ts +11 -7
  39. package/src/__tests__/background-shell-host-bash.test.ts +14 -15
  40. package/src/__tests__/bootstrap-turn-cleanup.test.ts +44 -0
  41. package/src/__tests__/btw-routes.test.ts +13 -4
  42. package/src/__tests__/call-controller.test.ts +49 -1
  43. package/src/__tests__/call-domain.test.ts +0 -2
  44. package/src/__tests__/call-routes-http.test.ts +0 -2
  45. package/src/__tests__/channel-readiness-service.test.ts +59 -1
  46. package/src/__tests__/checker.test.ts +3 -4
  47. package/src/__tests__/config-loader-backfill.test.ts +90 -155
  48. package/src/__tests__/config-loader-platform-defaults.test.ts +196 -0
  49. package/src/__tests__/config-schema-cmd.test.ts +0 -1
  50. package/src/__tests__/config-set-platform-guard.test.ts +48 -4
  51. package/src/__tests__/config-watcher-cleanup-throttle.test.ts +2 -2
  52. package/src/__tests__/config-watcher.test.ts +2 -2
  53. package/src/__tests__/conversation-app-control-instantiation.test.ts +392 -0
  54. package/src/__tests__/conversation-app-control-lifecycle.test.ts +237 -0
  55. package/src/__tests__/conversation-init.benchmark.test.ts +0 -2
  56. package/src/__tests__/conversation-lifecycle.test.ts +36 -0
  57. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +283 -0
  58. package/src/__tests__/conversation-routes-disk-view.test.ts +6 -0
  59. package/src/__tests__/conversation-routes-guardian-reply.test.ts +120 -72
  60. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -0
  61. package/src/__tests__/conversation-slash-commands.test.ts +0 -4
  62. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +202 -0
  63. package/src/__tests__/conversation-surfaces-app-control.test.ts +317 -0
  64. package/src/__tests__/credential-execution-feature-gates.test.ts +5 -12
  65. package/src/__tests__/credential-execution-managed-contract.test.ts +3 -131
  66. package/src/__tests__/credentials-cli.test.ts +5 -12
  67. package/src/__tests__/cu-unified-flow.test.ts +185 -23
  68. package/src/__tests__/daemon-credential-client.test.ts +101 -19
  69. package/src/__tests__/db-schedule-syntax-migration.test.ts +2 -0
  70. package/src/__tests__/dynamic-skill-workflow-prompt.test.ts +0 -1
  71. package/src/__tests__/gateway-only-enforcement.test.ts +0 -1
  72. package/src/__tests__/guardian-verification-voice-binding.test.ts +0 -2
  73. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +0 -2
  74. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +0 -1
  75. package/src/__tests__/heartbeat-service.test.ts +718 -1
  76. package/src/__tests__/helpers/call-route-handler.ts +7 -1
  77. package/src/__tests__/host-app-control-proxy.test.ts +602 -0
  78. package/src/__tests__/host-app-control-routes.test.ts +263 -0
  79. package/src/__tests__/host-bash-proxy.test.ts +246 -47
  80. package/src/__tests__/host-bash-routes.test.ts +294 -0
  81. package/src/__tests__/host-browser-proxy.test.ts +24 -22
  82. package/src/__tests__/host-browser-routes.test.ts +39 -13
  83. package/src/__tests__/host-cu-proxy.test.ts +41 -52
  84. package/src/__tests__/host-cu-routes-targeted.test.ts +300 -0
  85. package/src/__tests__/host-file-edit-tool.test.ts +47 -1
  86. package/src/__tests__/host-file-proxy-targeted.test.ts +339 -0
  87. package/src/__tests__/host-file-proxy.test.ts +37 -43
  88. package/src/__tests__/host-file-read-tool.test.ts +17 -0
  89. package/src/__tests__/host-file-routes-targeted.test.ts +262 -0
  90. package/src/__tests__/host-file-write-tool.test.ts +42 -1
  91. package/src/__tests__/host-proxy-base.test.ts +312 -0
  92. package/src/__tests__/host-shell-tool.test.ts +22 -4
  93. package/src/__tests__/host-transfer-proxy-targeted.test.ts +583 -0
  94. package/src/__tests__/host-transfer-proxy.test.ts +121 -22
  95. package/src/__tests__/host-transfer-routes-targeted.test.ts +447 -0
  96. package/src/__tests__/http-user-message-parity.test.ts +1 -0
  97. package/src/__tests__/identity-intro-cache.test.ts +29 -0
  98. package/src/__tests__/identity-routes.test.ts +103 -1
  99. package/src/__tests__/init-feature-flag-overrides.test.ts +26 -3
  100. package/src/__tests__/inline-command-runner.test.ts +0 -1
  101. package/src/__tests__/inline-skill-load-permissions.test.ts +5 -11
  102. package/src/__tests__/integration-status.test.ts +85 -5
  103. package/src/__tests__/intent-routing.test.ts +0 -1
  104. package/src/__tests__/jobs-store-qdrant-breaker.test.ts +95 -5
  105. package/src/__tests__/lifecycle-memory-v2-seed.test.ts +17 -0
  106. package/src/__tests__/managed-skill-lifecycle.test.ts +0 -1
  107. package/src/__tests__/mcp-auth-routes.test.ts +197 -0
  108. package/src/__tests__/mcp-cli.test.ts +338 -2
  109. package/src/__tests__/memory-jobs-worker-lanes.test.ts +188 -0
  110. package/src/__tests__/migration-import-commit-http.test.ts +108 -2
  111. package/src/__tests__/mock-gateway-ipc.ts +1 -0
  112. package/src/__tests__/oauth-cli.test.ts +0 -2
  113. package/src/__tests__/oauth2-gateway-transport.test.ts +0 -1
  114. package/src/__tests__/persistence-secret-redaction.test.ts +299 -0
  115. package/src/__tests__/platform-bash-auto-approve.test.ts +5 -9
  116. package/src/__tests__/prechat-onboarding-contract.test.ts +3 -1
  117. package/src/__tests__/process-message-background-slack.test.ts +2 -0
  118. package/src/__tests__/provider-commit-message-generator.test.ts +0 -1
  119. package/src/__tests__/public-ingress-urls.test.ts +97 -0
  120. package/src/__tests__/require-fresh-approval.test.ts +0 -1
  121. package/src/__tests__/retry-backoff.test.ts +87 -0
  122. package/src/__tests__/runtime-events-sse.test.ts +10 -6
  123. package/src/__tests__/sanitize-config-for-transfer.test.ts +24 -2
  124. package/src/__tests__/schedule-retry.test.ts +715 -0
  125. package/src/__tests__/script-proxy-mitm-handler.test.ts +1 -1
  126. package/src/__tests__/secret-ingress-http.test.ts +1 -0
  127. package/src/__tests__/send-endpoint-busy.test.ts +3 -0
  128. package/src/__tests__/shell-tool-proxy-mode.test.ts +0 -1
  129. package/src/__tests__/skill-feature-flags.test.ts +43 -41
  130. package/src/__tests__/skill-load-feature-flag.test.ts +13 -14
  131. package/src/__tests__/skill-load-inline-command.test.ts +0 -51
  132. package/src/__tests__/skill-load-inline-includes.test.ts +0 -43
  133. package/src/__tests__/skill-projection.benchmark.test.ts +0 -1
  134. package/src/__tests__/skill-script-runner-sandbox.test.ts +0 -1
  135. package/src/__tests__/slack-channel-config.test.ts +9 -14
  136. package/src/__tests__/system-prompt-ask-mode.test.ts +0 -1
  137. package/src/__tests__/system-prompt.test.ts +0 -1
  138. package/src/__tests__/telegram-config.test.ts +0 -1
  139. package/src/__tests__/test-preload.ts +8 -0
  140. package/src/__tests__/tool-approval-handler.test.ts +3 -4
  141. package/src/__tests__/tool-audit-listener.test.ts +48 -0
  142. package/src/__tests__/tool-execute-pipeline.test.ts +0 -1
  143. package/src/__tests__/tool-execution-abort-cleanup.test.ts +0 -1
  144. package/src/__tests__/tool-executor-lifecycle-events.test.ts +0 -1
  145. package/src/__tests__/tool-executor.test.ts +0 -1
  146. package/src/__tests__/twilio-config.test.ts +3 -16
  147. package/src/__tests__/twilio-routes.test.ts +3 -5
  148. package/src/__tests__/twilio-validation.test.ts +93 -0
  149. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +1 -4
  150. package/src/__tests__/verification-control-plane-policy.test.ts +2 -4
  151. package/src/__tests__/voice-ingress-preflight.test.ts +19 -0
  152. package/src/__tests__/workspace-migration-006-services-config.test.ts +3 -2
  153. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +1 -5
  154. package/src/__tests__/workspace-migration-down-functions.test.ts +8 -8
  155. package/src/__tests__/workspace-migration-unify-llm-callsite-configs.test.ts +10 -6
  156. package/src/backup/__tests__/paths.test.ts +0 -22
  157. package/src/backup/__tests__/restore.test.ts +51 -151
  158. package/src/backup/paths.ts +2 -18
  159. package/src/backup/restore.ts +107 -231
  160. package/src/bundler/app-bundler.ts +51 -3
  161. package/src/calls/relay-server.ts +4 -44
  162. package/src/calls/twilio-config.ts +2 -17
  163. package/src/calls/twilio-rest.ts +33 -105
  164. package/src/calls/twilio-routes.ts +11 -12
  165. package/src/channels/types.ts +8 -7
  166. package/src/cli/commands/__tests__/backup.test.ts +6 -277
  167. package/src/cli/commands/__tests__/gateway.test.ts +288 -0
  168. package/src/cli/commands/__tests__/memory-v2.test.ts +4 -0
  169. package/src/cli/commands/__tests__/webhooks.test.ts +0 -1
  170. package/src/cli/commands/backup.ts +6 -331
  171. package/src/cli/commands/clients.ts +36 -37
  172. package/src/cli/commands/contacts.ts +73 -0
  173. package/src/cli/commands/conversations.ts +2 -5
  174. package/src/cli/commands/credentials.ts +15 -7
  175. package/src/cli/commands/domain.ts +66 -15
  176. package/src/cli/commands/gateway.ts +183 -0
  177. package/src/cli/commands/keys.ts +9 -6
  178. package/src/cli/commands/mcp.ts +116 -156
  179. package/src/cli/commands/memory-v2.ts +296 -1
  180. package/src/cli/commands/platform/__tests__/callback-routes-list.test.ts +0 -1
  181. package/src/cli/commands/platform/__tests__/connect.test.ts +0 -2
  182. package/src/cli/commands/platform/__tests__/disconnect.test.ts +0 -2
  183. package/src/cli/commands/platform/__tests__/status.test.ts +13 -15
  184. package/src/cli/commands/platform/disconnect.ts +5 -4
  185. package/src/cli/commands/platform/index.ts +0 -18
  186. package/src/cli/lib/daemon-credential-client.ts +110 -28
  187. package/src/cli/program.ts +2 -0
  188. package/src/config/assistant-feature-flags.ts +67 -10
  189. package/src/config/bundled-skills/acp/SKILL.md +6 -0
  190. package/src/config/bundled-skills/acp/TOOLS.json +1 -22
  191. package/src/config/bundled-skills/app-builder/SKILL.md +14 -109
  192. package/src/config/bundled-skills/app-builder/TOOLS.json +1 -28
  193. package/src/config/bundled-skills/app-builder/tools/app-create.ts +1 -10
  194. package/src/config/bundled-skills/app-control/SKILL.md +75 -0
  195. package/src/config/bundled-skills/app-control/TOOLS.json +299 -0
  196. package/src/config/bundled-skills/app-control/tools/app-control-click.ts +12 -0
  197. package/src/config/bundled-skills/app-control/tools/app-control-combo.ts +12 -0
  198. package/src/config/bundled-skills/app-control/tools/app-control-drag.ts +12 -0
  199. package/src/config/bundled-skills/app-control/tools/app-control-observe.ts +12 -0
  200. package/src/config/bundled-skills/app-control/tools/app-control-press.ts +12 -0
  201. package/src/config/bundled-skills/app-control/tools/app-control-sequence.ts +12 -0
  202. package/src/config/bundled-skills/app-control/tools/app-control-start.ts +12 -0
  203. package/src/config/bundled-skills/app-control/tools/app-control-stop.ts +12 -0
  204. package/src/config/bundled-skills/app-control/tools/app-control-type.ts +12 -0
  205. package/src/config/bundled-skills/computer-use/SKILL.md +6 -0
  206. package/src/config/bundled-skills/computer-use/TOOLS.json +67 -43
  207. package/src/config/bundled-skills/contacts/TOOLS.json +0 -16
  208. package/src/config/bundled-skills/document/TOOLS.json +0 -8
  209. package/src/config/bundled-skills/followups/TOOLS.json +0 -12
  210. package/src/config/bundled-skills/image-studio/SKILL.md +4 -0
  211. package/src/config/bundled-skills/image-studio/TOOLS.json +0 -4
  212. package/src/config/bundled-skills/media-processing/TOOLS.json +0 -24
  213. package/src/config/bundled-skills/messaging/TOOLS.json +0 -40
  214. package/src/config/bundled-skills/phone-calls/TOOLS.json +0 -12
  215. package/src/config/bundled-skills/phone-calls/references/TROUBLESHOOTING.md +19 -4
  216. package/src/config/bundled-skills/playbooks/TOOLS.json +0 -16
  217. package/src/config/bundled-skills/schedule/TOOLS.json +14 -14
  218. package/src/config/bundled-skills/sequences/TOOLS.json +0 -36
  219. package/src/config/bundled-skills/settings/SKILL.md +4 -0
  220. package/src/config/bundled-skills/settings/TOOLS.json +0 -12
  221. package/src/config/bundled-skills/skill-management/SKILL.md +6 -0
  222. package/src/config/bundled-skills/skill-management/TOOLS.json +0 -8
  223. package/src/config/bundled-skills/subagent/SKILL.md +6 -2
  224. package/src/config/bundled-skills/subagent/TOOLS.json +0 -20
  225. package/src/config/bundled-skills/transcribe/SKILL.md +4 -0
  226. package/src/config/bundled-skills/transcribe/TOOLS.json +0 -4
  227. package/src/config/bundled-tool-registry.ts +21 -0
  228. package/src/config/env-registry.ts +0 -2
  229. package/src/config/env.ts +19 -12
  230. package/src/config/feature-flag-registry.json +21 -133
  231. package/src/config/loader.ts +73 -99
  232. package/src/config/sanitize-for-transfer.ts +2 -0
  233. package/src/config/schemas/__tests__/memory-lifecycle.test.ts +80 -0
  234. package/src/config/schemas/__tests__/memory-v2.test.ts +7 -4
  235. package/src/config/schemas/calls.ts +0 -9
  236. package/src/config/schemas/heartbeat.ts +63 -0
  237. package/src/config/schemas/ingress.ts +10 -6
  238. package/src/config/schemas/llm.ts +5 -10
  239. package/src/config/schemas/memory-lifecycle.ts +77 -24
  240. package/src/config/schemas/memory-v2.ts +48 -4
  241. package/src/config/schemas/platform.ts +6 -0
  242. package/src/config/schemas/services.ts +1 -15
  243. package/src/config/schemas/skills.ts +0 -6
  244. package/src/config/seed-inference-profiles.ts +1 -1
  245. package/src/contacts/contact-store.ts +0 -30
  246. package/src/contacts/contacts-write.ts +0 -27
  247. package/src/context/window-manager.ts +1 -2
  248. package/src/credential-execution/feature-gates.ts +10 -10
  249. package/src/credential-execution/process-manager.ts +12 -41
  250. package/src/daemon/__tests__/conversation-tool-setup.test.ts +126 -5
  251. package/src/daemon/bootstrap-turn-cleanup.ts +45 -0
  252. package/src/daemon/config-watcher.ts +4 -3
  253. package/src/daemon/conversation-agent-loop-handlers.ts +21 -3
  254. package/src/daemon/conversation-agent-loop.ts +32 -28
  255. package/src/daemon/conversation-lifecycle.ts +8 -1
  256. package/src/daemon/conversation-process.ts +16 -11
  257. package/src/daemon/conversation-runtime-assembly.ts +2 -2
  258. package/src/daemon/conversation-surfaces.ts +125 -4
  259. package/src/daemon/conversation-tool-setup.ts +16 -55
  260. package/src/daemon/conversation.ts +21 -2
  261. package/src/daemon/doordash-steps.ts +1 -1
  262. package/src/daemon/handlers/shared.ts +4 -1
  263. package/src/daemon/host-app-control-proxy.ts +293 -0
  264. package/src/daemon/host-bash-proxy.ts +84 -74
  265. package/src/daemon/host-browser-proxy.ts +67 -82
  266. package/src/daemon/host-cu-proxy.ts +81 -86
  267. package/src/daemon/host-file-proxy.ts +93 -69
  268. package/src/daemon/host-proxy-base.ts +294 -0
  269. package/src/daemon/host-proxy-preactivation.ts +82 -0
  270. package/src/daemon/host-transfer-proxy.ts +247 -129
  271. package/src/daemon/lifecycle.ts +115 -117
  272. package/src/daemon/message-protocol.ts +3 -8
  273. package/src/daemon/message-types/contacts.ts +23 -1
  274. package/src/daemon/message-types/conversations.ts +11 -8
  275. package/src/daemon/message-types/host-app-control.ts +150 -0
  276. package/src/daemon/message-types/host-bash.ts +4 -0
  277. package/src/daemon/message-types/host-cu.ts +2 -0
  278. package/src/daemon/message-types/host-file.ts +4 -0
  279. package/src/daemon/message-types/host-transfer.ts +3 -0
  280. package/src/daemon/message-types/schedules.ts +8 -3
  281. package/src/daemon/message-types/skills.ts +2 -2
  282. package/src/daemon/process-message.ts +18 -1
  283. package/src/daemon/shutdown-handlers.ts +0 -3
  284. package/src/daemon/tool-setup-types.ts +51 -0
  285. package/src/daemon/tool-side-effects.ts +1 -1
  286. package/src/events/tool-audit-listener.ts +2 -1
  287. package/src/heartbeat/__tests__/heartbeat-feed-event.test.ts +15 -7
  288. package/src/heartbeat/__tests__/heartbeat-run-store.test.ts +216 -0
  289. package/src/heartbeat/heartbeat-run-store.ts +236 -0
  290. package/src/heartbeat/heartbeat-service.ts +280 -49
  291. package/src/home/__tests__/post-connect-feed.test.ts +99 -0
  292. package/src/home/__tests__/relationship-state-writer.test.ts +11 -9
  293. package/src/home/__tests__/suggested-prompts.test.ts +89 -0
  294. package/src/home/post-connect-feed.ts +68 -0
  295. package/src/home/relationship-state-writer.ts +17 -92
  296. package/src/home/suggested-prompts.ts +46 -10
  297. package/src/inbound/public-ingress-urls.ts +32 -34
  298. package/src/ipc/__tests__/route-error-envelope.test.ts +80 -0
  299. package/src/ipc/assistant-server.ts +14 -1
  300. package/src/ipc/cli-client.ts +32 -1
  301. package/src/live-voice/live-voice-metrics.ts +10 -10
  302. package/src/mcp/__tests__/mcp-auth-orchestrator.test.ts +304 -0
  303. package/src/mcp/mcp-auth-orchestrator.ts +213 -0
  304. package/src/mcp/mcp-auth-state.ts +133 -0
  305. package/src/mcp/mcp-oauth-provider.ts +19 -0
  306. package/src/memory/__tests__/jobs-store-job-classes.test.ts +24 -0
  307. package/src/memory/__tests__/qdrant-client-sentinel.test.ts +49 -0
  308. package/src/memory/__tests__/sparse-tokenize.test.ts +66 -0
  309. package/src/memory/anisotropy.test.ts +247 -0
  310. package/src/memory/anisotropy.ts +443 -0
  311. package/src/memory/auto-analysis-constants.ts +17 -0
  312. package/src/memory/auto-analysis-guard.ts +5 -15
  313. package/src/memory/canonical-guardian-store.ts +7 -7
  314. package/src/memory/context-search/__tests__/agent-runner-redaction.test.ts +122 -0
  315. package/src/memory/context-search/agent-protocol.ts +6 -6
  316. package/src/memory/context-search/agent-runner.ts +32 -7
  317. package/src/memory/context-search/sources/memory-v2.ts +17 -5
  318. package/src/memory/conversation-crud.ts +1 -1
  319. package/src/memory/conversation-key-store.ts +2 -15
  320. package/src/memory/db-init.ts +4 -0
  321. package/src/memory/embedding-backend.ts +9 -21
  322. package/src/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +49 -4
  323. package/src/memory/graph/conversation-graph-memory.ts +1 -24
  324. package/src/memory/graph/graph-search.ts +8 -0
  325. package/src/memory/graph/retriever.ts +28 -0
  326. package/src/memory/graph/tools.ts +1 -1
  327. package/src/memory/jobs/__tests__/embed-concept-page.test.ts +8 -2
  328. package/src/memory/jobs/embed-concept-page.ts +28 -2
  329. package/src/memory/jobs/embed-pkb-file.test.ts +2 -2
  330. package/src/memory/jobs-store.ts +66 -22
  331. package/src/memory/jobs-worker.ts +112 -63
  332. package/src/memory/memory-v2-activation-log-store.ts +1 -1
  333. package/src/memory/migrations/237-heartbeat-runs.ts +45 -0
  334. package/src/memory/migrations/238-schedule-retry-policy.ts +20 -0
  335. package/src/memory/migrations/index.ts +5 -0
  336. package/src/memory/migrations/registry.ts +8 -0
  337. package/src/memory/pkb/pkb-search.ts +7 -0
  338. package/src/memory/qdrant-client.ts +50 -20
  339. package/src/memory/schema/infrastructure.ts +15 -0
  340. package/src/memory/search/semantic.ts +7 -0
  341. package/src/memory/sparse-tokenize.ts +49 -0
  342. package/src/memory/v2/__tests__/activation.test.ts +77 -95
  343. package/src/memory/v2/__tests__/injection.test.ts +43 -21
  344. package/src/memory/v2/__tests__/sim.test.ts +166 -6
  345. package/src/memory/v2/__tests__/sparse-bm25.test.ts +292 -0
  346. package/src/memory/v2/__tests__/static-context.test.ts +0 -1
  347. package/src/memory/v2/activation.ts +69 -88
  348. package/src/memory/v2/consolidation-job.ts +3 -5
  349. package/src/memory/v2/constants.ts +7 -0
  350. package/src/memory/v2/injection.ts +86 -53
  351. package/src/memory/v2/prompts/consolidation.ts +312 -91
  352. package/src/memory/v2/qdrant.ts +99 -1
  353. package/src/memory/v2/sim.ts +126 -16
  354. package/src/memory/v2/skill-qdrant.ts +12 -3
  355. package/src/memory/v2/skill-store.ts +16 -1
  356. package/src/memory/v2/sparse-bm25.ts +245 -0
  357. package/src/memory/v2/static-context.ts +6 -5
  358. package/src/messaging/providers/gmail/types.ts +0 -49
  359. package/src/messaging/providers/slack/adapter.ts +1 -31
  360. package/src/messaging/providers/slack/types.ts +0 -32
  361. package/src/notifications/README.md +10 -10
  362. package/src/notifications/broadcaster.ts +1 -1
  363. package/src/notifications/guardian-question-mode.ts +5 -5
  364. package/src/oauth/connect-orchestrator.ts +4 -0
  365. package/src/oauth/credential-token-resolver.ts +1 -3
  366. package/src/oauth/manual-token-connection.ts +0 -4
  367. package/src/outbound-proxy/index.ts +1 -37
  368. package/src/outbound-proxy/logging.ts +1 -1
  369. package/src/outbound-proxy/policy.ts +6 -5
  370. package/src/outbound-proxy/router.ts +2 -1
  371. package/src/permissions/approval-policy.test.ts +6 -275
  372. package/src/permissions/approval-policy.ts +0 -51
  373. package/src/permissions/checker.test.ts +0 -1
  374. package/src/permissions/checker.ts +3 -17
  375. package/src/permissions/gateway-threshold-reader.ts +2 -0
  376. package/src/permissions/prompter.ts +34 -1
  377. package/src/permissions/secret-prompter.ts +6 -2
  378. package/src/prompts/bootstrap-cleanup.ts +27 -0
  379. package/src/prompts/system-prompt.ts +3 -18
  380. package/src/prompts/templates/SOUL.md +13 -1
  381. package/src/providers/speech-to-text/provider-catalog.ts +7 -8
  382. package/src/runtime/assistant-event-hub.ts +118 -96
  383. package/src/runtime/assistant-event.ts +1 -0
  384. package/src/runtime/auth/__tests__/middleware.test.ts +11 -56
  385. package/src/runtime/auth/middleware.ts +0 -96
  386. package/src/runtime/auth/route-policy.ts +19 -0
  387. package/src/runtime/btw-sidechain.ts +2 -3
  388. package/src/runtime/channel-invite-transport.ts +2 -48
  389. package/src/runtime/channel-invite-transports/email.ts +1 -1
  390. package/src/runtime/channel-invite-transports/slack.ts +1 -1
  391. package/src/runtime/channel-invite-transports/telegram.ts +1 -1
  392. package/src/runtime/channel-invite-transports/voice.ts +1 -1
  393. package/src/runtime/channel-invite-transports/whatsapp.ts +1 -1
  394. package/src/runtime/channel-invite-types.ts +54 -0
  395. package/src/runtime/channel-readiness-service.ts +32 -13
  396. package/src/runtime/http-server.ts +3 -329
  397. package/src/runtime/http-types.ts +0 -5
  398. package/src/runtime/migrations/__tests__/vbundle-import-parity.test.ts +413 -0
  399. package/src/runtime/migrations/__tests__/vbundle-import-policy.test.ts +260 -0
  400. package/src/runtime/migrations/__tests__/vbundle-import-version-compat.test.ts +189 -0
  401. package/src/runtime/migrations/__tests__/vbundle-streaming-importer.test.ts +153 -1
  402. package/src/runtime/migrations/__tests__/vbundle-symlink-importer.test.ts +451 -0
  403. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming-importer.test.ts +0 -0
  404. package/src/runtime/migrations/__tests__/vbundle-symlink-streaming.test.ts +515 -0
  405. package/src/runtime/migrations/__tests__/vbundle-symlink-tar.test.ts +437 -0
  406. package/src/runtime/migrations/__tests__/vbundle-symlink-walker.test.ts +319 -0
  407. package/src/runtime/migrations/__tests__/vbundle-validator-v1-schema.test.ts +51 -1
  408. package/src/runtime/migrations/migration-transport.ts +7 -7
  409. package/src/runtime/migrations/vbundle-builder.ts +327 -60
  410. package/src/runtime/migrations/vbundle-import-analyzer.ts +4 -4
  411. package/src/runtime/migrations/vbundle-import-policy.ts +172 -0
  412. package/src/runtime/migrations/vbundle-importer.ts +245 -68
  413. package/src/runtime/migrations/vbundle-streaming-importer.ts +326 -35
  414. package/src/runtime/migrations/vbundle-streaming-validator.ts +157 -4
  415. package/src/runtime/migrations/vbundle-tar-stream.ts +15 -6
  416. package/src/runtime/migrations/vbundle-validator.ts +114 -0
  417. package/src/runtime/pending-interactions.ts +35 -9
  418. package/src/runtime/routes/__tests__/backup-routes.test.ts +22 -150
  419. package/src/runtime/routes/__tests__/conversation-query-routes.test.ts +98 -0
  420. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +242 -0
  421. package/src/runtime/routes/__tests__/heartbeat-routes.test.ts +112 -0
  422. package/src/runtime/routes/approval-interception-types.ts +13 -0
  423. package/src/runtime/routes/approval-strategies/guardian-text-engine-strategy.ts +1 -1
  424. package/src/runtime/routes/backup-routes.ts +15 -38
  425. package/src/runtime/routes/btw-routes.ts +14 -37
  426. package/src/runtime/routes/client-routes.ts +1 -0
  427. package/src/runtime/routes/contact-prompt-routes.ts +183 -0
  428. package/src/runtime/routes/conversation-query-routes.ts +36 -1
  429. package/src/runtime/routes/conversation-routes.ts +30 -13
  430. package/src/runtime/routes/document-pdf-renderer.ts +165 -0
  431. package/src/runtime/routes/documents-routes.ts +30 -0
  432. package/src/runtime/routes/errors.ts +19 -4
  433. package/src/runtime/routes/events-routes.ts +12 -6
  434. package/src/runtime/routes/gateway-log-routes.ts +79 -0
  435. package/src/runtime/routes/guardian-approval-interception.ts +2 -8
  436. package/src/runtime/routes/heartbeat-routes.ts +103 -38
  437. package/src/runtime/routes/host-app-control-routes.ts +134 -0
  438. package/src/runtime/routes/host-bash-routes.ts +36 -6
  439. package/src/runtime/routes/host-browser-routes.ts +108 -13
  440. package/src/runtime/routes/host-cu-routes.ts +44 -14
  441. package/src/runtime/routes/host-file-routes.ts +33 -10
  442. package/src/runtime/routes/host-transfer-routes.ts +64 -24
  443. package/src/runtime/routes/http-adapter.ts +1 -0
  444. package/src/runtime/routes/identity-intro-cache.ts +30 -0
  445. package/src/runtime/routes/identity-routes.ts +15 -43
  446. package/src/runtime/routes/inbound-message-handler.ts +1 -9
  447. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -7
  448. package/src/runtime/routes/inbound-stages/edit-intercept.ts +0 -8
  449. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +0 -20
  450. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +5 -13
  451. package/src/runtime/routes/index.ts +8 -0
  452. package/src/runtime/routes/mcp-auth-routes.ts +132 -0
  453. package/src/runtime/routes/memory-item-routes.ts +10 -12
  454. package/src/runtime/routes/memory-v2-routes.ts +441 -1
  455. package/src/runtime/routes/migration-routes.ts +96 -0
  456. package/src/runtime/routes/schedule-routes.ts +7 -0
  457. package/src/runtime/verification-templates.ts +4 -7
  458. package/src/schedule/integration-status.ts +66 -2
  459. package/src/schedule/recurrence-engine.ts +4 -1
  460. package/src/schedule/retry-backoff.ts +18 -0
  461. package/src/schedule/retry-policy.ts +82 -0
  462. package/src/schedule/schedule-recovery.ts +64 -0
  463. package/src/schedule/schedule-store.ts +106 -2
  464. package/src/schedule/scheduler-types.ts +25 -0
  465. package/src/schedule/scheduler.ts +63 -38
  466. package/src/security/oauth-callback-registry.ts +8 -0
  467. package/src/sequence/analytics.ts +5 -5
  468. package/src/sequence/engine.ts +1 -1
  469. package/src/skills/catalog-files.ts +2 -8
  470. package/src/skills/include-graph.ts +5 -5
  471. package/src/skills/remote-skill-policy.ts +5 -5
  472. package/src/skills/skill-file-provider.ts +1 -1
  473. package/src/skills/skill-file-types.ts +13 -0
  474. package/src/skills/skillssh-audit-types.ts +28 -0
  475. package/src/skills/skillssh-registry.ts +8 -21
  476. package/src/telemetry/types.ts +2 -0
  477. package/src/telemetry/usage-telemetry-reporter.test.ts +21 -0
  478. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  479. package/src/tools/app-control/skill-proxy-bridge.ts +28 -0
  480. package/src/tools/apps/executors.ts +56 -69
  481. package/src/tools/browser/__tests__/browser-status.test.ts +21 -18
  482. package/src/tools/browser/browser-execution.ts +2 -2
  483. package/src/tools/browser/cdp-client/__tests__/factory.test.ts +55 -4
  484. package/src/tools/browser/cdp-client/cdp-inspect/__tests__/ws-transport.test.ts +12 -6
  485. package/src/tools/browser/cdp-client/factory.ts +23 -24
  486. package/src/tools/browser/cdp-client/index.ts +1 -14
  487. package/src/tools/computer-use/definitions.ts +42 -20
  488. package/src/tools/executor.ts +2 -0
  489. package/src/tools/host-filesystem/edit.ts +26 -0
  490. package/src/tools/host-filesystem/read.ts +26 -0
  491. package/src/tools/host-filesystem/transfer.ts +31 -1
  492. package/src/tools/host-filesystem/write.ts +26 -0
  493. package/src/tools/host-terminal/host-shell.ts +58 -0
  494. package/src/tools/schedule/create.ts +6 -0
  495. package/src/tools/schedule/list.ts +2 -0
  496. package/src/tools/schedule/update.ts +10 -0
  497. package/src/tools/shared/filesystem/file-ops-service.ts +2 -0
  498. package/src/tools/shared/filesystem/path-policy.ts +25 -1
  499. package/src/tools/skills/load.ts +0 -32
  500. package/src/tools/tool-approval-handler.ts +1 -5
  501. package/src/tools/types.ts +4 -0
  502. package/src/usage/pricing.ts +1 -1
  503. package/src/workspace/hatched-date.ts +86 -0
  504. package/src/workspace/migrations/003-seed-device-id.ts +1 -1
  505. package/src/workspace/migrations/006-services-config.ts +8 -5
  506. package/src/workspace/migrations/016-extract-feature-flags-to-protected.ts +3 -9
  507. package/src/workspace/migrations/021-move-signals-to-workspace.ts +4 -10
  508. package/src/workspace/migrations/022-move-hooks-to-workspace.ts +4 -10
  509. package/src/workspace/migrations/023-move-config-files-to-workspace.ts +4 -11
  510. package/src/workspace/migrations/024-move-runtime-files-to-workspace.ts +3 -10
  511. package/src/workspace/migrations/040-seed-latency-callsite-defaults.ts +3 -2
  512. package/src/workspace/migrations/050-seed-main-agent-opus-callsite.ts +2 -1
  513. package/src/workspace/migrations/059-move-pid-to-workspace.ts +3 -8
  514. package/src/workspace/migrations/061-move-backup-key-to-workspace.ts +3 -8
  515. package/src/workspace/migrations/AGENTS.md +1 -1
  516. package/src/workspace/migrations/migrate-to-workspace-volume.ts +4 -10
  517. package/src/workspace/migrations/utils.ts +21 -0
  518. package/src/__tests__/host-browser-e2e-cloud.test.ts +0 -443
  519. package/src/__tests__/host-browser-e2e-self-hosted-capability.test.ts +0 -226
  520. package/src/__tests__/host-browser-ws-events-e2e.test.ts +0 -427
  521. package/src/__tests__/twilio-rest.test.ts +0 -34
  522. package/src/backup/__tests__/backup-key.test.ts +0 -152
  523. package/src/backup/__tests__/backup-worker.test.ts +0 -782
  524. package/src/backup/__tests__/offsite-writer.test.ts +0 -641
  525. package/src/backup/__tests__/stream-crypt.test.ts +0 -228
  526. package/src/backup/backup-key.ts +0 -137
  527. package/src/backup/backup-worker.ts +0 -472
  528. package/src/backup/offsite-writer.ts +0 -222
  529. package/src/backup/stream-crypt.ts +0 -263
  530. package/src/daemon/message-types/pairing.ts +0 -58
  531. package/src/outbound-proxy/config.ts +0 -20
  532. package/src/outbound-proxy/health.ts +0 -18
  533. package/src/outbound-proxy/types.ts +0 -150
  534. package/src/runtime/capability-tokens.ts +0 -190
  535. package/src/signals/mcp-reload.ts +0 -18
@@ -1,14 +1,48 @@
1
1
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3
+ import {
4
+ afterEach,
5
+ beforeEach,
6
+ describe,
7
+ expect,
8
+ jest,
9
+ mock,
10
+ test,
11
+ } from "bun:test";
4
12
 
5
13
  const testWorkspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
6
14
 
15
+ // ── Heartbeat run store mock ───────────────────────────────────────
16
+ const mockInsertPendingHeartbeatRun = mock(() => "mock-run-id");
17
+ const mockStartHeartbeatRun = mock(() => true);
18
+ const mockCompleteHeartbeatRun = mock(() => true);
19
+ const mockSkipHeartbeatRun = mock(() => true);
20
+ const mockSupersedePendingRun = mock(() => true);
21
+ const mockMarkStaleRunsAsMissed = mock(() => 0);
22
+ const mockMarkStaleRunningAsError = mock(() => 0);
23
+ mock.module("../heartbeat/heartbeat-run-store.js", () => ({
24
+ insertPendingHeartbeatRun: mockInsertPendingHeartbeatRun,
25
+ startHeartbeatRun: mockStartHeartbeatRun,
26
+ completeHeartbeatRun: mockCompleteHeartbeatRun,
27
+ skipHeartbeatRun: mockSkipHeartbeatRun,
28
+ supersedePendingRun: mockSupersedePendingRun,
29
+ markStaleRunsAsMissed: mockMarkStaleRunsAsMissed,
30
+ markStaleRunningAsError: mockMarkStaleRunningAsError,
31
+ }));
32
+
33
+ // ── Feed event mock ───────────────────────────────────────────────
34
+ const mockEmitFeedEvent = mock(() => Promise.resolve());
35
+ mock.module("../home/emit-feed-event.js", () => ({
36
+ emitFeedEvent: mockEmitFeedEvent,
37
+ }));
38
+
7
39
  // Mock config loader
8
40
  let mockConfig = {
9
41
  heartbeat: {
10
42
  enabled: true,
11
43
  intervalMs: 60_000,
44
+ cronExpression: null as string | null,
45
+ timezone: null as string | null,
12
46
  activeHoursStart: undefined as number | undefined,
13
47
  activeHoursEnd: undefined as number | undefined,
14
48
  },
@@ -22,6 +56,32 @@ mock.module("../config/loader.js", () => ({
22
56
  invalidateConfigCache: () => {},
23
57
  }));
24
58
 
59
+ // ── Recurrence engine mock ──────────────────────────────────────────
60
+ //
61
+ // HeartbeatService imports computeNextRunAt for cron scheduling.
62
+ // Tests mutate `mockComputeNextRunAt` to control the next cron occurrence.
63
+ let mockComputeNextRunAtResult: number | null = null;
64
+ let mockComputeNextRunAtError: Error | null = null;
65
+ let computeNextRunAtCallCount = 0;
66
+
67
+ mock.module("../schedule/recurrence-engine.js", () => ({
68
+ computeNextRunAt: (_spec: {
69
+ syntax: string;
70
+ expression: string;
71
+ timezone?: string | null;
72
+ }) => {
73
+ computeNextRunAtCallCount++;
74
+ if (mockComputeNextRunAtError) {
75
+ throw mockComputeNextRunAtError;
76
+ }
77
+ if (mockComputeNextRunAtResult != null) {
78
+ return mockComputeNextRunAtResult;
79
+ }
80
+ // Default: 1 hour from now
81
+ return Date.now() + 3_600_000;
82
+ },
83
+ }));
84
+
25
85
  // ── Guardian persona mock ─────────────────────────────────────────
26
86
  //
27
87
  // `heartbeat-service.isShallowProfile` reads the guardian persona via
@@ -263,6 +323,9 @@ describe("HeartbeatService", () => {
263
323
  mockCheckAllCredentialsFail = false;
264
324
  emittedNotificationSignals.length = 0;
265
325
  loggerWarnCalls.length = 0;
326
+ mockComputeNextRunAtResult = null;
327
+ mockComputeNextRunAtError = null;
328
+ computeNextRunAtCallCount = 0;
266
329
 
267
330
  // Default processMessage mock: capture calls for assertions.
268
331
  setTestProcessMessage(async (...args: unknown[]) => {
@@ -274,10 +337,29 @@ describe("HeartbeatService", () => {
274
337
  return { messageId: "msg-1" };
275
338
  });
276
339
 
340
+ mockInsertPendingHeartbeatRun.mockClear();
341
+ mockInsertPendingHeartbeatRun.mockImplementation(() => "mock-run-id");
342
+ mockStartHeartbeatRun.mockClear();
343
+ mockStartHeartbeatRun.mockImplementation(() => true);
344
+ mockCompleteHeartbeatRun.mockClear();
345
+ mockCompleteHeartbeatRun.mockImplementation(() => true);
346
+ mockSkipHeartbeatRun.mockClear();
347
+ mockSkipHeartbeatRun.mockImplementation(() => true);
348
+ mockSupersedePendingRun.mockClear();
349
+ mockSupersedePendingRun.mockImplementation(() => true);
350
+ mockMarkStaleRunsAsMissed.mockClear();
351
+ mockMarkStaleRunsAsMissed.mockImplementation(() => 0);
352
+ mockMarkStaleRunningAsError.mockClear();
353
+ mockMarkStaleRunningAsError.mockImplementation(() => 0);
354
+ mockEmitFeedEvent.mockClear();
355
+ mockEmitFeedEvent.mockImplementation(() => Promise.resolve());
356
+
277
357
  mockConfig = {
278
358
  heartbeat: {
279
359
  enabled: true,
280
360
  intervalMs: 60_000,
361
+ cronExpression: null,
362
+ timezone: null,
281
363
  activeHoursStart: undefined,
282
364
  activeHoursEnd: undefined,
283
365
  },
@@ -1053,4 +1135,639 @@ describe("HeartbeatService", () => {
1053
1135
  expect(unreachableWarns[0].unreachableCount).toBe(2);
1054
1136
  });
1055
1137
  });
1138
+
1139
+ describe("cron scheduling mode", () => {
1140
+ test("start() with cronExpression sets nextRunAt to cron occurrence, not now+intervalMs", () => {
1141
+ const cronNextRunAt = Date.now() + 7_200_000; // 2 hours from now
1142
+ mockComputeNextRunAtResult = cronNextRunAt;
1143
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1144
+ mockConfig.heartbeat.timezone = "America/New_York";
1145
+
1146
+ const service = createService();
1147
+ service.start();
1148
+
1149
+ expect(service.nextRunAt).toBe(cronNextRunAt);
1150
+ // Should NOT be now + intervalMs
1151
+ expect(service.nextRunAt).not.toBeCloseTo(
1152
+ Date.now() + mockConfig.heartbeat.intervalMs,
1153
+ -3,
1154
+ );
1155
+ service.stop();
1156
+ });
1157
+
1158
+ test("runOnce() does not call scheduleNextRun(intervalMs) in cron mode — nextRunAt is not clobbered", async () => {
1159
+ const cronNextRunAt = Date.now() + 7_200_000;
1160
+ mockComputeNextRunAtResult = cronNextRunAt;
1161
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1162
+
1163
+ const service = createService();
1164
+ service.start();
1165
+
1166
+ // nextRunAt should be the cron time before runOnce
1167
+ expect(service.nextRunAt).toBe(cronNextRunAt);
1168
+
1169
+ await service.runOnce();
1170
+
1171
+ // After runOnce(), nextRunAt should still reflect a cron time, not now + intervalMs.
1172
+ // The finally chain in scheduleNextCronRun recalculates it, but the runOnce()
1173
+ // finally block should NOT have called scheduleNextRun(intervalMs).
1174
+ // Since our mock always returns cronNextRunAt, nextRunAt should remain that value.
1175
+ expect(service.nextRunAt).toBe(cronNextRunAt);
1176
+ service.stop();
1177
+ });
1178
+
1179
+ test("after runOnce() rejects in cron mode, the next cron run is still scheduled via finally", async () => {
1180
+ const cronNextRunAt = Date.now() + 7_200_000;
1181
+ mockComputeNextRunAtResult = cronNextRunAt;
1182
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1183
+
1184
+ const service = createService({
1185
+ processMessage: async () => {
1186
+ throw new Error("LLM down");
1187
+ },
1188
+ });
1189
+ service.start();
1190
+
1191
+ await service.runOnce();
1192
+
1193
+ // Even though executeRun failed, the service should still have a nextRunAt
1194
+ // set to the cron occurrence (the finally chain reschedules)
1195
+ expect(service.nextRunAt).toBe(cronNextRunAt);
1196
+ service.stop();
1197
+ });
1198
+
1199
+ test("resetTimer() in cron mode recomputes from the current time", () => {
1200
+ const firstCronTime = Date.now() + 3_600_000;
1201
+ mockComputeNextRunAtResult = firstCronTime;
1202
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1203
+
1204
+ const service = createService();
1205
+ service.start();
1206
+ expect(service.nextRunAt).toBe(firstCronTime);
1207
+
1208
+ // Simulate time passing and a new cron occurrence
1209
+ const secondCronTime = Date.now() + 5_400_000;
1210
+ mockComputeNextRunAtResult = secondCronTime;
1211
+
1212
+ service.resetTimer();
1213
+ expect(service.nextRunAt).toBe(secondCronTime);
1214
+ service.stop();
1215
+ });
1216
+
1217
+ test("reconfigure() switches from interval to cron mode", () => {
1218
+ const service = createService();
1219
+ // Start in interval mode
1220
+ service.start();
1221
+ const intervalNextRunAt = service.nextRunAt;
1222
+ expect(intervalNextRunAt).not.toBeNull();
1223
+
1224
+ // Reconfigure to cron mode
1225
+ const cronNextRunAt = Date.now() + 7_200_000;
1226
+ mockComputeNextRunAtResult = cronNextRunAt;
1227
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1228
+ service.reconfigure();
1229
+
1230
+ expect(service.nextRunAt).toBe(cronNextRunAt);
1231
+ service.stop();
1232
+ });
1233
+
1234
+ test("reconfigure() switches from cron to interval mode", () => {
1235
+ const cronNextRunAt = Date.now() + 7_200_000;
1236
+ mockComputeNextRunAtResult = cronNextRunAt;
1237
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1238
+
1239
+ const service = createService();
1240
+ service.start();
1241
+ expect(service.nextRunAt).toBe(cronNextRunAt);
1242
+
1243
+ // Reconfigure to interval mode
1244
+ mockConfig.heartbeat.cronExpression = null;
1245
+ const before = Date.now();
1246
+ service.reconfigure();
1247
+
1248
+ expect(service.nextRunAt).not.toBeNull();
1249
+ expect(service.nextRunAt!).toBeGreaterThanOrEqual(
1250
+ before + mockConfig.heartbeat.intervalMs,
1251
+ );
1252
+ service.stop();
1253
+ });
1254
+
1255
+ test("active hours guard uses cron timezone when configured", async () => {
1256
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1257
+ mockConfig.heartbeat.timezone = "UTC";
1258
+ mockConfig.heartbeat.activeHoursStart = 9;
1259
+ mockConfig.heartbeat.activeHoursEnd = 17;
1260
+ mockComputeNextRunAtResult = Date.now() + 3_600_000;
1261
+
1262
+ const service = createService();
1263
+ service.start();
1264
+
1265
+ // In cron mode with timezone, the hour is computed via Intl.DateTimeFormat
1266
+ // rather than getCurrentHour(). The test verifies the code path runs without
1267
+ // error — the actual hour depends on the system clock and UTC conversion.
1268
+ // We just verify it doesn't throw and returns a boolean result.
1269
+ const result = await service.runOnce();
1270
+ // Result depends on current UTC hour vs active window — either outcome is valid
1271
+ expect(typeof result).toBe("boolean");
1272
+ service.stop();
1273
+ });
1274
+
1275
+ test("active hours guard falls back to getCurrentHour when cron mode has no timezone", async () => {
1276
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1277
+ mockConfig.heartbeat.timezone = null;
1278
+ mockConfig.heartbeat.activeHoursStart = 9;
1279
+ mockConfig.heartbeat.activeHoursEnd = 17;
1280
+ mockComputeNextRunAtResult = Date.now() + 3_600_000;
1281
+
1282
+ // getCurrentHour returns 3 (outside 9-17 window), so runOnce should skip
1283
+ const service = createService({ getCurrentHour: () => 3 });
1284
+ service.start();
1285
+ const result = await service.runOnce();
1286
+ expect(result).toBe(false);
1287
+ expect(processMessageCalls).toHaveLength(0);
1288
+ service.stop();
1289
+ });
1290
+
1291
+ test("runtime fallback: computeNextRunAt throws, service falls back to interval mode", () => {
1292
+ mockComputeNextRunAtError = new Error("No upcoming runs");
1293
+ mockConfig.heartbeat.cronExpression = "0 9,12,15,18 * * *";
1294
+
1295
+ const service = createService();
1296
+ service.start();
1297
+
1298
+ // Should have fallen back to interval mode — nextRunAt should be ~now + intervalMs
1299
+ expect(service.nextRunAt).not.toBeNull();
1300
+ const expectedMin = Date.now() + mockConfig.heartbeat.intervalMs - 100;
1301
+ expect(service.nextRunAt!).toBeGreaterThanOrEqual(expectedMin);
1302
+
1303
+ // Should have logged a warning about the fallback
1304
+ const fallbackWarns = loggerWarnCalls.filter((call) => "err" in call);
1305
+ expect(fallbackWarns.length).toBeGreaterThanOrEqual(1);
1306
+ service.stop();
1307
+ });
1308
+
1309
+ test("null cronExpression behaves identically to current fixed-interval mode", () => {
1310
+ mockConfig.heartbeat.cronExpression = null;
1311
+
1312
+ const service = createService();
1313
+ const before = Date.now();
1314
+ service.start();
1315
+
1316
+ expect(service.nextRunAt).not.toBeNull();
1317
+ expect(service.nextRunAt!).toBeGreaterThanOrEqual(
1318
+ before + mockConfig.heartbeat.intervalMs,
1319
+ );
1320
+ // computeNextRunAt should not have been called
1321
+ expect(computeNextRunAtCallCount).toBe(0);
1322
+ service.stop();
1323
+ });
1324
+ });
1325
+
1326
+ describe("heartbeat run store instrumentation", () => {
1327
+ test("successful run: pending → running → ok with conversationId", async () => {
1328
+ const service = createService();
1329
+ await service.runOnce();
1330
+
1331
+ expect(mockStartHeartbeatRun).toHaveBeenCalledTimes(1);
1332
+ expect(mockCompleteHeartbeatRun).toHaveBeenCalledTimes(1);
1333
+ expect(mockCompleteHeartbeatRun).toHaveBeenCalledWith("mock-run-id", {
1334
+ status: "ok",
1335
+ conversationId: "conv-1",
1336
+ });
1337
+ });
1338
+
1339
+ test("failed run: pending → running → error preserving conversationId", async () => {
1340
+ const service = createService({
1341
+ processMessage: async () => {
1342
+ throw new Error("LLM timeout");
1343
+ },
1344
+ });
1345
+
1346
+ await service.runOnce();
1347
+
1348
+ expect(mockStartHeartbeatRun).toHaveBeenCalledTimes(1);
1349
+ expect(mockCompleteHeartbeatRun).toHaveBeenCalledTimes(1);
1350
+ expect(mockCompleteHeartbeatRun).toHaveBeenCalledWith("mock-run-id", {
1351
+ status: "error",
1352
+ conversationId: "conv-1",
1353
+ error: "LLM timeout",
1354
+ });
1355
+ });
1356
+
1357
+ test("CAS false suppresses success feed event", async () => {
1358
+ mockCompleteHeartbeatRun.mockImplementation(() => false);
1359
+
1360
+ const service = createService();
1361
+ await service.runOnce();
1362
+
1363
+ // completeHeartbeatRun returned false, so no feed event should be emitted for success
1364
+ const successCalls = mockEmitFeedEvent.mock.calls.filter(
1365
+ (call: unknown[]) => {
1366
+ const opts = call[0] as { dedupKey?: string };
1367
+ return opts.dedupKey?.startsWith("heartbeat:ok:");
1368
+ },
1369
+ );
1370
+ expect(successCalls).toHaveLength(0);
1371
+ });
1372
+
1373
+ test("CAS false suppresses failure alerter and feed event", async () => {
1374
+ mockCompleteHeartbeatRun.mockImplementation(() => false);
1375
+
1376
+ const service = createService({
1377
+ processMessage: async () => {
1378
+ throw new Error("LLM timeout");
1379
+ },
1380
+ });
1381
+
1382
+ await service.runOnce();
1383
+
1384
+ // completeHeartbeatRun returned false, so alerter should NOT be called
1385
+ expect(alerterCalls).toHaveLength(0);
1386
+
1387
+ // No failure feed event either
1388
+ const failCalls = mockEmitFeedEvent.mock.calls.filter(
1389
+ (call: unknown[]) => {
1390
+ const opts = call[0] as { dedupKey?: string };
1391
+ return opts.dedupKey?.startsWith("heartbeat:fail:");
1392
+ },
1393
+ );
1394
+ expect(failCalls).toHaveLength(0);
1395
+ });
1396
+
1397
+ test("active-hours skip calls skipHeartbeatRun", async () => {
1398
+ mockConfig.heartbeat.activeHoursStart = 9;
1399
+ mockConfig.heartbeat.activeHoursEnd = 17;
1400
+
1401
+ const service = createService({ getCurrentHour: () => 3 });
1402
+ service.start();
1403
+ await service.runOnce();
1404
+
1405
+ expect(mockSkipHeartbeatRun).toHaveBeenCalledWith(
1406
+ "mock-run-id",
1407
+ "outside_active_hours",
1408
+ );
1409
+ service.stop();
1410
+ });
1411
+
1412
+ test("overlap skip calls skipHeartbeatRun", async () => {
1413
+ let resolveFirst: () => void;
1414
+ const firstPromise = new Promise<void>((r) => {
1415
+ resolveFirst = r;
1416
+ });
1417
+
1418
+ const service = createService({
1419
+ processMessage: async () => {
1420
+ await firstPromise;
1421
+ return { messageId: "msg-1" };
1422
+ },
1423
+ });
1424
+
1425
+ // Start first run (will block)
1426
+ const run1 = service.runOnce();
1427
+ await new Promise((r) => setTimeout(r, 10));
1428
+
1429
+ // Start service so the second runOnce has a pending row
1430
+ service.start();
1431
+ mockSkipHeartbeatRun.mockClear();
1432
+
1433
+ // Second run should be skipped due to overlap
1434
+ await service.runOnce();
1435
+
1436
+ expect(mockSkipHeartbeatRun).toHaveBeenCalledWith(
1437
+ "mock-run-id",
1438
+ "overlap",
1439
+ );
1440
+
1441
+ resolveFirst!();
1442
+ await run1;
1443
+ service.stop();
1444
+ });
1445
+
1446
+ test("start() calls markStaleRunsAsMissed and markStaleRunningAsError", () => {
1447
+ const service = createService();
1448
+ service.start();
1449
+
1450
+ expect(mockMarkStaleRunsAsMissed).toHaveBeenCalledTimes(1);
1451
+ expect(mockMarkStaleRunningAsError).toHaveBeenCalledTimes(1);
1452
+ service.stop();
1453
+ });
1454
+
1455
+ test("scheduleNextRun supersedes old pending row before creating new one", () => {
1456
+ const service = createService();
1457
+ service.start();
1458
+
1459
+ // start() called scheduleNextRun which set _pendingRunId.
1460
+ // Calling resetTimer triggers another scheduleNextRun which
1461
+ // should supersede the existing pending row before inserting
1462
+ // a new one.
1463
+ const callOrder: string[] = [];
1464
+ mockSupersedePendingRun.mockImplementation(() => {
1465
+ callOrder.push("supersede");
1466
+ return true;
1467
+ });
1468
+ mockInsertPendingHeartbeatRun.mockImplementation(() => {
1469
+ callOrder.push("insert");
1470
+ return "mock-run-id";
1471
+ });
1472
+
1473
+ service.resetTimer();
1474
+
1475
+ // resetTimer's scheduleNextRun should supersede then insert
1476
+ expect(callOrder.filter((c) => c === "supersede").length).toBeGreaterThan(
1477
+ 0,
1478
+ );
1479
+ const firstSupersede = callOrder.indexOf("supersede");
1480
+ const firstInsert = callOrder.indexOf("insert");
1481
+ expect(firstSupersede).toBeLessThan(firstInsert);
1482
+
1483
+ service.stop();
1484
+ });
1485
+
1486
+ test("resetTimer() supersedes pending row", () => {
1487
+ const service = createService();
1488
+ service.start();
1489
+
1490
+ mockSupersedePendingRun.mockClear();
1491
+ service.resetTimer();
1492
+
1493
+ // resetTimer calls scheduleNextRun which supersedes existing pending
1494
+ expect(mockSupersedePendingRun).toHaveBeenCalled();
1495
+ service.stop();
1496
+ });
1497
+
1498
+ test("force run creates its own pending row, does not consume scheduled one", async () => {
1499
+ const service = createService();
1500
+ service.start();
1501
+
1502
+ // Clear to track only the force run's calls
1503
+ mockInsertPendingHeartbeatRun.mockClear();
1504
+
1505
+ await service.runOnce({ force: true });
1506
+
1507
+ // Force run should have called insertPendingHeartbeatRun for itself
1508
+ // (at least once for its own row, plus the scheduleNextRun in finally)
1509
+ expect(mockInsertPendingHeartbeatRun).toHaveBeenCalled();
1510
+
1511
+ // The scheduled pending row (from start()) should NOT have been consumed
1512
+ // by the force run — force creates its own
1513
+ service.stop();
1514
+ });
1515
+
1516
+ test("disabled config with stale pending row skips it as disabled", async () => {
1517
+ const service = createService();
1518
+ service.start();
1519
+
1520
+ // Now disable config and call runOnce — should skip the pending row
1521
+ mockConfig.heartbeat.enabled = false;
1522
+ mockSkipHeartbeatRun.mockClear();
1523
+
1524
+ await service.runOnce();
1525
+
1526
+ expect(mockSkipHeartbeatRun).toHaveBeenCalledWith(
1527
+ "mock-run-id",
1528
+ "disabled",
1529
+ );
1530
+ service.stop();
1531
+ });
1532
+
1533
+ test("stop() supersedes outstanding pending row", async () => {
1534
+ const service = createService();
1535
+ service.start();
1536
+
1537
+ mockSupersedePendingRun.mockClear();
1538
+ await service.stop();
1539
+
1540
+ expect(mockSupersedePendingRun).toHaveBeenCalledWith("mock-run-id");
1541
+ });
1542
+
1543
+ test("timeout calls completeHeartbeatRun with status timeout", async () => {
1544
+ jest.useFakeTimers();
1545
+ try {
1546
+ let resolveRun: () => void;
1547
+ const runPromise = new Promise<void>((r) => {
1548
+ resolveRun = r;
1549
+ });
1550
+
1551
+ const service = createService({
1552
+ processMessage: async () => {
1553
+ await runPromise;
1554
+ return { messageId: "msg-1" };
1555
+ },
1556
+ });
1557
+
1558
+ const runOncePromise = service.runOnce();
1559
+ // Advance past the 30-minute timeout
1560
+ jest.advanceTimersByTime(30 * 60 * 1000 + 1000);
1561
+ await runOncePromise;
1562
+
1563
+ expect(mockCompleteHeartbeatRun).toHaveBeenCalledWith("mock-run-id", {
1564
+ status: "timeout",
1565
+ error: "Heartbeat execution exceeded the 30-minute timeout",
1566
+ });
1567
+
1568
+ // Clean up — resolve the hanging promise so it doesn't leak
1569
+ resolveRun!();
1570
+ } finally {
1571
+ jest.useRealTimers();
1572
+ }
1573
+ });
1574
+
1575
+ test("failure feed event has urgency high and includes error message", async () => {
1576
+ const service = createService({
1577
+ processMessage: async () => {
1578
+ throw new Error("web_search outage");
1579
+ },
1580
+ });
1581
+
1582
+ await service.runOnce();
1583
+
1584
+ const failCalls = mockEmitFeedEvent.mock.calls.filter(
1585
+ (call: unknown[]) => {
1586
+ const opts = call[0] as { title?: string };
1587
+ return opts.title === "Heartbeat Failed";
1588
+ },
1589
+ );
1590
+ expect(failCalls).toHaveLength(1);
1591
+ const opts = (failCalls as any[][])[0][0] as {
1592
+ urgency?: string;
1593
+ summary?: string;
1594
+ };
1595
+ expect(opts.urgency).toBe("high");
1596
+ expect(opts.summary).toContain("web_search outage");
1597
+ });
1598
+
1599
+ test("CAS false on complete suppresses failure feed event", async () => {
1600
+ mockCompleteHeartbeatRun.mockImplementation(() => false);
1601
+
1602
+ const service = createService({
1603
+ processMessage: async () => {
1604
+ throw new Error("some error");
1605
+ },
1606
+ });
1607
+
1608
+ await service.runOnce();
1609
+
1610
+ const failCalls = mockEmitFeedEvent.mock.calls.filter(
1611
+ (call: unknown[]) => {
1612
+ const opts = call[0] as { title?: string };
1613
+ return opts.title === "Heartbeat Failed";
1614
+ },
1615
+ );
1616
+ expect(failCalls).toHaveLength(0);
1617
+ });
1618
+
1619
+ test("timeout emits feed event with urgency high", async () => {
1620
+ jest.useFakeTimers();
1621
+ try {
1622
+ let resolveRun: () => void;
1623
+ const runPromise = new Promise<void>((r) => {
1624
+ resolveRun = r;
1625
+ });
1626
+
1627
+ const service = createService({
1628
+ processMessage: async () => {
1629
+ await runPromise;
1630
+ return { messageId: "msg-1" };
1631
+ },
1632
+ });
1633
+
1634
+ const runOncePromise = service.runOnce();
1635
+ jest.advanceTimersByTime(30 * 60 * 1000 + 1000);
1636
+ await runOncePromise;
1637
+
1638
+ const timeoutCalls = mockEmitFeedEvent.mock.calls.filter(
1639
+ (call: unknown[]) => {
1640
+ const opts = call[0] as { title?: string };
1641
+ return opts.title === "Heartbeat Timed Out";
1642
+ },
1643
+ );
1644
+ expect(timeoutCalls).toHaveLength(1);
1645
+ const opts = (timeoutCalls as any[][])[0][0] as {
1646
+ urgency?: string;
1647
+ };
1648
+ expect(opts.urgency).toBe("high");
1649
+
1650
+ resolveRun!();
1651
+ } finally {
1652
+ jest.useRealTimers();
1653
+ }
1654
+ });
1655
+
1656
+ test("CAS false on timeout suppresses timeout feed event", async () => {
1657
+ jest.useFakeTimers();
1658
+ try {
1659
+ mockCompleteHeartbeatRun.mockImplementation(() => false);
1660
+
1661
+ let resolveRun: () => void;
1662
+ const runPromise = new Promise<void>((r) => {
1663
+ resolveRun = r;
1664
+ });
1665
+
1666
+ const service = createService({
1667
+ processMessage: async () => {
1668
+ await runPromise;
1669
+ return { messageId: "msg-1" };
1670
+ },
1671
+ });
1672
+
1673
+ const runOncePromise = service.runOnce();
1674
+ jest.advanceTimersByTime(30 * 60 * 1000 + 1000);
1675
+ await runOncePromise;
1676
+
1677
+ // completeHeartbeatRun returned false, so no timeout feed event
1678
+ const timeoutCalls = mockEmitFeedEvent.mock.calls.filter(
1679
+ (call: unknown[]) => {
1680
+ const opts = call[0] as { title?: string };
1681
+ return opts.title === "Heartbeat Timed Out";
1682
+ },
1683
+ );
1684
+ expect(timeoutCalls).toHaveLength(0);
1685
+
1686
+ resolveRun!();
1687
+ } finally {
1688
+ jest.useRealTimers();
1689
+ }
1690
+ });
1691
+
1692
+ test("late run emits late feed event", async () => {
1693
+ const service = createService();
1694
+ service.start();
1695
+
1696
+ // Set the pending run to be 10 minutes in the past
1697
+ (service as any)._nextRunAt = Date.now() - 10 * 60 * 1000;
1698
+ (service as any)._pendingRunId = "late-run-id";
1699
+
1700
+ await service.runOnce();
1701
+
1702
+ const lateCalls = mockEmitFeedEvent.mock.calls.filter(
1703
+ (call: unknown[]) => {
1704
+ const opts = call[0] as { title?: string };
1705
+ return opts.title === "Heartbeat Ran Late";
1706
+ },
1707
+ );
1708
+ expect(lateCalls).toHaveLength(1);
1709
+ const opts = (lateCalls as any[][])[0][0] as {
1710
+ urgency?: string;
1711
+ summary?: string;
1712
+ };
1713
+ expect(opts.urgency).toBe("medium");
1714
+ expect(opts.summary).toContain("10 minutes late");
1715
+
1716
+ await service.stop();
1717
+ });
1718
+
1719
+ test("on-time run does not emit late feed event", async () => {
1720
+ const service = createService();
1721
+ await service.runOnce();
1722
+
1723
+ const lateCalls = mockEmitFeedEvent.mock.calls.filter(
1724
+ (call: unknown[]) => {
1725
+ const opts = call[0] as { title?: string };
1726
+ return opts.title === "Heartbeat Ran Late";
1727
+ },
1728
+ );
1729
+ expect(lateCalls).toHaveLength(0);
1730
+ });
1731
+
1732
+ test("start() emits missed-run feed event when stale rows exist", () => {
1733
+ mockMarkStaleRunsAsMissed.mockImplementation(() => 2);
1734
+ mockMarkStaleRunningAsError.mockImplementation(() => 1);
1735
+
1736
+ const service = createService();
1737
+ service.start();
1738
+
1739
+ const missedCalls = mockEmitFeedEvent.mock.calls.filter(
1740
+ (call: unknown[]) => {
1741
+ const opts = call[0] as { title?: string };
1742
+ return opts.title === "Heartbeat Runs Missed";
1743
+ },
1744
+ );
1745
+ expect(missedCalls).toHaveLength(1);
1746
+ const opts = (missedCalls as any[][])[0][0] as {
1747
+ urgency?: string;
1748
+ summary?: string;
1749
+ };
1750
+ expect(opts.urgency).toBe("high");
1751
+ expect(opts.summary).toContain("3");
1752
+
1753
+ service.stop();
1754
+ });
1755
+
1756
+ test("start() does not emit missed-run feed event when counts are 0", () => {
1757
+ mockMarkStaleRunsAsMissed.mockImplementation(() => 0);
1758
+ mockMarkStaleRunningAsError.mockImplementation(() => 0);
1759
+
1760
+ const service = createService();
1761
+ service.start();
1762
+
1763
+ const missedCalls = mockEmitFeedEvent.mock.calls.filter(
1764
+ (call: unknown[]) => {
1765
+ const opts = call[0] as { title?: string };
1766
+ return opts.title === "Heartbeat Runs Missed";
1767
+ },
1768
+ );
1769
+ expect(missedCalls).toHaveLength(0);
1770
+ service.stop();
1771
+ });
1772
+ });
1056
1773
  });