@swarmclawai/swarmclaw 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -0,0 +1,594 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import http from 'node:http'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { after, before, describe, it } from 'node:test'
7
+
8
+ const originalEnv = {
9
+ DATA_DIR: process.env.DATA_DIR,
10
+ WORKSPACE_DIR: process.env.WORKSPACE_DIR,
11
+ SWARMCLAW_BUILD_MODE: process.env.SWARMCLAW_BUILD_MODE,
12
+ }
13
+
14
+ let tempDir = ''
15
+ let watchJobs: typeof import('./watch-jobs')
16
+ let storage: typeof import('./storage')
17
+
18
+ before(async () => {
19
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-watch-adv-'))
20
+ process.env.DATA_DIR = path.join(tempDir, 'data')
21
+ process.env.WORKSPACE_DIR = path.join(tempDir, 'workspace')
22
+ process.env.SWARMCLAW_BUILD_MODE = '1'
23
+ watchJobs = await import('./watch-jobs')
24
+ storage = await import('./storage')
25
+ // When run after another test file, modules are cached and DATA_DIR is the
26
+ // old (deleted) path. Ensure the cached DATA_DIR directory exists so the
27
+ // shared DB connection works.
28
+ const dataDir = await import('./data-dir')
29
+ fs.mkdirSync(dataDir.DATA_DIR, { recursive: true })
30
+ })
31
+
32
+ after(() => {
33
+ if (originalEnv.DATA_DIR === undefined) delete process.env.DATA_DIR
34
+ else process.env.DATA_DIR = originalEnv.DATA_DIR
35
+ if (originalEnv.WORKSPACE_DIR === undefined) delete process.env.WORKSPACE_DIR
36
+ else process.env.WORKSPACE_DIR = originalEnv.WORKSPACE_DIR
37
+ if (originalEnv.SWARMCLAW_BUILD_MODE === undefined) delete process.env.SWARMCLAW_BUILD_MODE
38
+ else process.env.SWARMCLAW_BUILD_MODE = originalEnv.SWARMCLAW_BUILD_MODE
39
+ fs.rmSync(tempDir, { recursive: true, force: true })
40
+ })
41
+
42
+ describe('watch-jobs advanced', () => {
43
+ it('1. time watch triggers at exact boundary', async () => {
44
+ const now = Date.now()
45
+ const job = await watchJobs.createWatchJob({
46
+ type: 'time',
47
+ resumeMessage: 'wake up',
48
+ target: { source: 'boundary-test' },
49
+ condition: {},
50
+ runAt: now - 1000,
51
+ })
52
+ const outcome = await watchJobs.processDueWatchJobs(now)
53
+ const afterJob = watchJobs.getWatchJob(job.id)
54
+
55
+ assert.ok(outcome.triggered >= 1)
56
+ assert.equal(afterJob?.status, 'triggered')
57
+ })
58
+
59
+ it('2. time watch does not fire early', async () => {
60
+ const now = Date.now()
61
+ const job = await watchJobs.createWatchJob({
62
+ type: 'time',
63
+ resumeMessage: 'too early',
64
+ target: { source: 'early-test' },
65
+ condition: {},
66
+ runAt: now + 60000,
67
+ })
68
+ await watchJobs.processDueWatchJobs(now)
69
+ const afterJob = watchJobs.getWatchJob(job.id)
70
+
71
+ assert.equal(afterJob?.status, 'active')
72
+ })
73
+
74
+ it('3. task status watch chain — mixed triggers', async () => {
75
+ const now = Date.now()
76
+ const tasks = storage.loadTasks()
77
+ tasks['task-adv-1'] = { id: 'task-adv-1', title: 'T1', status: 'completed', result: 'ok', createdAt: now, updatedAt: now }
78
+ tasks['task-adv-2'] = { id: 'task-adv-2', title: 'T2', status: 'queued', result: null, createdAt: now, updatedAt: now }
79
+ tasks['task-adv-3'] = { id: 'task-adv-3', title: 'T3', status: 'failed', result: null, createdAt: now, updatedAt: now }
80
+ storage.saveTasks(tasks)
81
+
82
+ const watchA = await watchJobs.createWatchJob({
83
+ type: 'task',
84
+ resumeMessage: 'task-1 done',
85
+ target: { taskId: 'task-adv-1' },
86
+ condition: { statusIn: ['completed'] },
87
+ })
88
+ const watchB = await watchJobs.createWatchJob({
89
+ type: 'task',
90
+ resumeMessage: 'task-2 running',
91
+ target: { taskId: 'task-adv-2' },
92
+ condition: { statusIn: ['running'] },
93
+ })
94
+ const watchC = await watchJobs.createWatchJob({
95
+ type: 'task',
96
+ resumeMessage: 'task-3 terminal',
97
+ target: { taskId: 'task-adv-3' },
98
+ condition: { statusIn: ['completed', 'failed'] },
99
+ })
100
+
101
+ await watchJobs.processDueWatchJobs(Date.now())
102
+
103
+ assert.equal(watchJobs.getWatchJob(watchA.id)?.status, 'triggered')
104
+ assert.equal(watchJobs.getWatchJob(watchB.id)?.status, 'active')
105
+ assert.equal(watchJobs.getWatchJob(watchC.id)?.status, 'triggered')
106
+ assert.equal(watchJobs.getWatchJob(watchA.id)?.result?.status, 'completed')
107
+ assert.equal(watchJobs.getWatchJob(watchC.id)?.result?.status, 'failed')
108
+ })
109
+
110
+ it('4. file existence watch — not exists then exists', async () => {
111
+ const tmpFile = path.join(tempDir, 'exist-test-' + Date.now() + '.txt')
112
+ const now = Date.now()
113
+
114
+ const job = await watchJobs.createWatchJob({
115
+ type: 'file',
116
+ resumeMessage: 'file appeared',
117
+ target: { path: tmpFile },
118
+ condition: { exists: true },
119
+ })
120
+
121
+ await watchJobs.processDueWatchJobs(now)
122
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
123
+
124
+ fs.writeFileSync(tmpFile, 'hello')
125
+ await watchJobs.processDueWatchJobs(now + 120_000)
126
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
127
+ })
128
+
129
+ it('5. file change detection — baseline then mutation', async () => {
130
+ const tmpFile = path.join(tempDir, 'change-test-' + Date.now() + '.txt')
131
+ const now = Date.now()
132
+
133
+ fs.writeFileSync(tmpFile, 'initial content')
134
+
135
+ const job = await watchJobs.createWatchJob({
136
+ type: 'file',
137
+ resumeMessage: 'file changed',
138
+ target: { path: tmpFile },
139
+ condition: { changed: true },
140
+ })
141
+
142
+ assert.ok(typeof job.target.baselineHash === 'string' && job.target.baselineHash.length > 0)
143
+
144
+ await watchJobs.processDueWatchJobs(now)
145
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
146
+
147
+ fs.writeFileSync(tmpFile, 'modified content')
148
+ await watchJobs.processDueWatchJobs(now + 120_000)
149
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
150
+ })
151
+
152
+ it('6. HTTP status code watch — 500 then 200', async () => {
153
+ let statusCode = 500
154
+ const server = http.createServer((_req, res) => {
155
+ res.writeHead(statusCode, { 'Content-Type': 'text/plain' })
156
+ res.end('status: ' + statusCode)
157
+ })
158
+
159
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
160
+ const addr = server.address()
161
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0
162
+ const url = 'http://127.0.0.1:' + port + '/health'
163
+ const now = Date.now()
164
+
165
+ try {
166
+ const job = await watchJobs.createWatchJob({
167
+ type: 'http',
168
+ resumeMessage: 'health ok',
169
+ target: { url },
170
+ condition: { status: 200 },
171
+ })
172
+
173
+ await watchJobs.processDueWatchJobs(now)
174
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
175
+
176
+ statusCode = 200
177
+ await watchJobs.processDueWatchJobs(now + 120_000)
178
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
179
+ } finally {
180
+ server.close()
181
+ }
182
+ })
183
+
184
+ it('7. multiple watches, mixed results', async () => {
185
+ const now = Date.now()
186
+
187
+ const tasks = storage.loadTasks()
188
+ tasks['task-mix-adv'] = { id: 'task-mix-adv', title: 'Mix', status: 'completed', result: 'ok', createdAt: now, updatedAt: now }
189
+ storage.saveTasks(tasks)
190
+
191
+ const w1 = await watchJobs.createWatchJob({
192
+ type: 'time', resumeMessage: 'w1-adv', target: { source: 'w1-adv' }, condition: {}, runAt: now - 5000,
193
+ })
194
+ const w2 = await watchJobs.createWatchJob({
195
+ type: 'time', resumeMessage: 'w2-adv', target: { source: 'w2-adv' }, condition: {}, runAt: now - 1000,
196
+ })
197
+ const w3 = await watchJobs.createWatchJob({
198
+ type: 'file', resumeMessage: 'w3-adv', target: { path: path.join(tempDir, 'nonexistent-' + now + '.txt') }, condition: { exists: true },
199
+ })
200
+ const w4 = await watchJobs.createWatchJob({
201
+ type: 'task', resumeMessage: 'w4-adv', target: { taskId: 'task-mix-adv' }, condition: { statusIn: ['completed'] },
202
+ })
203
+ const w5 = await watchJobs.createWatchJob({
204
+ type: 'time', resumeMessage: 'w5-adv', target: { source: 'w5-adv' }, condition: {}, runAt: now + 60000,
205
+ })
206
+
207
+ await watchJobs.processDueWatchJobs(Date.now())
208
+
209
+ assert.equal(watchJobs.getWatchJob(w1.id)?.status, 'triggered')
210
+ assert.equal(watchJobs.getWatchJob(w2.id)?.status, 'triggered')
211
+ assert.equal(watchJobs.getWatchJob(w3.id)?.status, 'active')
212
+ assert.equal(watchJobs.getWatchJob(w4.id)?.status, 'triggered')
213
+ assert.equal(watchJobs.getWatchJob(w5.id)?.status, 'active')
214
+ })
215
+
216
+ it('8. triggered watch is terminal — does not re-trigger', async () => {
217
+ const now = Date.now()
218
+
219
+ const job = await watchJobs.createWatchJob({
220
+ type: 'time', resumeMessage: 'once only adv', target: { source: 'terminal-test-adv' }, condition: {}, runAt: now - 1000,
221
+ })
222
+
223
+ await watchJobs.processDueWatchJobs(now)
224
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
225
+
226
+ await watchJobs.processDueWatchJobs(now + 120_000)
227
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
228
+ })
229
+
230
+ it('9. validation rejects bad targets', async () => {
231
+ await assert.rejects(
232
+ watchJobs.createWatchJob({ type: 'time', resumeMessage: 'x', target: { source: 'v' }, condition: {} }),
233
+ /runAt/,
234
+ )
235
+ await assert.rejects(
236
+ watchJobs.createWatchJob({ type: 'task', resumeMessage: 'x', target: {}, condition: {} }),
237
+ /taskId/,
238
+ )
239
+ await assert.rejects(
240
+ watchJobs.createWatchJob({ type: 'file', resumeMessage: 'x', target: {}, condition: {} }),
241
+ /path/,
242
+ )
243
+ await assert.rejects(
244
+ watchJobs.createWatchJob({ type: 'http', resumeMessage: 'x', target: {}, condition: {} }),
245
+ /url/,
246
+ )
247
+ await assert.rejects(
248
+ watchJobs.createWatchJob({ type: 'webhook', resumeMessage: 'x', target: {}, condition: {} }),
249
+ /webhookId/,
250
+ )
251
+ await assert.rejects(
252
+ watchJobs.createWatchJob({ type: 'mailbox', resumeMessage: 'x', target: {}, condition: {} }),
253
+ /sessionId/,
254
+ )
255
+ await assert.rejects(
256
+ watchJobs.createWatchJob({ type: 'approval', resumeMessage: 'x', target: {}, condition: {} }),
257
+ /approvalId/,
258
+ )
259
+ })
260
+
261
+ it('10. mailbox watch triggers on matching envelope', async () => {
262
+ const now = Date.now()
263
+
264
+ const job = await watchJobs.createWatchJob({
265
+ type: 'mailbox',
266
+ resumeMessage: 'got mail adv',
267
+ target: { sessionId: 'session-inbox-adv-1' },
268
+ condition: { type: 'human_reply', correlationId: 'corr-adv-42' },
269
+ })
270
+
271
+ const matches = watchJobs.triggerMailboxWatchJobs({
272
+ sessionId: 'session-inbox-adv-1',
273
+ envelope: {
274
+ id: 'env-adv-1',
275
+ type: 'human_reply',
276
+ payload: 'looks good',
277
+ toSessionId: 'session-inbox-adv-1',
278
+ correlationId: 'corr-adv-42',
279
+ status: 'new',
280
+ createdAt: now,
281
+ },
282
+ })
283
+ const afterJob = watchJobs.getWatchJob(job.id)
284
+
285
+ assert.equal(matches.length, 1)
286
+ assert.equal(afterJob?.status, 'triggered')
287
+ assert.equal(afterJob?.result?.correlationId, 'corr-adv-42')
288
+ assert.equal(afterJob?.result?.payload, 'looks good')
289
+ })
290
+
291
+ it('11. mailbox watch ignores non-matching envelope', async () => {
292
+ const now = Date.now()
293
+
294
+ const job = await watchJobs.createWatchJob({
295
+ type: 'mailbox',
296
+ resumeMessage: 'got mail adv-2',
297
+ target: { sessionId: 'session-inbox-adv-2' },
298
+ condition: { type: 'human_reply', correlationId: 'corr-adv-99' },
299
+ })
300
+
301
+ const matches1 = watchJobs.triggerMailboxWatchJobs({
302
+ sessionId: 'session-inbox-WRONG',
303
+ envelope: {
304
+ id: 'env-adv-2', type: 'human_reply', payload: 'wrong session',
305
+ toSessionId: 'session-inbox-WRONG', correlationId: 'corr-adv-99', status: 'new', createdAt: now,
306
+ },
307
+ })
308
+
309
+ const matches2 = watchJobs.triggerMailboxWatchJobs({
310
+ sessionId: 'session-inbox-adv-2',
311
+ envelope: {
312
+ id: 'env-adv-3', type: 'human_reply', payload: 'wrong corr',
313
+ toSessionId: 'session-inbox-adv-2', correlationId: 'corr-adv-WRONG', status: 'new', createdAt: now,
314
+ },
315
+ })
316
+
317
+ assert.equal(matches1.length, 0)
318
+ assert.equal(matches2.length, 0)
319
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
320
+ })
321
+
322
+ it('12. approval watch triggers on matching approval status', async () => {
323
+ const now = Date.now()
324
+
325
+ storage.upsertApproval('appr-adv-1', {
326
+ id: 'appr-adv-1',
327
+ category: 'human_loop',
328
+ title: 'Deploy approval adv',
329
+ description: 'Approve deploy?',
330
+ data: {},
331
+ createdAt: now,
332
+ updatedAt: now,
333
+ status: 'pending',
334
+ })
335
+
336
+ const job = await watchJobs.createWatchJob({
337
+ type: 'approval',
338
+ resumeMessage: 'approved adv',
339
+ target: { approvalId: 'appr-adv-1' },
340
+ condition: { statusIn: ['approved'] },
341
+ })
342
+
343
+ const matches = watchJobs.triggerApprovalWatchJobs({
344
+ approvalId: 'appr-adv-1',
345
+ status: 'approved',
346
+ })
347
+ const afterJob = watchJobs.getWatchJob(job.id)
348
+
349
+ assert.equal(matches.length, 1)
350
+ assert.equal(afterJob?.status, 'triggered')
351
+ assert.equal(afterJob?.result?.status, 'approved')
352
+ assert.equal(afterJob?.result?.approvalId, 'appr-adv-1')
353
+ })
354
+
355
+ it('13. approval watch ignores non-matching status', async () => {
356
+ const now = Date.now()
357
+
358
+ storage.upsertApproval('appr-adv-2', {
359
+ id: 'appr-adv-2', category: 'human_loop', title: 'Test adv',
360
+ description: 'Test', data: {}, createdAt: now, updatedAt: now, status: 'pending',
361
+ })
362
+
363
+ const job = await watchJobs.createWatchJob({
364
+ type: 'approval',
365
+ resumeMessage: 'approved only adv',
366
+ target: { approvalId: 'appr-adv-2' },
367
+ condition: { statusIn: ['approved'] },
368
+ })
369
+
370
+ const matches = watchJobs.triggerApprovalWatchJobs({
371
+ approvalId: 'appr-adv-2',
372
+ status: 'rejected',
373
+ })
374
+
375
+ assert.equal(matches.length, 0)
376
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
377
+ })
378
+
379
+ it('14. timeout expires a watch as failed', async () => {
380
+ const now = Date.now()
381
+ const tmpFile = path.join(tempDir, 'timeout-file-' + now + '.txt')
382
+
383
+ const job = await watchJobs.createWatchJob({
384
+ type: 'file',
385
+ resumeMessage: 'never arrives',
386
+ target: { path: tmpFile },
387
+ condition: { exists: true },
388
+ timeoutAt: now + 5000,
389
+ })
390
+
391
+ const outcome = await watchJobs.processDueWatchJobs(now + 10_000)
392
+ const afterJob = watchJobs.getWatchJob(job.id)
393
+
394
+ assert.ok(outcome.failed >= 1)
395
+ assert.equal(afterJob?.status, 'failed')
396
+ assert.match(String(afterJob?.lastError), /timed out/)
397
+ })
398
+
399
+ it('15. cancel prevents future processing', async () => {
400
+ const now = Date.now()
401
+
402
+ const job = await watchJobs.createWatchJob({
403
+ type: 'time',
404
+ resumeMessage: 'cancelled adv',
405
+ target: { source: 'cancel-test-adv' },
406
+ condition: {},
407
+ runAt: now - 1000,
408
+ })
409
+
410
+ watchJobs.cancelWatchJob(job.id)
411
+ await watchJobs.processDueWatchJobs(now)
412
+ const afterJob = watchJobs.getWatchJob(job.id)
413
+
414
+ assert.equal(afterJob?.status, 'cancelled')
415
+ })
416
+
417
+ it('16. HTTP content-contains watch', async () => {
418
+ let body = 'status: deploying'
419
+ const server = http.createServer((_req, res) => {
420
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
421
+ res.end(body)
422
+ })
423
+
424
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
425
+ const addr = server.address()
426
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0
427
+ const url = 'http://127.0.0.1:' + port + '/status'
428
+ const now = Date.now()
429
+
430
+ try {
431
+ const job = await watchJobs.createWatchJob({
432
+ type: 'http',
433
+ resumeMessage: 'deploy done adv',
434
+ target: { url },
435
+ condition: { containsText: 'deployed successfully' },
436
+ })
437
+
438
+ await watchJobs.processDueWatchJobs(now)
439
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
440
+
441
+ body = 'status: deployed successfully at 12:00'
442
+ await watchJobs.processDueWatchJobs(now + 120_000)
443
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
444
+ } finally {
445
+ server.close()
446
+ }
447
+ })
448
+
449
+ it('17. webhook watch triggers only for matching webhookId and event', async () => {
450
+ const job = await watchJobs.createWatchJob({
451
+ type: 'webhook',
452
+ resumeMessage: 'build done adv',
453
+ target: { webhookId: 'wh-build-adv-1' },
454
+ condition: { event: 'build.completed' },
455
+ })
456
+
457
+ const m1 = watchJobs.triggerWebhookWatchJobs({ webhookId: 'wh-other', event: 'build.completed' })
458
+ const m2 = watchJobs.triggerWebhookWatchJobs({ webhookId: 'wh-build-adv-1', event: 'build.started' })
459
+ const m3 = watchJobs.triggerWebhookWatchJobs({ webhookId: 'wh-build-adv-1', event: 'build.completed', payloadPreview: '{"ok":true}' })
460
+
461
+ assert.equal(m1.length, 0)
462
+ assert.equal(m2.length, 0)
463
+ assert.equal(m3.length, 1)
464
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
465
+ assert.equal(watchJobs.getWatchJob(job.id)?.result?.event, 'build.completed')
466
+ })
467
+
468
+ it('18. list and filter watch jobs by status and sessionId', async () => {
469
+ const now = Date.now()
470
+
471
+ await watchJobs.createWatchJob({
472
+ type: 'time', resumeMessage: 'a-adv', target: { source: 'a-adv' }, condition: {},
473
+ runAt: now - 1000, sessionId: 'sess-adv-A',
474
+ })
475
+ await watchJobs.createWatchJob({
476
+ type: 'time', resumeMessage: 'b-adv', target: { source: 'b-adv' }, condition: {},
477
+ runAt: now + 600_000, sessionId: 'sess-adv-A',
478
+ })
479
+ await watchJobs.createWatchJob({
480
+ type: 'time', resumeMessage: 'c-adv', target: { source: 'c-adv' }, condition: {},
481
+ runAt: now - 500, sessionId: 'sess-adv-B',
482
+ })
483
+
484
+ await watchJobs.processDueWatchJobs(now)
485
+
486
+ const sessAAll = watchJobs.listWatchJobs({ sessionId: 'sess-adv-A' })
487
+ const sessATriggered = watchJobs.listWatchJobs({ sessionId: 'sess-adv-A', status: 'triggered' })
488
+ const sessBAll = watchJobs.listWatchJobs({ sessionId: 'sess-adv-B' })
489
+
490
+ assert.equal(sessAAll.length, 2)
491
+ assert.equal(sessATriggered.length, 1)
492
+ assert.equal(sessBAll.length, 1)
493
+ })
494
+
495
+ it('19. concurrent batch — many watches processed in single pass', async () => {
496
+ const now = Date.now()
497
+ const ids: string[] = []
498
+
499
+ for (let i = 0; i < 20; i++) {
500
+ const job = await watchJobs.createWatchJob({
501
+ type: 'time',
502
+ resumeMessage: 'batch-adv-' + i,
503
+ target: { source: 'batch-adv-' + i },
504
+ condition: {},
505
+ runAt: i < 15 ? now - 1000 : now + 60000,
506
+ })
507
+ ids.push(job.id)
508
+ }
509
+
510
+ const outcome = await watchJobs.processDueWatchJobs(now)
511
+
512
+ assert.ok(outcome.triggered >= 15)
513
+
514
+ for (let i = 0; i < 15; i++) {
515
+ assert.equal(watchJobs.getWatchJob(ids[i])?.status, 'triggered', 'batch item ' + i + ' should be triggered')
516
+ }
517
+ for (let i = 15; i < 20; i++) {
518
+ assert.equal(watchJobs.getWatchJob(ids[i])?.status, 'active', 'batch item ' + i + ' should be active')
519
+ }
520
+ })
521
+
522
+ it('20. HTTP change detection via baseline hash', async () => {
523
+ let body = 'version: 1.0.0'
524
+ const server = http.createServer((_req, res) => {
525
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
526
+ res.end(body)
527
+ })
528
+
529
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()))
530
+ const addr = server.address()
531
+ const port = typeof addr === 'object' && addr !== null ? addr.port : 0
532
+ const url = 'http://127.0.0.1:' + port + '/version'
533
+ const now = Date.now()
534
+
535
+ try {
536
+ const job = await watchJobs.createWatchJob({
537
+ type: 'http',
538
+ resumeMessage: 'version changed adv',
539
+ target: { url },
540
+ condition: { changed: true },
541
+ })
542
+ assert.ok(typeof job.target.baselineHash === 'string' && job.target.baselineHash.length > 0)
543
+
544
+ await watchJobs.processDueWatchJobs(now)
545
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
546
+
547
+ body = 'version: 2.0.0'
548
+ await watchJobs.processDueWatchJobs(now + 120_000)
549
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
550
+ } finally {
551
+ server.close()
552
+ }
553
+ })
554
+
555
+ it('21. file watch with regex condition', async () => {
556
+ const tmpFile = path.join(tempDir, 'regex-test-' + Date.now() + '.txt')
557
+ const now = Date.now()
558
+
559
+ fs.writeFileSync(tmpFile, 'status: pending')
560
+
561
+ const job = await watchJobs.createWatchJob({
562
+ type: 'file',
563
+ resumeMessage: 'pattern matched adv',
564
+ target: { path: tmpFile },
565
+ condition: { regex: 'status:\\s+completed' },
566
+ })
567
+
568
+ await watchJobs.processDueWatchJobs(now)
569
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'active')
570
+
571
+ fs.writeFileSync(tmpFile, 'status: completed')
572
+ await watchJobs.processDueWatchJobs(now + 120_000)
573
+ assert.equal(watchJobs.getWatchJob(job.id)?.status, 'triggered')
574
+ })
575
+
576
+ it('22. interval rescheduling — nextCheckAt advances by intervalMs', async () => {
577
+ const now = Date.now()
578
+ const missingFile = path.join(tempDir, 'resched-' + now + '.txt')
579
+
580
+ const job = await watchJobs.createWatchJob({
581
+ type: 'file',
582
+ resumeMessage: 'resched test adv',
583
+ target: { path: missingFile },
584
+ condition: { exists: true },
585
+ intervalMs: 60_000,
586
+ })
587
+
588
+ await watchJobs.processDueWatchJobs(now)
589
+ const afterJob = watchJobs.getWatchJob(job.id)
590
+
591
+ assert.equal(afterJob?.status, 'active')
592
+ assert.equal(afterJob?.nextCheckAt, now + 60_000)
593
+ })
594
+ })
@@ -77,14 +77,29 @@ function finalizeWatchJob(job: WatchJob, status: WatchJob['status'], result?: Re
77
77
  function wakeFromWatch(job: WatchJob, result?: Record<string, unknown> | null) {
78
78
  const summary = job.description || `Watch ${job.id}`
79
79
  const detail = result ? JSON.stringify(result).slice(0, 1200) : ''
80
+ const resumeMessage = job.resumeMessage || summary
80
81
  if (job.sessionId) {
81
82
  enqueueSystemEvent(
82
83
  job.sessionId,
83
84
  `[Watch Triggered] ${summary}\n${job.resumeMessage}${detail ? `\n\nObserved:\n${detail}` : ''}`,
84
85
  )
85
- requestHeartbeatNow({ sessionId: job.sessionId, reason: 'watch_job' })
86
+ requestHeartbeatNow({
87
+ sessionId: job.sessionId,
88
+ eventId: `${job.id}:${job.updatedAt || job.createdAt}`,
89
+ reason: 'watch_job',
90
+ source: `watch:${job.type}`,
91
+ resumeMessage,
92
+ detail,
93
+ })
86
94
  } else if (job.agentId) {
87
- requestHeartbeatNow({ agentId: job.agentId, reason: 'watch_job' })
95
+ requestHeartbeatNow({
96
+ agentId: job.agentId,
97
+ eventId: `${job.id}:${job.updatedAt || job.createdAt}`,
98
+ reason: 'watch_job',
99
+ source: `watch:${job.type}`,
100
+ resumeMessage,
101
+ detail,
102
+ })
88
103
  }
89
104
  }
90
105