@desplega.ai/agent-swarm 1.20.0 → 1.51.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 (561) hide show
  1. package/README.md +271 -169
  2. package/openapi.json +5015 -0
  3. package/package.json +40 -7
  4. package/plugin/commands/close-issue.md +7 -3
  5. package/plugin/commands/create-pr.md +18 -12
  6. package/plugin/commands/implement-issue.md +7 -3
  7. package/plugin/commands/respond-github.md +8 -4
  8. package/plugin/commands/review-pr.md +44 -10
  9. package/plugin/commands/start-leader.md +1 -3
  10. package/plugin/commands/start-worker.md +1 -3
  11. package/plugin/commands/work-on-task.md +22 -3
  12. package/plugin/pi-skills/close-issue/SKILL.md +90 -0
  13. package/plugin/pi-skills/create-pr/SKILL.md +99 -0
  14. package/plugin/pi-skills/implement-issue/SKILL.md +135 -0
  15. package/plugin/pi-skills/investigate-sentry-issue/SKILL.md +138 -0
  16. package/plugin/pi-skills/respond-github/SKILL.md +98 -0
  17. package/plugin/pi-skills/review-offered-task/SKILL.md +45 -0
  18. package/plugin/pi-skills/review-pr/SKILL.md +261 -0
  19. package/plugin/pi-skills/start-leader/SKILL.md +121 -0
  20. package/plugin/pi-skills/start-worker/SKILL.md +60 -0
  21. package/plugin/pi-skills/swarm-chat/SKILL.md +82 -0
  22. package/plugin/pi-skills/todos/SKILL.md +66 -0
  23. package/plugin/pi-skills/work-on-task/SKILL.md +65 -0
  24. package/plugin/skills/artifacts/examples/approval-flow.ts +34 -0
  25. package/plugin/skills/artifacts/examples/hono-dashboard.ts +31 -0
  26. package/plugin/skills/artifacts/examples/multi-artifact.ts +20 -0
  27. package/plugin/skills/artifacts/examples/static-report.sh +17 -0
  28. package/plugin/skills/artifacts/skill.md +71 -0
  29. package/src/agentmail/app.ts +65 -0
  30. package/src/agentmail/handlers.ts +262 -0
  31. package/src/agentmail/index.ts +9 -0
  32. package/src/agentmail/templates.ts +111 -0
  33. package/src/agentmail/types.ts +51 -0
  34. package/src/artifact-sdk/browser-sdk.ts +30 -0
  35. package/src/artifact-sdk/index.ts +2 -0
  36. package/src/artifact-sdk/localtunnel.d.ts +20 -0
  37. package/src/artifact-sdk/port.ts +12 -0
  38. package/src/artifact-sdk/server.ts +156 -0
  39. package/src/artifact-sdk/tunnel.ts +19 -0
  40. package/src/be/chunking.ts +193 -0
  41. package/src/be/db-queries/oauth.ts +90 -0
  42. package/src/be/db-queries/tracker.ts +182 -0
  43. package/src/be/db.ts +3327 -784
  44. package/src/be/embedding.ts +80 -0
  45. package/src/be/migrations/001_initial.sql +409 -0
  46. package/src/be/migrations/002_one_time_schedules.sql +59 -0
  47. package/src/be/migrations/003_workflows.sql +51 -0
  48. package/src/be/migrations/004_workflow_source.sql +81 -0
  49. package/src/be/migrations/005_epic_next_steps.sql +2 -0
  50. package/src/be/migrations/006_vcs_provider.sql +94 -0
  51. package/src/be/migrations/007_task_dir.sql +2 -0
  52. package/src/be/migrations/008_workflow_redesign.sql +85 -0
  53. package/src/be/migrations/009_tracker_integration.sql +144 -0
  54. package/src/be/migrations/010_step_diagnostics.sql +1 -0
  55. package/src/be/migrations/011_step_next_port.sql +1 -0
  56. package/src/be/migrations/012_trigger_schema.sql +1 -0
  57. package/src/be/migrations/013_task_output_schema.sql +2 -0
  58. package/src/be/migrations/014_prompt_templates.sql +33 -0
  59. package/src/be/migrations/015_workflow_workspace.sql +3 -0
  60. package/src/be/migrations/016_active_session_runner_session.sql +4 -0
  61. package/src/be/migrations/017_channel_activity_cursors.sql +6 -0
  62. package/src/be/migrations/018_fix_seed_double_version.sql +30 -0
  63. package/src/be/migrations/runner.ts +188 -0
  64. package/src/be/seed.ts +62 -0
  65. package/src/cli.tsx +231 -299
  66. package/src/commands/artifact.ts +241 -0
  67. package/src/commands/onboard/compose-generator.ts +169 -0
  68. package/src/commands/onboard/env-generator.ts +79 -0
  69. package/src/commands/onboard/manifest.ts +37 -0
  70. package/src/commands/onboard/presets.ts +85 -0
  71. package/src/commands/onboard/service-names.ts +47 -0
  72. package/src/commands/onboard/steps/core-credentials.tsx +111 -0
  73. package/src/commands/onboard/steps/custom-templates.tsx +168 -0
  74. package/src/commands/onboard/steps/generate.tsx +154 -0
  75. package/src/commands/onboard/steps/harness-credentials.tsx +195 -0
  76. package/src/commands/onboard/steps/harness.tsx +21 -0
  77. package/src/commands/onboard/steps/health-check.tsx +171 -0
  78. package/src/commands/onboard/steps/integration-github.tsx +105 -0
  79. package/src/commands/onboard/steps/integration-gitlab.tsx +79 -0
  80. package/src/commands/onboard/steps/integration-menu.tsx +58 -0
  81. package/src/commands/onboard/steps/integration-sentry.tsx +79 -0
  82. package/src/commands/onboard/steps/integration-slack.tsx +165 -0
  83. package/src/commands/onboard/steps/post-connect.tsx +145 -0
  84. package/src/commands/onboard/steps/post-dashboard.tsx +34 -0
  85. package/src/commands/onboard/steps/post-task.tsx +103 -0
  86. package/src/commands/onboard/steps/prereq-check.tsx +178 -0
  87. package/src/commands/onboard/steps/review.tsx +82 -0
  88. package/src/commands/onboard/steps/start.tsx +97 -0
  89. package/src/commands/onboard/templates.ts +34 -0
  90. package/src/commands/onboard/types.ts +259 -0
  91. package/src/commands/onboard.tsx +425 -0
  92. package/src/commands/runner.ts +1540 -630
  93. package/src/commands/setup.tsx +23 -38
  94. package/src/commands/shared/client-config.ts +41 -0
  95. package/src/commands/templates.ts +172 -0
  96. package/src/github/app.ts +8 -0
  97. package/src/github/handlers.ts +384 -151
  98. package/src/github/index.ts +1 -0
  99. package/src/github/mentions-aliases.test.ts +73 -0
  100. package/src/github/mentions.test.ts +3 -3
  101. package/src/github/mentions.ts +32 -6
  102. package/src/github/templates.ts +398 -0
  103. package/src/github/types.ts +1 -0
  104. package/src/gitlab/auth.ts +63 -0
  105. package/src/gitlab/handlers.ts +368 -0
  106. package/src/gitlab/index.ts +19 -0
  107. package/src/gitlab/reactions.ts +104 -0
  108. package/src/gitlab/templates.ts +140 -0
  109. package/src/gitlab/types.ts +130 -0
  110. package/src/heartbeat/heartbeat.ts +434 -0
  111. package/src/heartbeat/index.ts +1 -0
  112. package/src/heartbeat/templates.ts +30 -0
  113. package/src/hooks/hook.ts +555 -4
  114. package/src/hooks/tool-loop-detection.test.ts +158 -0
  115. package/src/hooks/tool-loop-detection.ts +167 -0
  116. package/src/http/active-sessions.ts +199 -0
  117. package/src/http/agents.ts +328 -0
  118. package/src/http/config.ts +191 -0
  119. package/src/http/core.ts +309 -0
  120. package/src/http/db-query.ts +91 -0
  121. package/src/http/ecosystem.ts +63 -0
  122. package/src/http/epics.ts +460 -0
  123. package/src/http/index.ts +216 -0
  124. package/src/http/mcp.ts +77 -0
  125. package/src/http/memory.ts +168 -0
  126. package/src/http/openapi.ts +109 -0
  127. package/src/http/poll.ts +299 -0
  128. package/src/http/prompt-templates.ts +412 -0
  129. package/src/http/repos.ts +195 -0
  130. package/src/http/route-def.ts +123 -0
  131. package/src/http/schedules.ts +426 -0
  132. package/src/http/session-data.ts +241 -0
  133. package/src/http/stats.ts +174 -0
  134. package/src/http/tasks.ts +468 -0
  135. package/src/http/trackers/index.ts +10 -0
  136. package/src/http/trackers/linear.ts +187 -0
  137. package/src/http/types.ts +12 -0
  138. package/src/http/utils.ts +87 -0
  139. package/src/http/webhooks.ts +432 -0
  140. package/src/http/workflows.ts +530 -0
  141. package/src/http.ts +1 -1890
  142. package/src/linear/README.md +65 -0
  143. package/src/linear/app.ts +48 -0
  144. package/src/linear/client.ts +18 -0
  145. package/src/linear/index.ts +1 -0
  146. package/src/linear/oauth.ts +35 -0
  147. package/src/linear/outbound.ts +212 -0
  148. package/src/linear/sync.ts +567 -0
  149. package/src/linear/templates.ts +47 -0
  150. package/src/linear/types.ts +7 -0
  151. package/src/linear/webhook.ts +104 -0
  152. package/src/oauth/README.md +66 -0
  153. package/src/oauth/index.ts +6 -0
  154. package/src/oauth/wrapper.ts +204 -0
  155. package/src/prompts/base-prompt.ts +150 -265
  156. package/src/prompts/defaults.ts +196 -0
  157. package/src/prompts/registry.ts +57 -0
  158. package/src/prompts/resolver.ts +296 -0
  159. package/src/prompts/session-templates.ts +604 -0
  160. package/src/providers/claude-adapter.ts +442 -0
  161. package/src/providers/index.ts +24 -0
  162. package/src/providers/pi-mono-adapter.ts +442 -0
  163. package/src/providers/pi-mono-extension.ts +624 -0
  164. package/src/providers/pi-mono-mcp-client.ts +124 -0
  165. package/src/providers/types.ts +75 -0
  166. package/src/scheduler/scheduler.test.ts +2 -0
  167. package/src/scheduler/scheduler.ts +231 -40
  168. package/src/server.ts +97 -6
  169. package/src/slack/HEURISTICS.md +105 -0
  170. package/src/slack/actions.ts +133 -0
  171. package/src/slack/app.ts +7 -0
  172. package/src/slack/assistant.ts +118 -0
  173. package/src/slack/blocks.ts +233 -0
  174. package/src/slack/channel-activity.ts +177 -0
  175. package/src/slack/commands.ts +31 -17
  176. package/src/slack/files.ts +1 -1
  177. package/src/slack/handlers.test.ts +114 -1
  178. package/src/slack/handlers.ts +230 -55
  179. package/src/slack/responses.ts +120 -67
  180. package/src/slack/router.ts +17 -99
  181. package/src/slack/templates.ts +55 -0
  182. package/src/slack/thread-buffer.ts +213 -0
  183. package/src/slack/watcher.ts +119 -4
  184. package/src/tests/agent-activity.test.ts +247 -0
  185. package/src/tests/agentmail-filters.test.ts +97 -0
  186. package/src/tests/artifact-sdk.test.ts +800 -0
  187. package/src/tests/base-prompt.test.ts +264 -0
  188. package/src/tests/build-pi-skills.test.ts +127 -0
  189. package/src/tests/channel-activity.test.ts +363 -0
  190. package/src/tests/claude-adapter.test.ts +126 -0
  191. package/src/tests/context-versioning.test.ts +425 -0
  192. package/src/tests/db-queries-oauth.test.ts +197 -0
  193. package/src/tests/db-queries-tracker.test.ts +230 -0
  194. package/src/tests/epics.test.ts +3 -3
  195. package/src/tests/error-tracker.test.ts +368 -0
  196. package/src/tests/fetch-resolved-env.test.ts +167 -0
  197. package/src/tests/generate-default-claude-md.test.ts +9 -1
  198. package/src/tests/generate-identity-templates.test.ts +124 -0
  199. package/src/tests/gitlab-auth.test.ts +109 -0
  200. package/src/tests/gitlab-handlers.test.ts +691 -0
  201. package/src/tests/gitlab-vcs-db.test.ts +177 -0
  202. package/src/tests/heartbeat.test.ts +364 -0
  203. package/src/tests/http-api-integration.test.ts +1698 -0
  204. package/src/tests/linear-outbound-sync.test.ts +200 -0
  205. package/src/tests/linear-webhook.test.ts +406 -0
  206. package/src/tests/match-route.test.ts +187 -0
  207. package/src/tests/memory.test.ts +737 -0
  208. package/src/tests/migration-runner-regressions.test.ts +86 -0
  209. package/src/tests/model-control.test.ts +338 -0
  210. package/src/tests/oauth-wrapper.test.ts +147 -0
  211. package/src/tests/onboard-compose.test.ts +138 -0
  212. package/src/tests/onboard-env.test.ts +174 -0
  213. package/src/tests/onboard-manifest.test.ts +137 -0
  214. package/src/tests/pi-mono-adapter.test.ts +234 -0
  215. package/src/tests/pool-session-logs.test.ts +199 -0
  216. package/src/tests/progress-dedup.test.ts +98 -0
  217. package/src/tests/prompt-template-github.test.ts +682 -0
  218. package/src/tests/prompt-template-remaining.test.ts +504 -0
  219. package/src/tests/prompt-template-resolver.test.ts +621 -0
  220. package/src/tests/prompt-template-session.test.ts +363 -0
  221. package/src/tests/prompt-templates-db.test.ts +616 -0
  222. package/src/tests/provider-adapter.test.ts +122 -0
  223. package/src/tests/provider-command-format.test.ts +98 -0
  224. package/src/tests/reload-config.test.ts +170 -0
  225. package/src/tests/runner-polling-api.test.ts +25 -20
  226. package/src/tests/scheduled-tasks.test.ts +104 -0
  227. package/src/tests/scheduler-backoff.test.ts +166 -0
  228. package/src/tests/self-improvement.test.ts +541 -0
  229. package/src/tests/session-attach.test.ts +536 -0
  230. package/src/tests/session-costs.test.ts +267 -1
  231. package/src/tests/slack-actions.test.ts +133 -0
  232. package/src/tests/slack-assistant.test.ts +136 -0
  233. package/src/tests/slack-blocks.test.ts +246 -0
  234. package/src/tests/slack-metadata-inheritance.test.ts +243 -0
  235. package/src/tests/slack-queue-offline.test.ts +174 -0
  236. package/src/tests/slack-router.test.ts +181 -0
  237. package/src/tests/slack-thread-buffer.test.ts +305 -0
  238. package/src/tests/slack-thread-followups.test.ts +298 -0
  239. package/src/tests/slack-watcher.test.ts +101 -0
  240. package/src/tests/structured-output.test.ts +307 -0
  241. package/src/tests/swarm-repos.test.ts +198 -0
  242. package/src/tests/task-cancellation.test.ts +6 -4
  243. package/src/tests/task-working-dir.test.ts +176 -0
  244. package/src/tests/template-fetch.test.ts +490 -0
  245. package/src/tests/tool-annotations.test.ts +371 -0
  246. package/src/tests/tracker-tools.test.ts +184 -0
  247. package/src/tests/update-profile-agentid.test.ts +248 -0
  248. package/src/tests/update-profile-api.test.ts +143 -3
  249. package/src/tests/update-profile-auth.test.ts +195 -0
  250. package/src/tests/validation-adapters.test.ts +86 -0
  251. package/src/tests/vcs-provider.test.ts +27 -0
  252. package/src/tests/workflow-agent-task.test.ts +196 -0
  253. package/src/tests/workflow-async-v2.test.ts +508 -0
  254. package/src/tests/workflow-convergence.test.ts +541 -0
  255. package/src/tests/workflow-definition-validation.test.ts +366 -0
  256. package/src/tests/workflow-engine-v2.test.ts +691 -0
  257. package/src/tests/workflow-executors.test.ts +736 -0
  258. package/src/tests/workflow-http-v2.test.ts +599 -0
  259. package/src/tests/workflow-integration-io.test.ts +902 -0
  260. package/src/tests/workflow-io-schemas.test.ts +624 -0
  261. package/src/tests/workflow-registry.test.ts +592 -0
  262. package/src/tests/workflow-retry-v2.test.ts +401 -0
  263. package/src/tests/workflow-retry-validation.test.ts +282 -0
  264. package/src/tests/workflow-schedule-trigger.test.ts +104 -0
  265. package/src/tests/workflow-template.test.ts +288 -0
  266. package/src/tests/workflow-trigger-schema.test.ts +359 -0
  267. package/src/tests/workflow-triggers-v2.test.ts +264 -0
  268. package/src/tests/workflow-versions.test.ts +208 -0
  269. package/src/tests/workflow-workspace.test.ts +272 -0
  270. package/src/tests/x402-client.test.ts +117 -0
  271. package/src/tests/x402-config.test.ts +182 -0
  272. package/src/tests/x402-spending-tracker.test.ts +185 -0
  273. package/src/tools/cancel-task.ts +2 -0
  274. package/src/tools/context-diff.ts +171 -0
  275. package/src/tools/context-history.ts +138 -0
  276. package/src/tools/create-channel.ts +1 -0
  277. package/src/tools/db-query.ts +78 -0
  278. package/src/tools/delete-channel.ts +132 -0
  279. package/src/tools/epics/assign-task-to-epic.ts +1 -0
  280. package/src/tools/epics/create-epic.ts +3 -2
  281. package/src/tools/epics/delete-epic.ts +2 -0
  282. package/src/tools/epics/get-epic-details.ts +2 -0
  283. package/src/tools/epics/list-epics.ts +2 -0
  284. package/src/tools/epics/unassign-task-from-epic.ts +1 -0
  285. package/src/tools/epics/update-epic.ts +7 -4
  286. package/src/tools/get-swarm.ts +2 -0
  287. package/src/tools/get-task-details.ts +2 -0
  288. package/src/tools/get-tasks.ts +27 -1
  289. package/src/tools/inject-learning.ts +106 -0
  290. package/src/tools/join-swarm.ts +17 -7
  291. package/src/tools/list-channels.ts +2 -0
  292. package/src/tools/list-services.ts +2 -0
  293. package/src/tools/memory-get.ts +56 -0
  294. package/src/tools/memory-search.ts +131 -0
  295. package/src/tools/my-agent-info.ts +2 -0
  296. package/src/tools/poll-task.ts +2 -20
  297. package/src/tools/post-message.ts +1 -0
  298. package/src/tools/prompt-templates/delete.ts +86 -0
  299. package/src/tools/prompt-templates/get.ts +89 -0
  300. package/src/tools/prompt-templates/index.ts +5 -0
  301. package/src/tools/prompt-templates/list.ts +95 -0
  302. package/src/tools/prompt-templates/preview.ts +84 -0
  303. package/src/tools/prompt-templates/set.ts +117 -0
  304. package/src/tools/read-messages.ts +2 -0
  305. package/src/tools/register-agentmail-inbox.ts +166 -0
  306. package/src/tools/register-service.ts +2 -0
  307. package/src/tools/schedules/create-schedule.ts +134 -24
  308. package/src/tools/schedules/delete-schedule.ts +2 -0
  309. package/src/tools/schedules/list-schedules.ts +20 -4
  310. package/src/tools/schedules/run-schedule-now.ts +1 -0
  311. package/src/tools/schedules/update-schedule.ts +49 -17
  312. package/src/tools/send-task.ts +132 -10
  313. package/src/tools/slack-download-file.ts +4 -2
  314. package/src/tools/slack-list-channels.ts +2 -0
  315. package/src/tools/slack-post.ts +2 -0
  316. package/src/tools/slack-read.ts +2 -0
  317. package/src/tools/slack-reply.ts +2 -0
  318. package/src/tools/slack-upload-file.ts +2 -0
  319. package/src/tools/store-progress.ts +205 -4
  320. package/src/tools/swarm-config/delete-config.ts +87 -0
  321. package/src/tools/swarm-config/get-config.ts +108 -0
  322. package/src/tools/swarm-config/index.ts +4 -0
  323. package/src/tools/swarm-config/list-config.ts +99 -0
  324. package/src/tools/swarm-config/set-config.ts +118 -0
  325. package/src/tools/task-action.ts +50 -5
  326. package/src/tools/task-dedup.ts +97 -0
  327. package/src/tools/templates.ts +53 -0
  328. package/src/tools/tool-config.ts +124 -0
  329. package/src/tools/tracker/index.ts +6 -0
  330. package/src/tools/tracker/tracker-link-epic.ts +64 -0
  331. package/src/tools/tracker/tracker-link-task.ts +64 -0
  332. package/src/tools/tracker/tracker-map-agent.ts +57 -0
  333. package/src/tools/tracker/tracker-status.ts +56 -0
  334. package/src/tools/tracker/tracker-sync-status.ts +42 -0
  335. package/src/tools/tracker/tracker-unlink.ts +41 -0
  336. package/src/tools/unregister-service.ts +2 -0
  337. package/src/tools/update-profile.ts +172 -17
  338. package/src/tools/update-service-status.ts +2 -0
  339. package/src/tools/utils.ts +10 -1
  340. package/src/tools/workflows/create-workflow.ts +129 -0
  341. package/src/tools/workflows/delete-workflow.ts +42 -0
  342. package/src/tools/workflows/get-workflow-run.ts +59 -0
  343. package/src/tools/workflows/get-workflow.ts +53 -0
  344. package/src/tools/workflows/index.ts +9 -0
  345. package/src/tools/workflows/list-workflow-runs.ts +48 -0
  346. package/src/tools/workflows/list-workflows.ts +42 -0
  347. package/src/tools/workflows/retry-workflow-run.ts +40 -0
  348. package/src/tools/workflows/trigger-workflow.ts +96 -0
  349. package/src/tools/workflows/update-workflow.ts +133 -0
  350. package/src/tracker/types.ts +51 -0
  351. package/src/types.ts +530 -14
  352. package/src/utils/credentials.test.ts +156 -0
  353. package/src/utils/credentials.ts +50 -0
  354. package/src/utils/error-tracker.ts +190 -0
  355. package/src/vcs/index.ts +15 -0
  356. package/src/vcs/types.ts +5 -0
  357. package/src/workflows/checkpoint.ts +121 -0
  358. package/src/workflows/cooldown.ts +28 -0
  359. package/src/workflows/definition.ts +235 -0
  360. package/src/workflows/engine.ts +580 -0
  361. package/src/workflows/event-bus.ts +29 -0
  362. package/src/workflows/executors/agent-task.ts +103 -0
  363. package/src/workflows/executors/base.ts +86 -0
  364. package/src/workflows/executors/code-match.ts +88 -0
  365. package/src/workflows/executors/index.ts +16 -0
  366. package/src/workflows/executors/notify.ts +93 -0
  367. package/src/workflows/executors/property-match.ts +104 -0
  368. package/src/workflows/executors/raw-llm.ts +83 -0
  369. package/src/workflows/executors/registry.ts +76 -0
  370. package/src/workflows/executors/script.ts +103 -0
  371. package/src/workflows/executors/validate.ts +215 -0
  372. package/src/workflows/executors/vcs.ts +58 -0
  373. package/src/workflows/index.ts +61 -0
  374. package/src/workflows/input.ts +46 -0
  375. package/src/workflows/json-schema-validator.ts +118 -0
  376. package/src/workflows/recovery.ts +139 -0
  377. package/src/workflows/resume.ts +229 -0
  378. package/src/workflows/retry-poller.ts +216 -0
  379. package/src/workflows/template.ts +74 -0
  380. package/src/workflows/templates.ts +86 -0
  381. package/src/workflows/triggers.ts +124 -0
  382. package/src/workflows/validation.ts +104 -0
  383. package/src/workflows/version.ts +44 -0
  384. package/src/x402/cli.ts +140 -0
  385. package/src/x402/client.ts +192 -0
  386. package/src/x402/config.ts +131 -0
  387. package/src/x402/index.ts +37 -0
  388. package/src/x402/openfort-signer.ts +83 -0
  389. package/src/x402/spending-tracker.ts +109 -0
  390. package/templates/official/coder/CLAUDE.md +49 -0
  391. package/templates/official/coder/IDENTITY.md +28 -0
  392. package/templates/official/coder/SOUL.md +43 -0
  393. package/templates/official/coder/TOOLS.md +40 -0
  394. package/templates/official/coder/config.json +23 -0
  395. package/templates/official/coder/start-up.sh +23 -0
  396. package/templates/official/content-reviewer/CLAUDE.md +68 -0
  397. package/templates/official/content-reviewer/IDENTITY.md +28 -0
  398. package/templates/official/content-reviewer/SOUL.md +44 -0
  399. package/templates/official/content-reviewer/TOOLS.md +37 -0
  400. package/templates/official/content-reviewer/config.json +23 -0
  401. package/templates/official/content-reviewer/start-up.sh +23 -0
  402. package/templates/official/content-strategist/CLAUDE.md +63 -0
  403. package/templates/official/content-strategist/IDENTITY.md +33 -0
  404. package/templates/official/content-strategist/SOUL.md +48 -0
  405. package/templates/official/content-strategist/TOOLS.md +47 -0
  406. package/templates/official/content-strategist/config.json +23 -0
  407. package/templates/official/content-strategist/start-up.sh +23 -0
  408. package/templates/official/content-writer/CLAUDE.md +72 -0
  409. package/templates/official/content-writer/IDENTITY.md +30 -0
  410. package/templates/official/content-writer/SOUL.md +46 -0
  411. package/templates/official/content-writer/TOOLS.md +44 -0
  412. package/templates/official/content-writer/config.json +23 -0
  413. package/templates/official/content-writer/start-up.sh +23 -0
  414. package/templates/official/forward-deployed-engineer/CLAUDE.md +54 -0
  415. package/templates/official/forward-deployed-engineer/IDENTITY.md +37 -0
  416. package/templates/official/forward-deployed-engineer/SOUL.md +55 -0
  417. package/templates/official/forward-deployed-engineer/config.json +21 -0
  418. package/templates/official/lead/CLAUDE.md +33 -0
  419. package/templates/official/lead/IDENTITY.md +36 -0
  420. package/templates/official/lead/SOUL.md +51 -0
  421. package/templates/official/lead/config.json +22 -0
  422. package/templates/official/researcher/CLAUDE.md +46 -0
  423. package/templates/official/researcher/IDENTITY.md +28 -0
  424. package/templates/official/researcher/SOUL.md +43 -0
  425. package/templates/official/researcher/config.json +21 -0
  426. package/templates/official/reviewer/CLAUDE.md +63 -0
  427. package/templates/official/reviewer/IDENTITY.md +28 -0
  428. package/templates/official/reviewer/SOUL.md +45 -0
  429. package/templates/official/reviewer/config.json +21 -0
  430. package/templates/official/tester/CLAUDE.md +53 -0
  431. package/templates/official/tester/IDENTITY.md +28 -0
  432. package/templates/official/tester/SOUL.md +55 -0
  433. package/templates/official/tester/config.json +21 -0
  434. package/templates/schema.ts +35 -0
  435. package/.claude/settings.local.json +0 -115
  436. package/.dockerignore +0 -61
  437. package/.editorconfig +0 -15
  438. package/.env.docker.example +0 -39
  439. package/.env.example +0 -40
  440. package/.github/workflows/ci.yml +0 -76
  441. package/.github/workflows/docker-and-deploy.yml +0 -117
  442. package/.wts-config.json +0 -4
  443. package/.wts-setup.ts +0 -102
  444. package/CLAUDE.md +0 -104
  445. package/CONTRIBUTING.md +0 -270
  446. package/DEPLOYMENT.md +0 -605
  447. package/Dockerfile +0 -57
  448. package/Dockerfile.worker +0 -157
  449. package/FAQ.md +0 -19
  450. package/MCP.md +0 -406
  451. package/UI.md +0 -40
  452. package/assets/agent-swarm-logo-orange.png +0 -0
  453. package/assets/agent-swarm-logo.png +0 -0
  454. package/assets/agent-swarm.mp4 +0 -0
  455. package/assets/agent-swarm.png +0 -0
  456. package/biome.json +0 -39
  457. package/deploy/DEPLOY.md +0 -60
  458. package/deploy/agent-swarm.service +0 -17
  459. package/deploy/docker-push.ts +0 -30
  460. package/deploy/install.ts +0 -85
  461. package/deploy/prod-db.ts +0 -42
  462. package/deploy/uninstall.ts +0 -12
  463. package/deploy/update.ts +0 -21
  464. package/docker-compose.example.yml +0 -159
  465. package/docker-entrypoint.sh +0 -352
  466. package/ecosystem.config.cjs +0 -66
  467. package/plugin/README.md +0 -1
  468. package/plugin/hooks/hooks.json +0 -71
  469. package/pyproject.toml +0 -9
  470. package/scripts/generate-mcp-docs.ts +0 -415
  471. package/slack-manifest.json +0 -71
  472. package/src/tests/get-inbox-message.test.ts +0 -145
  473. package/src/tools/get-inbox-message.ts +0 -89
  474. package/src/tools/inbox-delegate.ts +0 -113
  475. package/thoughts/shared/plans/2025-12-18-slack-integration.md +0 -1195
  476. package/thoughts/shared/plans/2025-12-19-agent-log-streaming.md +0 -732
  477. package/thoughts/shared/plans/2025-12-19-role-based-swarm-plugin.md +0 -361
  478. package/thoughts/shared/plans/2025-12-20-mobile-responsive-ui.md +0 -501
  479. package/thoughts/shared/plans/2025-12-20-startup-team-swarm.md +0 -560
  480. package/thoughts/shared/plans/2025-12-23-runner-level-polling.md +0 -934
  481. package/thoughts/shared/plans/2025-12-23-runner-session-logs.md +0 -1000
  482. package/thoughts/shared/plans/2025-12-23-worker-lead-spawn-triggers.md +0 -568
  483. package/thoughts/shared/plans/2026-01-09-inverse-teleport.md +0 -1516
  484. package/thoughts/shared/plans/2026-01-12-agent-rename-pm2-control.md +0 -1133
  485. package/thoughts/shared/plans/2026-01-12-github-app-integration.md +0 -380
  486. package/thoughts/shared/plans/2026-01-12-lead-inbox-model.md +0 -876
  487. package/thoughts/shared/plans/2026-01-12-ralph-wiggum-integration.md +0 -463
  488. package/thoughts/shared/plans/2026-01-13-agent-concurrency.md +0 -691
  489. package/thoughts/shared/plans/2026-01-13-github-assignment-handling.md +0 -690
  490. package/thoughts/shared/plans/2026-01-13-prevent-duplicate-trigger-processing.md +0 -1071
  491. package/thoughts/shared/plans/2026-01-14-fix-slack-thread-context.md +0 -507
  492. package/thoughts/shared/plans/2026-01-15-scheduled-tasks-implementation.md +0 -565
  493. package/thoughts/shared/plans/2026-01-15-usage-cost-tracking-ui.md +0 -1479
  494. package/thoughts/shared/plans/2026-01-16-epics-feature-implementation.md +0 -1230
  495. package/thoughts/shared/research/.gitkeep +0 -0
  496. package/thoughts/shared/research/2025-01-09-inverse-teleport-plan-review.md +0 -420
  497. package/thoughts/shared/research/2025-12-18-slack-integration.md +0 -442
  498. package/thoughts/shared/research/2025-12-19-agent-log-streaming.md +0 -339
  499. package/thoughts/shared/research/2025-12-19-agent-secrets-cli-research.md +0 -390
  500. package/thoughts/shared/research/2025-12-21-gemini-cli-integration.md +0 -376
  501. package/thoughts/shared/research/2025-12-22-runner-loop-architecture.md +0 -582
  502. package/thoughts/shared/research/2025-12-22-setup-experience-improvements.md +0 -264
  503. package/thoughts/shared/research/2026-01-13-lead-duplicate-trigger-processing.md +0 -223
  504. package/thoughts/shared/research/2026-01-14-lead-slack-thread-context.md +0 -277
  505. package/thoughts/shared/research/2026-01-15-ai-tracker-agent-swarm-integration.md +0 -376
  506. package/thoughts/shared/research/2026-01-15-auto-starting-processes-in-worker-containers.md +0 -787
  507. package/thoughts/shared/research/2026-01-15-scheduled-tasks.md +0 -390
  508. package/thoughts/shared/research/2026-01-16-epics-feature-research.md +0 -437
  509. package/thoughts/taras/plans/2026-01-22-agent-swarm-schemas.md +0 -98
  510. package/thoughts/taras/plans/2026-01-28-per-worker-claude-md.md +0 -617
  511. package/thoughts/taras/plans/2026-01-28-sentry-cli-integration.md +0 -214
  512. package/thoughts/taras/research/2026-01-22-vercel-cli-integration.md +0 -287
  513. package/thoughts/taras/research/2026-01-27-excessive-polling-issue.md +0 -311
  514. package/thoughts/taras/research/2026-01-28-per-worker-claude-md.md +0 -383
  515. package/thoughts/taras/research/2026-01-28-sentry-cli-integration.md +0 -240
  516. package/tsconfig.json +0 -37
  517. package/ui/CLAUDE.md +0 -49
  518. package/ui/bun.lock +0 -771
  519. package/ui/index.html +0 -22
  520. package/ui/package-lock.json +0 -5290
  521. package/ui/package.json +0 -33
  522. package/ui/pnpm-lock.yaml +0 -3341
  523. package/ui/postcss.config.js +0 -6
  524. package/ui/public/logo.png +0 -0
  525. package/ui/src/App.tsx +0 -63
  526. package/ui/src/components/ActivityFeed.tsx +0 -440
  527. package/ui/src/components/AgentDetailPanel.tsx +0 -733
  528. package/ui/src/components/AgentsPanel.tsx +0 -815
  529. package/ui/src/components/ChatPanel.tsx +0 -1920
  530. package/ui/src/components/ConfigModal.tsx +0 -253
  531. package/ui/src/components/Dashboard.tsx +0 -832
  532. package/ui/src/components/EditAgentProfileModal.tsx +0 -433
  533. package/ui/src/components/EpicDetailPage.tsx +0 -741
  534. package/ui/src/components/EpicsPanel.tsx +0 -566
  535. package/ui/src/components/Header.tsx +0 -160
  536. package/ui/src/components/JsonViewer.tsx +0 -171
  537. package/ui/src/components/ScheduledTaskDetailPanel.tsx +0 -517
  538. package/ui/src/components/ScheduledTasksPanel.tsx +0 -639
  539. package/ui/src/components/ServicesPanel.tsx +0 -622
  540. package/ui/src/components/SessionLogPanel.tsx +0 -1219
  541. package/ui/src/components/StatsBar.tsx +0 -321
  542. package/ui/src/components/StatusBadge.tsx +0 -168
  543. package/ui/src/components/TaskDetailPanel.tsx +0 -903
  544. package/ui/src/components/TasksPanel.tsx +0 -614
  545. package/ui/src/components/UsageCharts.tsx +0 -216
  546. package/ui/src/components/UsageTab.tsx +0 -394
  547. package/ui/src/hooks/queries.ts +0 -353
  548. package/ui/src/hooks/useAutoScroll.ts +0 -83
  549. package/ui/src/index.css +0 -257
  550. package/ui/src/lib/api.ts +0 -268
  551. package/ui/src/lib/config.ts +0 -35
  552. package/ui/src/lib/contentPreview.ts +0 -208
  553. package/ui/src/lib/theme.ts +0 -214
  554. package/ui/src/lib/utils.ts +0 -88
  555. package/ui/src/main.tsx +0 -28
  556. package/ui/src/types/api.ts +0 -323
  557. package/ui/src/vite-env.d.ts +0 -1
  558. package/ui/tailwind.config.js +0 -37
  559. package/ui/tsconfig.json +0 -31
  560. package/ui/vite.config.ts +0 -35
  561. /package/{thoughts/shared/plans → templates/community}/.gitkeep +0 -0
package/src/be/db.ts CHANGED
@@ -1,27 +1,53 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { configureDbResolver } from "../prompts/resolver";
2
3
  import type {
4
+ ActiveSession,
3
5
  Agent,
4
6
  AgentLog,
5
7
  AgentLogEventType,
8
+ AgentMemory,
9
+ AgentMemoryScope,
10
+ AgentMemorySource,
6
11
  AgentStatus,
7
12
  AgentTask,
8
13
  AgentTaskSource,
9
14
  AgentTaskStatus,
10
15
  AgentWithTasks,
16
+ ChangeSource,
11
17
  Channel,
12
18
  ChannelMessage,
13
19
  ChannelType,
20
+ ContextVersion,
21
+ CooldownConfig,
14
22
  Epic,
15
23
  EpicStatus,
16
24
  EpicWithProgress,
17
25
  InboxMessage,
18
26
  InboxMessageStatus,
27
+ InputValue,
28
+ PromptTemplate,
29
+ PromptTemplateHistory,
19
30
  ScheduledTask,
20
31
  Service,
21
32
  ServiceStatus,
22
33
  SessionCost,
23
34
  SessionLog,
35
+ SwarmConfig,
36
+ SwarmRepo,
37
+ TriggerConfig,
38
+ VersionableField,
39
+ VersionMeta,
40
+ Workflow,
41
+ WorkflowDefinition,
42
+ WorkflowRun,
43
+ WorkflowRunStatus,
44
+ WorkflowRunStep,
45
+ WorkflowRunStepStatus,
46
+ WorkflowSnapshot,
47
+ WorkflowVersion,
24
48
  } from "../types";
49
+ import { runMigrations } from "./migrations/runner";
50
+ import { seedDefaultTemplates } from "./seed";
25
51
 
26
52
  let db: Database | null = null;
27
53
 
@@ -33,613 +59,141 @@ export function initDb(dbPath = "./agent-swarm-db.sqlite"): Database {
33
59
  db = new Database(dbPath, { create: true });
34
60
  console.log(`Database initialized at ${dbPath}`);
35
61
 
36
- // Capture in local const for TypeScript (db is guaranteed non-null here)
37
62
  const database = db;
38
-
39
63
  database.run("PRAGMA journal_mode = WAL;");
40
64
  database.run("PRAGMA foreign_keys = ON;");
41
65
 
42
- // Schema initialization - wrapped in transaction for atomicity
43
- // Individual statements ensure compatibility with older Bun versions (< 1.0.26)
44
- // that don't support multi-statement queries
45
- const initSchema = database.transaction(() => {
46
- // Tables
47
- database.run(`
48
- CREATE TABLE IF NOT EXISTS agents (
49
- id TEXT PRIMARY KEY,
50
- name TEXT NOT NULL,
51
- isLead INTEGER NOT NULL DEFAULT 0,
52
- status TEXT NOT NULL CHECK(status IN ('idle', 'busy', 'offline')),
53
- description TEXT,
54
- role TEXT,
55
- capabilities TEXT DEFAULT '[]',
56
- createdAt TEXT NOT NULL,
57
- lastUpdatedAt TEXT NOT NULL
58
- )
59
- `);
60
-
61
- database.run(`
62
- CREATE TABLE IF NOT EXISTS agent_tasks (
63
- id TEXT PRIMARY KEY,
64
- agentId TEXT,
65
- creatorAgentId TEXT,
66
- task TEXT NOT NULL,
67
- status TEXT NOT NULL DEFAULT 'pending',
68
- source TEXT NOT NULL DEFAULT 'mcp',
69
- taskType TEXT,
70
- tags TEXT DEFAULT '[]',
71
- priority INTEGER DEFAULT 50,
72
- dependsOn TEXT DEFAULT '[]',
73
- offeredTo TEXT,
74
- offeredAt TEXT,
75
- acceptedAt TEXT,
76
- rejectionReason TEXT,
77
- slackChannelId TEXT,
78
- slackThreadTs TEXT,
79
- slackUserId TEXT,
80
- createdAt TEXT NOT NULL,
81
- lastUpdatedAt TEXT NOT NULL,
82
- finishedAt TEXT,
83
- failureReason TEXT,
84
- output TEXT,
85
- progress TEXT,
86
- notifiedAt TEXT
87
- )
88
- `);
89
-
90
- database.run(`
91
- CREATE TABLE IF NOT EXISTS agent_log (
92
- id TEXT PRIMARY KEY,
93
- eventType TEXT NOT NULL,
94
- agentId TEXT,
95
- taskId TEXT,
96
- oldValue TEXT,
97
- newValue TEXT,
98
- metadata TEXT,
99
- createdAt TEXT NOT NULL
100
- )
101
- `);
102
-
103
- database.run(`
104
- CREATE TABLE IF NOT EXISTS channels (
105
- id TEXT PRIMARY KEY,
106
- name TEXT NOT NULL UNIQUE,
107
- description TEXT,
108
- type TEXT NOT NULL DEFAULT 'public' CHECK(type IN ('public', 'dm')),
109
- createdBy TEXT,
110
- participants TEXT DEFAULT '[]',
111
- createdAt TEXT NOT NULL,
112
- FOREIGN KEY (createdBy) REFERENCES agents(id) ON DELETE SET NULL
113
- )
114
- `);
115
-
116
- database.run(`
117
- CREATE TABLE IF NOT EXISTS channel_messages (
118
- id TEXT PRIMARY KEY,
119
- channelId TEXT NOT NULL,
120
- agentId TEXT,
121
- content TEXT NOT NULL,
122
- replyToId TEXT,
123
- mentions TEXT DEFAULT '[]',
124
- createdAt TEXT NOT NULL,
125
- FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE,
126
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
127
- FOREIGN KEY (replyToId) REFERENCES channel_messages(id) ON DELETE SET NULL
128
- )
129
- `);
130
-
131
- database.run(`
132
- CREATE TABLE IF NOT EXISTS channel_read_state (
133
- agentId TEXT NOT NULL,
134
- channelId TEXT NOT NULL,
135
- lastReadAt TEXT NOT NULL,
136
- processing_since TEXT,
137
- PRIMARY KEY (agentId, channelId),
138
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
139
- FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE CASCADE
140
- )
141
- `);
142
-
143
- database.run(`
144
- CREATE TABLE IF NOT EXISTS services (
145
- id TEXT PRIMARY KEY,
146
- agentId TEXT NOT NULL,
147
- name TEXT NOT NULL,
148
- port INTEGER NOT NULL DEFAULT 3000,
149
- description TEXT,
150
- url TEXT,
151
- healthCheckPath TEXT DEFAULT '/health',
152
- status TEXT NOT NULL DEFAULT 'starting' CHECK(status IN ('starting', 'healthy', 'unhealthy', 'stopped')),
153
- script TEXT NOT NULL DEFAULT '',
154
- cwd TEXT,
155
- interpreter TEXT,
156
- args TEXT,
157
- env TEXT,
158
- metadata TEXT DEFAULT '{}',
159
- createdAt TEXT NOT NULL,
160
- lastUpdatedAt TEXT NOT NULL,
161
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
162
- UNIQUE(agentId, name)
163
- )
164
- `);
165
-
166
- database.run(`
167
- CREATE TABLE IF NOT EXISTS session_logs (
168
- id TEXT PRIMARY KEY,
169
- taskId TEXT,
170
- sessionId TEXT NOT NULL,
171
- iteration INTEGER NOT NULL,
172
- cli TEXT NOT NULL DEFAULT 'claude',
173
- content TEXT NOT NULL,
174
- lineNumber INTEGER NOT NULL,
175
- createdAt TEXT NOT NULL
176
- )
177
- `);
178
-
179
- database.run(`
180
- CREATE TABLE IF NOT EXISTS session_costs (
181
- id TEXT PRIMARY KEY,
182
- sessionId TEXT NOT NULL,
183
- taskId TEXT,
184
- agentId TEXT NOT NULL,
185
- totalCostUsd REAL NOT NULL,
186
- inputTokens INTEGER NOT NULL DEFAULT 0,
187
- outputTokens INTEGER NOT NULL DEFAULT 0,
188
- cacheReadTokens INTEGER NOT NULL DEFAULT 0,
189
- cacheWriteTokens INTEGER NOT NULL DEFAULT 0,
190
- durationMs INTEGER NOT NULL,
191
- numTurns INTEGER NOT NULL,
192
- model TEXT NOT NULL,
193
- isError INTEGER NOT NULL DEFAULT 0,
194
- createdAt TEXT NOT NULL,
195
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
196
- FOREIGN KEY (taskId) REFERENCES agent_tasks(id) ON DELETE SET NULL
197
- )
198
- `);
199
-
200
- database.run(`
201
- CREATE TABLE IF NOT EXISTS inbox_messages (
202
- id TEXT PRIMARY KEY,
203
- agentId TEXT NOT NULL,
204
- content TEXT NOT NULL,
205
- source TEXT NOT NULL DEFAULT 'slack',
206
- status TEXT NOT NULL DEFAULT 'unread' CHECK(status IN ('unread', 'processing', 'read', 'responded', 'delegated')),
207
- slackChannelId TEXT,
208
- slackThreadTs TEXT,
209
- slackUserId TEXT,
210
- matchedText TEXT,
211
- delegatedToTaskId TEXT,
212
- responseText TEXT,
213
- createdAt TEXT NOT NULL,
214
- lastUpdatedAt TEXT NOT NULL,
215
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
216
- FOREIGN KEY (delegatedToTaskId) REFERENCES agent_tasks(id) ON DELETE SET NULL
217
- )
218
- `);
219
-
220
- // Indexes
221
- database.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentId ON agent_tasks(agentId)`);
222
- database.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status)`);
223
- database.run(`CREATE INDEX IF NOT EXISTS idx_agent_log_agentId ON agent_log(agentId)`);
224
- database.run(`CREATE INDEX IF NOT EXISTS idx_agent_log_taskId ON agent_log(taskId)`);
225
- database.run(`CREATE INDEX IF NOT EXISTS idx_agent_log_eventType ON agent_log(eventType)`);
226
- database.run(`CREATE INDEX IF NOT EXISTS idx_agent_log_createdAt ON agent_log(createdAt)`);
227
- database.run(
228
- `CREATE INDEX IF NOT EXISTS idx_channel_messages_channelId ON channel_messages(channelId)`,
229
- );
230
- database.run(
231
- `CREATE INDEX IF NOT EXISTS idx_channel_messages_agentId ON channel_messages(agentId)`,
232
- );
233
- database.run(
234
- `CREATE INDEX IF NOT EXISTS idx_channel_messages_createdAt ON channel_messages(createdAt)`,
235
- );
236
- database.run(`CREATE INDEX IF NOT EXISTS idx_services_agentId ON services(agentId)`);
237
- database.run(`CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)`);
238
- database.run(`CREATE INDEX IF NOT EXISTS idx_session_logs_taskId ON session_logs(taskId)`);
239
- database.run(
240
- `CREATE INDEX IF NOT EXISTS idx_session_logs_sessionId ON session_logs(sessionId)`,
241
- );
242
- // Session costs indexes for timeseries queries
243
- database.run(
244
- `CREATE INDEX IF NOT EXISTS idx_session_costs_createdAt ON session_costs(createdAt)`,
245
- );
246
- database.run(`CREATE INDEX IF NOT EXISTS idx_session_costs_taskId ON session_costs(taskId)`);
247
- database.run(`CREATE INDEX IF NOT EXISTS idx_session_costs_agentId ON session_costs(agentId)`);
248
- database.run(
249
- `CREATE INDEX IF NOT EXISTS idx_session_costs_agent_createdAt ON session_costs(agentId, createdAt)`,
250
- );
251
- database.run(
252
- `CREATE INDEX IF NOT EXISTS idx_inbox_messages_agentId ON inbox_messages(agentId)`,
253
- );
254
- database.run(`CREATE INDEX IF NOT EXISTS idx_inbox_messages_status ON inbox_messages(status)`);
255
-
256
- // Scheduled tasks table
257
- database.run(`
258
- CREATE TABLE IF NOT EXISTS scheduled_tasks (
259
- id TEXT PRIMARY KEY,
260
- name TEXT NOT NULL UNIQUE,
261
- description TEXT,
262
- cronExpression TEXT,
263
- intervalMs INTEGER,
264
- taskTemplate TEXT NOT NULL,
265
- taskType TEXT,
266
- tags TEXT DEFAULT '[]',
267
- priority INTEGER DEFAULT 50,
268
- targetAgentId TEXT,
269
- enabled INTEGER DEFAULT 1,
270
- lastRunAt TEXT,
271
- nextRunAt TEXT,
272
- createdByAgentId TEXT,
273
- timezone TEXT DEFAULT 'UTC',
274
- createdAt TEXT NOT NULL,
275
- lastUpdatedAt TEXT NOT NULL,
276
- CHECK (cronExpression IS NOT NULL OR intervalMs IS NOT NULL)
277
- )
278
- `);
279
-
280
- // Scheduled tasks indexes
281
- database.run(
282
- `CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_enabled ON scheduled_tasks(enabled)`,
283
- );
284
- database.run(
285
- `CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_nextRunAt ON scheduled_tasks(nextRunAt)`,
286
- );
287
-
288
- // Epics table - project-level task organization
289
- database.run(`
290
- CREATE TABLE IF NOT EXISTS epics (
291
- id TEXT PRIMARY KEY,
292
- name TEXT NOT NULL UNIQUE,
293
- description TEXT,
294
- goal TEXT NOT NULL,
295
- prd TEXT,
296
- plan TEXT,
297
- status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'active', 'paused', 'completed', 'cancelled')),
298
- priority INTEGER DEFAULT 50,
299
- tags TEXT DEFAULT '[]',
300
- createdByAgentId TEXT,
301
- leadAgentId TEXT,
302
- channelId TEXT,
303
- researchDocPath TEXT,
304
- planDocPath TEXT,
305
- slackChannelId TEXT,
306
- slackThreadTs TEXT,
307
- githubRepo TEXT,
308
- githubMilestone TEXT,
309
- createdAt TEXT NOT NULL,
310
- lastUpdatedAt TEXT NOT NULL,
311
- startedAt TEXT,
312
- completedAt TEXT,
313
- FOREIGN KEY (createdByAgentId) REFERENCES agents(id) ON DELETE SET NULL,
314
- FOREIGN KEY (leadAgentId) REFERENCES agents(id) ON DELETE SET NULL,
315
- FOREIGN KEY (channelId) REFERENCES channels(id) ON DELETE SET NULL
316
- )
317
- `);
318
-
319
- // Epics indexes
320
- database.run(`CREATE INDEX IF NOT EXISTS idx_epics_status ON epics(status)`);
321
- database.run(
322
- `CREATE INDEX IF NOT EXISTS idx_epics_createdByAgentId ON epics(createdByAgentId)`,
323
- );
324
- database.run(`CREATE INDEX IF NOT EXISTS idx_epics_leadAgentId ON epics(leadAgentId)`);
325
- });
326
-
327
- initSchema();
328
-
329
- // Seed default general channel if it doesn't exist
330
- // Use a stable UUID for the general channel so it's consistent across restarts
331
- const generalChannelId = "00000000-0000-4000-8000-000000000001";
332
- try {
333
- // Migration: Fix old 'general' channel ID that wasn't a valid UUID
334
- db.run(`UPDATE channels SET id = ? WHERE id = 'general'`, [generalChannelId]);
335
- db.run(`UPDATE channel_messages SET channelId = ? WHERE channelId = 'general'`, [
336
- generalChannelId,
337
- ]);
338
- db.run(`UPDATE channel_read_state SET channelId = ? WHERE channelId = 'general'`, [
339
- generalChannelId,
340
- ]);
341
- } catch {
342
- /* Migration not needed or already applied */
343
- }
344
- try {
345
- db.run(
346
- `
347
- INSERT OR IGNORE INTO channels (id, name, description, type, createdAt)
348
- VALUES (?, 'general', 'Default channel for all agents', 'public', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
349
- `,
350
- [generalChannelId],
351
- );
352
- } catch {
353
- /* Channel already exists */
354
- }
355
-
356
- // Migration: Add new columns to existing databases (SQLite doesn't support IF NOT EXISTS for columns)
357
- // Agent task columns
358
- try {
359
- db.run(
360
- `ALTER TABLE agent_tasks ADD COLUMN source TEXT NOT NULL DEFAULT 'mcp' CHECK(source IN ('mcp', 'slack', 'api'))`,
361
- );
362
- } catch {
363
- /* exists */
364
- }
365
- try {
366
- db.run(`ALTER TABLE agent_tasks ADD COLUMN slackChannelId TEXT`);
367
- } catch {
368
- /* exists */
369
- }
370
- try {
371
- db.run(`ALTER TABLE agent_tasks ADD COLUMN slackThreadTs TEXT`);
372
- } catch {
373
- /* exists */
374
- }
375
- try {
376
- db.run(`ALTER TABLE agent_tasks ADD COLUMN slackUserId TEXT`);
377
- } catch {
378
- /* exists */
379
- }
380
- try {
381
- db.run(`ALTER TABLE agent_tasks ADD COLUMN taskType TEXT`);
382
- } catch {
383
- /* exists */
384
- }
385
- try {
386
- db.run(`ALTER TABLE agent_tasks ADD COLUMN tags TEXT DEFAULT '[]'`);
387
- } catch {
388
- /* exists */
389
- }
390
- try {
391
- db.run(`ALTER TABLE agent_tasks ADD COLUMN priority INTEGER DEFAULT 50`);
392
- } catch {
393
- /* exists */
394
- }
395
- try {
396
- db.run(`ALTER TABLE agent_tasks ADD COLUMN dependsOn TEXT DEFAULT '[]'`);
397
- } catch {
398
- /* exists */
399
- }
400
- try {
401
- db.run(`ALTER TABLE agent_tasks ADD COLUMN offeredTo TEXT`);
402
- } catch {
403
- /* exists */
404
- }
405
- try {
406
- db.run(`ALTER TABLE agent_tasks ADD COLUMN offeredAt TEXT`);
407
- } catch {
408
- /* exists */
409
- }
410
- try {
411
- db.run(`ALTER TABLE agent_tasks ADD COLUMN acceptedAt TEXT`);
412
- } catch {
413
- /* exists */
414
- }
415
- try {
416
- db.run(`ALTER TABLE agent_tasks ADD COLUMN rejectionReason TEXT`);
417
- } catch {
418
- /* exists */
419
- }
420
- try {
421
- db.run(`ALTER TABLE agent_tasks ADD COLUMN creatorAgentId TEXT`);
422
- } catch {
423
- /* exists */
424
- }
425
- // Mention-to-task columns
426
- try {
427
- db.run(`ALTER TABLE agent_tasks ADD COLUMN mentionMessageId TEXT`);
428
- } catch {
429
- /* exists */
430
- }
431
- try {
432
- db.run(`ALTER TABLE agent_tasks ADD COLUMN mentionChannelId TEXT`);
433
- } catch {
434
- /* exists */
435
- }
436
- // GitHub-specific columns
437
- try {
438
- db.run(`ALTER TABLE agent_tasks ADD COLUMN githubRepo TEXT`);
439
- } catch {
440
- /* exists */
441
- }
442
- try {
443
- db.run(`ALTER TABLE agent_tasks ADD COLUMN githubEventType TEXT`);
444
- } catch {
445
- /* exists */
446
- }
447
- try {
448
- db.run(`ALTER TABLE agent_tasks ADD COLUMN githubNumber INTEGER`);
449
- } catch {
450
- /* exists */
451
- }
452
- try {
453
- db.run(`ALTER TABLE agent_tasks ADD COLUMN githubCommentId INTEGER`);
454
- } catch {
455
- /* exists */
456
- }
457
- try {
458
- db.run(`ALTER TABLE agent_tasks ADD COLUMN githubAuthor TEXT`);
459
- } catch {
460
- /* exists */
461
- }
462
- try {
463
- db.run(`ALTER TABLE agent_tasks ADD COLUMN githubUrl TEXT`);
464
- } catch {
465
- /* exists */
466
- }
467
- // Agent profile columns
468
- try {
469
- db.run(`ALTER TABLE agents ADD COLUMN description TEXT`);
470
- } catch {
471
- /* exists */
472
- }
473
- try {
474
- db.run(`ALTER TABLE agents ADD COLUMN role TEXT`);
475
- } catch {
476
- /* exists */
477
- }
478
- try {
479
- db.run(`ALTER TABLE agents ADD COLUMN capabilities TEXT DEFAULT '[]'`);
480
- } catch {
481
- /* exists */
482
- }
483
- // Concurrency limit column
484
- try {
485
- db.run(`ALTER TABLE agents ADD COLUMN maxTasks INTEGER DEFAULT 1`);
486
- } catch {
487
- /* exists */
488
- }
489
-
490
- // Polling limit tracking column
491
- try {
492
- db.run(`ALTER TABLE agents ADD COLUMN emptyPollCount INTEGER DEFAULT 0`);
493
- } catch {
494
- /* exists */
495
- }
496
-
497
- // CLAUDE.md storage column
498
- try {
499
- db.run(`ALTER TABLE agents ADD COLUMN claudeMd TEXT`);
500
- } catch {
501
- /* exists */
502
- }
503
-
504
- // Service PM2 columns migration
505
- try {
506
- db.run(`ALTER TABLE services ADD COLUMN script TEXT NOT NULL DEFAULT ''`);
507
- } catch {
508
- /* exists */
509
- }
510
- try {
511
- db.run(`ALTER TABLE services ADD COLUMN cwd TEXT`);
512
- } catch {
513
- /* exists */
514
- }
515
- try {
516
- db.run(`ALTER TABLE services ADD COLUMN interpreter TEXT`);
517
- } catch {
518
- /* exists */
519
- }
520
- try {
521
- db.run(`ALTER TABLE services ADD COLUMN args TEXT`);
522
- } catch {
523
- /* exists */
524
- }
525
- try {
526
- db.run(`ALTER TABLE services ADD COLUMN env TEXT`);
527
- } catch {
528
- /* exists */
529
- }
530
-
531
- // Migration: Add processing_since column to channel_read_state for Phase 3
532
- try {
533
- db.run(`ALTER TABLE channel_read_state ADD COLUMN processing_since TEXT`);
534
- } catch {
535
- /* exists */
536
- }
66
+ // Run database migrations (schema creation + incremental changes)
67
+ runMigrations(database);
537
68
 
538
- // Migration: Add notifiedAt column to agent_tasks for Phase 4
539
- try {
540
- db.run(`ALTER TABLE agent_tasks ADD COLUMN notifiedAt TEXT`);
541
- } catch {
542
- /* exists */
543
- }
69
+ // Compatibility migration for legacy databases that predate profile fields
70
+ ensureAgentProfileColumns(database);
544
71
 
545
- // Migration: Update inbox_messages CHECK constraint to include 'processing' status
546
- // SQLite doesn't support ALTER TABLE MODIFY COLUMN, so we need to recreate the table
72
+ // Migration: Remove restrictive CHECK constraint on agent_tasks.status
73
+ // Old databases have CHECK(status IN ('pending','in_progress','completed','failed'))
74
+ // which blocks 'cancelled', 'paused', 'offered', 'unassigned' statuses
547
75
  try {
548
- // Check if the table schema already includes 'processing' in the CHECK constraint
549
- const schemaInfo = db
76
+ const taskSchemaInfo = db
550
77
  .prepare<{ sql: string | null }, []>(
551
- "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'inbox_messages'",
78
+ "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'agent_tasks'",
552
79
  )
553
80
  .get();
554
81
 
555
- const needsMigration = schemaInfo?.sql && !schemaInfo.sql.includes("'processing'");
82
+ const schemaSql = taskSchemaInfo?.sql ?? "";
83
+ const hasStatusCheck = /status\s+TEXT\b[^,]*\bCHECK\s*\(\s*status\s+IN\s*\(/i.test(schemaSql);
84
+ const statusAllowsCancelled = /status\s+IN\s*\([^)]*'cancelled'/i.test(schemaSql);
85
+ const needsStatusMigration = hasStatusCheck && !statusAllowsCancelled;
556
86
 
557
- if (needsMigration) {
558
- console.log(
559
- "[Migration] Updating inbox_messages CHECK constraint to include 'processing' status",
560
- );
87
+ if (needsStatusMigration) {
88
+ console.log("[Migration] Removing restrictive CHECK constraint on agent_tasks.status");
561
89
  db.run("PRAGMA foreign_keys=off");
562
90
 
563
91
  db.run(`
564
- CREATE TABLE inbox_messages_new (
92
+ CREATE TABLE agent_tasks_new (
565
93
  id TEXT PRIMARY KEY,
566
- agentId TEXT NOT NULL,
567
- content TEXT NOT NULL,
568
- source TEXT NOT NULL DEFAULT 'slack',
569
- status TEXT NOT NULL DEFAULT 'unread' CHECK(status IN ('unread', 'processing', 'read', 'responded', 'delegated')),
94
+ agentId TEXT,
95
+ creatorAgentId TEXT,
96
+ task TEXT NOT NULL,
97
+ status TEXT NOT NULL DEFAULT 'pending',
98
+ source TEXT NOT NULL DEFAULT 'mcp' CHECK(source IN ('mcp', 'slack', 'api', 'github', 'agentmail', 'system', 'schedule')),
99
+ taskType TEXT,
100
+ tags TEXT DEFAULT '[]',
101
+ priority INTEGER DEFAULT 50,
102
+ dependsOn TEXT DEFAULT '[]',
103
+ offeredTo TEXT,
104
+ offeredAt TEXT,
105
+ acceptedAt TEXT,
106
+ rejectionReason TEXT,
570
107
  slackChannelId TEXT,
571
108
  slackThreadTs TEXT,
572
109
  slackUserId TEXT,
573
- matchedText TEXT,
574
- delegatedToTaskId TEXT,
575
- responseText TEXT,
576
110
  createdAt TEXT NOT NULL,
577
111
  lastUpdatedAt TEXT NOT NULL,
578
- FOREIGN KEY (agentId) REFERENCES agents(id) ON DELETE CASCADE,
579
- FOREIGN KEY (delegatedToTaskId) REFERENCES agent_tasks(id) ON DELETE SET NULL
112
+ finishedAt TEXT,
113
+ failureReason TEXT,
114
+ output TEXT,
115
+ progress TEXT,
116
+ notifiedAt TEXT,
117
+ mentionMessageId TEXT,
118
+ mentionChannelId TEXT,
119
+ githubRepo TEXT,
120
+ githubEventType TEXT,
121
+ githubNumber INTEGER,
122
+ githubCommentId INTEGER,
123
+ githubAuthor TEXT,
124
+ githubUrl TEXT,
125
+ epicId TEXT REFERENCES epics(id) ON DELETE SET NULL,
126
+ parentTaskId TEXT,
127
+ claudeSessionId TEXT,
128
+ agentmailInboxId TEXT,
129
+ agentmailMessageId TEXT,
130
+ agentmailThreadId TEXT,
131
+ model TEXT,
132
+ scheduleId TEXT
580
133
  )
581
134
  `);
582
135
 
583
- db.run("INSERT INTO inbox_messages_new SELECT * FROM inbox_messages");
584
- db.run("DROP TABLE inbox_messages");
585
- db.run("ALTER TABLE inbox_messages_new RENAME TO inbox_messages");
136
+ // Copy all data use column list to handle any column ordering differences
137
+ db.run(`
138
+ INSERT INTO agent_tasks_new (
139
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
140
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
141
+ slackChannelId, slackThreadTs, slackUserId, createdAt, lastUpdatedAt,
142
+ finishedAt, failureReason, output, progress, notifiedAt,
143
+ mentionMessageId, mentionChannelId, githubRepo, githubEventType,
144
+ githubNumber, githubCommentId, githubAuthor, githubUrl,
145
+ epicId, parentTaskId, claudeSessionId,
146
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
147
+ model, scheduleId
148
+ )
149
+ SELECT
150
+ id, agentId, creatorAgentId, task, status, source, taskType, tags,
151
+ priority, dependsOn, offeredTo, offeredAt, acceptedAt, rejectionReason,
152
+ slackChannelId, slackThreadTs, slackUserId, createdAt, lastUpdatedAt,
153
+ finishedAt, failureReason, output, progress, notifiedAt,
154
+ mentionMessageId, mentionChannelId, githubRepo, githubEventType,
155
+ githubNumber, githubCommentId, githubAuthor, githubUrl,
156
+ epicId, parentTaskId, claudeSessionId,
157
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
158
+ model, scheduleId
159
+ FROM agent_tasks
160
+ `);
586
161
 
587
- // Recreate indexes
588
- db.run("CREATE INDEX IF NOT EXISTS idx_inbox_messages_agentId ON inbox_messages(agentId)");
589
- db.run("CREATE INDEX IF NOT EXISTS idx_inbox_messages_status ON inbox_messages(status)");
162
+ db.run("DROP TABLE agent_tasks");
163
+ db.run("ALTER TABLE agent_tasks_new RENAME TO agent_tasks");
164
+
165
+ // Recreate all indexes
166
+ db.run("CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentId ON agent_tasks(agentId)");
167
+ db.run("CREATE INDEX IF NOT EXISTS idx_agent_tasks_status ON agent_tasks(status)");
168
+ db.run("CREATE INDEX IF NOT EXISTS idx_agent_tasks_offeredTo ON agent_tasks(offeredTo)");
169
+ db.run("CREATE INDEX IF NOT EXISTS idx_agent_tasks_taskType ON agent_tasks(taskType)");
170
+ db.run("CREATE INDEX IF NOT EXISTS idx_agent_tasks_epicId ON agent_tasks(epicId)");
171
+ db.run(
172
+ "CREATE INDEX IF NOT EXISTS idx_agent_tasks_agentmailThreadId ON agent_tasks(agentmailThreadId)",
173
+ );
174
+ db.run("CREATE INDEX IF NOT EXISTS idx_agent_tasks_schedule_id ON agent_tasks(scheduleId)");
590
175
 
591
176
  db.run("PRAGMA foreign_keys=on");
592
- console.log("[Migration] Successfully updated inbox_messages table");
177
+ console.log("[Migration] Successfully removed CHECK constraint on agent_tasks.status");
593
178
  }
594
179
  } catch (e) {
595
- console.error("[Migration] Failed to update inbox_messages CHECK constraint:", e);
180
+ console.error("[Migration] Failed to update agent_tasks CHECK constraint:", e);
596
181
  try {
597
182
  db.run("PRAGMA foreign_keys=on");
598
- } catch {}
183
+ } catch (cleanupError) {
184
+ console.error("[Migration] Failed to re-enable SQLite foreign_keys pragma:", cleanupError);
185
+ }
599
186
  throw e;
600
187
  }
601
188
 
602
- // Create indexes on new columns (after migrations add them)
603
- try {
604
- db.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_offeredTo ON agent_tasks(offeredTo)`);
605
- } catch {
606
- /* exists or column missing */
607
- }
608
- try {
609
- db.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_taskType ON agent_tasks(taskType)`);
610
- } catch {
611
- /* exists or column missing */
612
- }
613
-
614
- // Epic feature migration: Add epicId to agent_tasks
615
- try {
616
- db.run(
617
- `ALTER TABLE agent_tasks ADD COLUMN epicId TEXT REFERENCES epics(id) ON DELETE SET NULL`,
618
- );
619
- } catch {
620
- /* exists */
621
- }
622
- try {
623
- db.run(`CREATE INDEX IF NOT EXISTS idx_agent_tasks_epicId ON agent_tasks(epicId)`);
624
- } catch {
625
- /* exists */
626
- }
189
+ // Backfill: Seed v1 for existing agents that don't have any context versions yet
190
+ seedContextVersions();
627
191
 
628
- // Epic progress trigger migration: Add progressNotifiedAt to epics
629
- try {
630
- db.run(`ALTER TABLE epics ADD COLUMN progressNotifiedAt TEXT`);
631
- } catch {
632
- /* exists */
633
- }
192
+ // Inject DB resolver into the prompt template resolver (DI to avoid worker/API boundary violation)
193
+ configureDbResolver(resolvePromptTemplate);
634
194
 
635
- // Epic channel migration: Add channelId to epics
636
- try {
637
- db.run(
638
- `ALTER TABLE epics ADD COLUMN channelId TEXT REFERENCES channels(id) ON DELETE SET NULL`,
639
- );
640
- } catch {
641
- /* exists */
642
- }
195
+ // Seed default prompt templates from the in-memory code registry
196
+ seedDefaultTemplates();
643
197
 
644
198
  return db;
645
199
  }
@@ -659,64 +213,286 @@ export function closeDb(): void {
659
213
  }
660
214
 
661
215
  // ============================================================================
662
- // Agent Queries
216
+ // Context Versioning
663
217
  // ============================================================================
664
218
 
665
- type AgentRow = {
219
+ const VERSIONABLE_FIELDS: VersionableField[] = [
220
+ "soulMd",
221
+ "identityMd",
222
+ "toolsMd",
223
+ "claudeMd",
224
+ "setupScript",
225
+ ];
226
+
227
+ function ensureAgentProfileColumns(database: Database): void {
228
+ const existingColumns = new Set(
229
+ database
230
+ .prepare<{ name: string }, []>("PRAGMA table_info(agents)")
231
+ .all()
232
+ .map((row) => row.name),
233
+ );
234
+
235
+ for (const column of VERSIONABLE_FIELDS) {
236
+ if (!existingColumns.has(column)) {
237
+ try {
238
+ database.run(`ALTER TABLE agents ADD COLUMN ${column} TEXT`);
239
+ } catch (error) {
240
+ console.error(`[Migration] Failed to add missing agents.${column} column`, error);
241
+ throw error;
242
+ }
243
+ }
244
+ }
245
+ }
246
+
247
+ function computeContentHash(content: string): string {
248
+ const hasher = new Bun.CryptoHasher("sha256");
249
+ hasher.update(content);
250
+ return hasher.digest("hex");
251
+ }
252
+
253
+ type ContextVersionRow = {
666
254
  id: string;
667
- name: string;
668
- isLead: number;
669
- status: AgentStatus;
670
- description: string | null;
671
- role: string | null;
672
- capabilities: string | null;
673
- maxTasks: number | null;
674
- emptyPollCount: number | null;
675
- claudeMd: string | null;
255
+ agentId: string;
256
+ field: string;
257
+ content: string;
258
+ version: number;
259
+ changeSource: string;
260
+ changedByAgentId: string | null;
261
+ changeReason: string | null;
262
+ contentHash: string;
263
+ previousVersionId: string | null;
676
264
  createdAt: string;
677
- lastUpdatedAt: string;
678
265
  };
679
266
 
680
- function rowToAgent(row: AgentRow): Agent {
267
+ function rowToContextVersion(row: ContextVersionRow): ContextVersion {
681
268
  return {
682
269
  id: row.id,
683
- name: row.name,
684
- isLead: row.isLead === 1,
685
- status: row.status,
686
- description: row.description ?? undefined,
687
- role: row.role ?? undefined,
688
- capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
689
- maxTasks: row.maxTasks ?? 1,
690
- emptyPollCount: row.emptyPollCount ?? 0,
691
- claudeMd: row.claudeMd ?? undefined,
270
+ agentId: row.agentId,
271
+ field: row.field as VersionableField,
272
+ content: row.content,
273
+ version: row.version,
274
+ changeSource: row.changeSource as ChangeSource,
275
+ changedByAgentId: row.changedByAgentId,
276
+ changeReason: row.changeReason,
277
+ contentHash: row.contentHash,
278
+ previousVersionId: row.previousVersionId,
692
279
  createdAt: row.createdAt,
693
- lastUpdatedAt: row.lastUpdatedAt,
694
280
  };
695
281
  }
696
282
 
697
- export const agentQueries = {
698
- insert: () =>
699
- getDb().prepare<AgentRow, [string, string, number, AgentStatus, number]>(
700
- "INSERT INTO agents (id, name, isLead, status, maxTasks, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
701
- ),
283
+ export function createContextVersion(params: {
284
+ agentId: string;
285
+ field: VersionableField;
286
+ content: string;
287
+ version: number;
288
+ changeSource: ChangeSource;
289
+ changedByAgentId?: string | null;
290
+ changeReason?: string | null;
291
+ contentHash: string;
292
+ previousVersionId?: string | null;
293
+ }): ContextVersion {
294
+ const id = crypto.randomUUID();
295
+ const now = new Date().toISOString();
702
296
 
703
- getById: () => getDb().prepare<AgentRow, [string]>("SELECT * FROM agents WHERE id = ?"),
297
+ const row = getDb()
298
+ .prepare<
299
+ ContextVersionRow,
300
+ [
301
+ string,
302
+ string,
303
+ string,
304
+ string,
305
+ number,
306
+ string,
307
+ string | null,
308
+ string | null,
309
+ string,
310
+ string | null,
311
+ string,
312
+ ]
313
+ >(
314
+ `INSERT INTO context_versions (id, agentId, field, content, version, changeSource, changedByAgentId, changeReason, contentHash, previousVersionId, createdAt)
315
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
316
+ )
317
+ .get(
318
+ id,
319
+ params.agentId,
320
+ params.field,
321
+ params.content,
322
+ params.version,
323
+ params.changeSource,
324
+ params.changedByAgentId ?? null,
325
+ params.changeReason ?? null,
326
+ params.contentHash,
327
+ params.previousVersionId ?? null,
328
+ now,
329
+ );
704
330
 
705
- getAll: () => getDb().prepare<AgentRow, []>("SELECT * FROM agents ORDER BY name"),
331
+ if (!row) throw new Error("Failed to create context version");
332
+ return rowToContextVersion(row);
333
+ }
706
334
 
707
- updateStatus: () =>
708
- getDb().prepare<AgentRow, [AgentStatus, string]>(
709
- "UPDATE agents SET status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
710
- ),
335
+ export function getLatestContextVersion(
336
+ agentId: string,
337
+ field: VersionableField,
338
+ ): ContextVersion | null {
339
+ const row = getDb()
340
+ .prepare<ContextVersionRow, [string, string]>(
341
+ `SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT 1`,
342
+ )
343
+ .get(agentId, field);
711
344
 
712
- delete: () => getDb().prepare<null, [string]>("DELETE FROM agents WHERE id = ?"),
713
- };
345
+ return row ? rowToContextVersion(row) : null;
346
+ }
714
347
 
715
- export function createAgent(
716
- agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
717
- ): Agent {
718
- const id = agent.id ?? crypto.randomUUID();
719
- const maxTasks = agent.maxTasks ?? 1;
348
+ export function getContextVersion(id: string): ContextVersion | null {
349
+ const row = getDb()
350
+ .prepare<ContextVersionRow, [string]>(`SELECT * FROM context_versions WHERE id = ?`)
351
+ .get(id);
352
+
353
+ return row ? rowToContextVersion(row) : null;
354
+ }
355
+
356
+ export function getContextVersionHistory(params: {
357
+ agentId: string;
358
+ field?: VersionableField;
359
+ limit?: number;
360
+ }): ContextVersion[] {
361
+ const limit = params.limit ?? 10;
362
+
363
+ if (params.field) {
364
+ const rows = getDb()
365
+ .prepare<ContextVersionRow, [string, string, number]>(
366
+ `SELECT * FROM context_versions WHERE agentId = ? AND field = ? ORDER BY version DESC LIMIT ?`,
367
+ )
368
+ .all(params.agentId, params.field, limit);
369
+ return rows.map(rowToContextVersion);
370
+ }
371
+
372
+ const rows = getDb()
373
+ .prepare<ContextVersionRow, [string, number]>(
374
+ `SELECT * FROM context_versions WHERE agentId = ? ORDER BY createdAt DESC LIMIT ?`,
375
+ )
376
+ .all(params.agentId, limit);
377
+ return rows.map(rowToContextVersion);
378
+ }
379
+
380
+ /**
381
+ * Seed v1 context versions for existing agents that don't have any versions yet.
382
+ * Called during migration.
383
+ */
384
+ function seedContextVersions(): void {
385
+ const database = getDb();
386
+ const agents = database
387
+ .prepare<
388
+ {
389
+ id: string;
390
+ soulMd: string | null;
391
+ identityMd: string | null;
392
+ toolsMd: string | null;
393
+ claudeMd: string | null;
394
+ setupScript: string | null;
395
+ },
396
+ []
397
+ >(`SELECT id, soulMd, identityMd, toolsMd, claudeMd, setupScript FROM agents`)
398
+ .all();
399
+
400
+ for (const agent of agents) {
401
+ for (const field of VERSIONABLE_FIELDS) {
402
+ const content = agent[field];
403
+ if (!content) continue;
404
+
405
+ // Check if a version already exists for this agent+field
406
+ const existing = database
407
+ .prepare<{ id: string }, [string, string]>(
408
+ `SELECT id FROM context_versions WHERE agentId = ? AND field = ? LIMIT 1`,
409
+ )
410
+ .get(agent.id, field);
411
+ if (existing) continue;
412
+
413
+ const id = crypto.randomUUID();
414
+ const hash = computeContentHash(content);
415
+ const now = new Date().toISOString();
416
+
417
+ database
418
+ .prepare(
419
+ `INSERT INTO context_versions (id, agentId, field, content, version, changeSource, contentHash, createdAt)
420
+ VALUES (?, ?, ?, ?, 1, 'system', ?, ?)`,
421
+ )
422
+ .run(id, agent.id, field, content, hash, now);
423
+ }
424
+ }
425
+ }
426
+
427
+ // ============================================================================
428
+ // Agent Queries
429
+ // ============================================================================
430
+
431
+ type AgentRow = {
432
+ id: string;
433
+ name: string;
434
+ isLead: number;
435
+ status: AgentStatus;
436
+ description: string | null;
437
+ role: string | null;
438
+ capabilities: string | null;
439
+ maxTasks: number | null;
440
+ emptyPollCount: number | null;
441
+ claudeMd: string | null;
442
+ soulMd: string | null;
443
+ identityMd: string | null;
444
+ setupScript: string | null;
445
+ toolsMd: string | null;
446
+ lastActivityAt: string | null;
447
+ createdAt: string;
448
+ lastUpdatedAt: string;
449
+ };
450
+
451
+ function rowToAgent(row: AgentRow): Agent {
452
+ return {
453
+ id: row.id,
454
+ name: row.name,
455
+ isLead: row.isLead === 1,
456
+ status: row.status,
457
+ description: row.description ?? undefined,
458
+ role: row.role ?? undefined,
459
+ capabilities: row.capabilities ? JSON.parse(row.capabilities) : [],
460
+ maxTasks: row.maxTasks ?? 1,
461
+ emptyPollCount: row.emptyPollCount ?? 0,
462
+ claudeMd: row.claudeMd ?? undefined,
463
+ soulMd: row.soulMd ?? undefined,
464
+ identityMd: row.identityMd ?? undefined,
465
+ setupScript: row.setupScript ?? undefined,
466
+ toolsMd: row.toolsMd ?? undefined,
467
+ lastActivityAt: row.lastActivityAt ?? undefined,
468
+ createdAt: row.createdAt,
469
+ lastUpdatedAt: row.lastUpdatedAt,
470
+ };
471
+ }
472
+
473
+ export const agentQueries = {
474
+ insert: () =>
475
+ getDb().prepare<AgentRow, [string, string, number, AgentStatus, number]>(
476
+ "INSERT INTO agents (id, name, isLead, status, maxTasks, createdAt, lastUpdatedAt) VALUES (?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) RETURNING *",
477
+ ),
478
+
479
+ getById: () => getDb().prepare<AgentRow, [string]>("SELECT * FROM agents WHERE id = ?"),
480
+
481
+ getAll: () => getDb().prepare<AgentRow, []>("SELECT * FROM agents ORDER BY name"),
482
+
483
+ updateStatus: () =>
484
+ getDb().prepare<AgentRow, [AgentStatus, string]>(
485
+ "UPDATE agents SET status = ?, lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
486
+ ),
487
+
488
+ delete: () => getDb().prepare<null, [string]>("DELETE FROM agents WHERE id = ?"),
489
+ };
490
+
491
+ export function createAgent(
492
+ agent: Omit<Agent, "id" | "createdAt" | "lastUpdatedAt"> & { id?: string },
493
+ ): Agent {
494
+ const id = agent.id ?? crypto.randomUUID();
495
+ const maxTasks = agent.maxTasks ?? 1;
720
496
  const row = agentQueries
721
497
  .insert()
722
498
  .get(id, agent.name, agent.isLead ? 1 : 0, agent.status, maxTasks);
@@ -736,6 +512,11 @@ export function getAllAgents(): Agent[] {
736
512
  return agentQueries.getAll().all().map(rowToAgent);
737
513
  }
738
514
 
515
+ export function getLeadAgent(): Agent | null {
516
+ const agents = getAllAgents();
517
+ return agents.find((a) => a.isLead) ?? null;
518
+ }
519
+
739
520
  export function updateAgentStatus(id: string, status: AgentStatus): Agent | null {
740
521
  const oldAgent = getAgentById(id);
741
522
  const row = agentQueries.updateStatus().get(status, id);
@@ -762,6 +543,14 @@ export function updateAgentMaxTasks(id: string, maxTasks: number): Agent | null
762
543
  return row ? rowToAgent(row) : null;
763
544
  }
764
545
 
546
+ export function updateAgentActivity(id: string): void {
547
+ getDb()
548
+ .prepare<null, [string]>(
549
+ `UPDATE agents SET lastActivityAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?`,
550
+ )
551
+ .run(id);
552
+ }
553
+
765
554
  // ============================================================================
766
555
  // Agent Poll Tracking Functions
767
556
  // ============================================================================
@@ -895,15 +684,27 @@ type AgentTaskRow = {
895
684
  slackChannelId: string | null;
896
685
  slackThreadTs: string | null;
897
686
  slackUserId: string | null;
898
- githubRepo: string | null;
899
- githubEventType: string | null;
900
- githubNumber: number | null;
901
- githubCommentId: number | null;
902
- githubAuthor: string | null;
903
- githubUrl: string | null;
687
+ vcsProvider: string | null;
688
+ vcsRepo: string | null;
689
+ vcsEventType: string | null;
690
+ vcsNumber: number | null;
691
+ vcsCommentId: number | null;
692
+ vcsAuthor: string | null;
693
+ vcsUrl: string | null;
694
+ agentmailInboxId: string | null;
695
+ agentmailMessageId: string | null;
696
+ agentmailThreadId: string | null;
904
697
  mentionMessageId: string | null;
905
698
  mentionChannelId: string | null;
906
699
  epicId: string | null;
700
+ dir: string | null;
701
+ parentTaskId: string | null;
702
+ claudeSessionId: string | null;
703
+ model: string | null;
704
+ scheduleId: string | null;
705
+ workflowRunId: string | null;
706
+ workflowRunStepId: string | null;
707
+ outputSchema: string | null;
907
708
  createdAt: string;
908
709
  lastUpdatedAt: string;
909
710
  finishedAt: string | null;
@@ -932,15 +733,27 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
932
733
  slackChannelId: row.slackChannelId ?? undefined,
933
734
  slackThreadTs: row.slackThreadTs ?? undefined,
934
735
  slackUserId: row.slackUserId ?? undefined,
935
- githubRepo: row.githubRepo ?? undefined,
936
- githubEventType: row.githubEventType ?? undefined,
937
- githubNumber: row.githubNumber ?? undefined,
938
- githubCommentId: row.githubCommentId ?? undefined,
939
- githubAuthor: row.githubAuthor ?? undefined,
940
- githubUrl: row.githubUrl ?? undefined,
736
+ vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
737
+ vcsRepo: row.vcsRepo ?? undefined,
738
+ vcsEventType: row.vcsEventType ?? undefined,
739
+ vcsNumber: row.vcsNumber ?? undefined,
740
+ vcsCommentId: row.vcsCommentId ?? undefined,
741
+ vcsAuthor: row.vcsAuthor ?? undefined,
742
+ vcsUrl: row.vcsUrl ?? undefined,
743
+ agentmailInboxId: row.agentmailInboxId ?? undefined,
744
+ agentmailMessageId: row.agentmailMessageId ?? undefined,
745
+ agentmailThreadId: row.agentmailThreadId ?? undefined,
941
746
  mentionMessageId: row.mentionMessageId ?? undefined,
942
747
  mentionChannelId: row.mentionChannelId ?? undefined,
943
748
  epicId: row.epicId ?? undefined,
749
+ dir: row.dir ?? undefined,
750
+ parentTaskId: row.parentTaskId ?? undefined,
751
+ claudeSessionId: row.claudeSessionId ?? undefined,
752
+ model: (row.model as "haiku" | "sonnet" | "opus" | null) ?? undefined,
753
+ scheduleId: row.scheduleId ?? undefined,
754
+ workflowRunId: row.workflowRunId ?? undefined,
755
+ workflowRunStepId: row.workflowRunStepId ?? undefined,
756
+ outputSchema: row.outputSchema ? JSON.parse(row.outputSchema) : undefined,
944
757
  createdAt: row.createdAt,
945
758
  lastUpdatedAt: row.lastUpdatedAt,
946
759
  finishedAt: row.finishedAt ?? undefined,
@@ -1006,7 +819,10 @@ export const taskQueries = {
1006
819
 
1007
820
  setProgress: () =>
1008
821
  getDb().prepare<AgentTaskRow, [string, string]>(
1009
- "UPDATE agent_tasks SET progress = ?, status = 'in_progress', lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ? RETURNING *",
822
+ `UPDATE agent_tasks SET progress = ?,
823
+ status = CASE WHEN status IN ('completed', 'failed', 'cancelled') THEN status ELSE 'in_progress' END,
824
+ lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
825
+ WHERE id = ? RETURNING *`,
1010
826
  ),
1011
827
 
1012
828
  delete: () => getDb().prepare<null, [string]>("DELETE FROM agent_tasks WHERE id = ?"),
@@ -1071,10 +887,17 @@ export function getPendingTaskForAgent(agentId: string): AgentTask | null {
1071
887
 
1072
888
  export function startTask(taskId: string): AgentTask | null {
1073
889
  const oldTask = getTaskById(taskId);
890
+ if (!oldTask) return null;
891
+
892
+ // Guard: never revive tasks that are already in a terminal state
893
+ if (["completed", "failed", "cancelled"].includes(oldTask.status)) {
894
+ return null;
895
+ }
896
+
1074
897
  const row = getDb()
1075
898
  .prepare<AgentTaskRow, [string]>(
1076
899
  `UPDATE agent_tasks SET status = 'in_progress', lastUpdatedAt = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
1077
- WHERE id = ? RETURNING *`,
900
+ WHERE id = ? AND status NOT IN ('completed', 'failed', 'cancelled') RETURNING *`,
1078
901
  )
1079
902
  .get(taskId);
1080
903
  if (row && oldTask) {
@@ -1096,6 +919,18 @@ export function getTaskById(id: string): AgentTask | null {
1096
919
  return row ? rowToAgentTask(row) : null;
1097
920
  }
1098
921
 
922
+ export function updateTaskClaudeSessionId(
923
+ taskId: string,
924
+ claudeSessionId: string,
925
+ ): AgentTask | null {
926
+ const row = getDb()
927
+ .prepare<AgentTaskRow, [string, string, string]>(
928
+ `UPDATE agent_tasks SET claudeSessionId = ?, lastUpdatedAt = ? WHERE id = ? RETURNING *`,
929
+ )
930
+ .get(claudeSessionId, new Date().toISOString(), taskId);
931
+ return row ? rowToAgentTask(row) : null;
932
+ }
933
+
1099
934
  export function getTasksByAgentId(agentId: string): AgentTask[] {
1100
935
  return taskQueries.getByAgentId().all(agentId).map(rowToAgentTask);
1101
936
  }
@@ -1105,25 +940,29 @@ export function getTasksByStatus(status: AgentTaskStatus): AgentTask[] {
1105
940
  }
1106
941
 
1107
942
  /**
1108
- * Find a task by GitHub repo and issue/PR number
1109
- * Returns the most recent non-completed/failed task for this GitHub entity
943
+ * Find a task by VCS repo and issue/PR/MR number.
944
+ * Returns the most recent non-completed/failed task for this VCS entity.
1110
945
  */
1111
- export function findTaskByGitHub(githubRepo: string, githubNumber: number): AgentTask | null {
946
+ export function findTaskByVcs(vcsRepo: string, vcsNumber: number): AgentTask | null {
1112
947
  const row = getDb()
1113
948
  .prepare<AgentTaskRow, [string, number]>(
1114
949
  `SELECT * FROM agent_tasks
1115
- WHERE githubRepo = ? AND githubNumber = ?
950
+ WHERE vcsRepo = ? AND vcsNumber = ?
1116
951
  AND status NOT IN ('completed', 'failed')
1117
952
  ORDER BY createdAt DESC
1118
953
  LIMIT 1`,
1119
954
  )
1120
- .get(githubRepo, githubNumber);
955
+ .get(vcsRepo, vcsNumber);
1121
956
  return row ? rowToAgentTask(row) : null;
1122
957
  }
1123
958
 
959
+ /** @deprecated Use findTaskByVcs instead */
960
+ export const findTaskByGitHub = findTaskByVcs;
961
+
1124
962
  export interface TaskFilters {
1125
963
  status?: AgentTaskStatus;
1126
964
  agentId?: string;
965
+ epicId?: string;
1127
966
  search?: string;
1128
967
  // New filters
1129
968
  unassigned?: boolean;
@@ -1131,7 +970,10 @@ export interface TaskFilters {
1131
970
  readyOnly?: boolean;
1132
971
  taskType?: string;
1133
972
  tags?: string[];
973
+ scheduleId?: string;
1134
974
  limit?: number;
975
+ offset?: number;
976
+ includeHeartbeat?: boolean;
1135
977
  }
1136
978
 
1137
979
  export function getAllTasks(filters?: TaskFilters): AgentTask[] {
@@ -1148,6 +990,11 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1148
990
  params.push(filters.agentId);
1149
991
  }
1150
992
 
993
+ if (filters?.epicId) {
994
+ conditions.push("epicId = ?");
995
+ params.push(filters.epicId);
996
+ }
997
+
1151
998
  if (filters?.search) {
1152
999
  conditions.push("task LIKE ?");
1153
1000
  params.push(`%${filters.search}%`);
@@ -1177,9 +1024,20 @@ export function getAllTasks(filters?: TaskFilters): AgentTask[] {
1177
1024
  }
1178
1025
  }
1179
1026
 
1027
+ if (filters?.scheduleId) {
1028
+ conditions.push("scheduleId = ?");
1029
+ params.push(filters.scheduleId);
1030
+ }
1031
+
1032
+ // Exclude heartbeat tasks by default
1033
+ if (!filters?.includeHeartbeat) {
1034
+ conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
1035
+ }
1036
+
1180
1037
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1181
1038
  const limit = filters?.limit ?? 25;
1182
- const query = `SELECT * FROM agent_tasks ${whereClause} ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit}`;
1039
+ const offset = filters?.offset ?? 0;
1040
+ const query = `SELECT * FROM agent_tasks ${whereClause} ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
1183
1041
 
1184
1042
  let tasks = getDb()
1185
1043
  .prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
@@ -1215,6 +1073,11 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1215
1073
  params.push(filters.agentId);
1216
1074
  }
1217
1075
 
1076
+ if (filters?.epicId) {
1077
+ conditions.push("epicId = ?");
1078
+ params.push(filters.epicId);
1079
+ }
1080
+
1218
1081
  if (filters?.search) {
1219
1082
  conditions.push("task LIKE ?");
1220
1083
  params.push(`%${filters.search}%`);
@@ -1242,6 +1105,16 @@ export function getTasksCount(filters?: Omit<TaskFilters, "limit" | "readyOnly">
1242
1105
  }
1243
1106
  }
1244
1107
 
1108
+ if (filters?.scheduleId) {
1109
+ conditions.push("scheduleId = ?");
1110
+ params.push(filters.scheduleId);
1111
+ }
1112
+
1113
+ // Exclude heartbeat tasks by default
1114
+ if (!filters?.includeHeartbeat) {
1115
+ conditions.push("(IFNULL(taskType, '') != 'heartbeat' AND tags NOT LIKE '%\"heartbeat\"%')");
1116
+ }
1117
+
1245
1118
  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1246
1119
  const query = `SELECT COUNT(*) as count FROM agent_tasks ${whereClause}`;
1247
1120
 
@@ -1315,10 +1188,10 @@ export function getCompletedSlackTasks(): AgentTask[] {
1315
1188
  return getDb()
1316
1189
  .prepare<AgentTaskRow, []>(
1317
1190
  `SELECT * FROM agent_tasks
1318
- WHERE source = 'slack'
1319
- AND slackChannelId IS NOT NULL
1191
+ WHERE slackChannelId IS NOT NULL
1320
1192
  AND status IN ('completed', 'failed')
1321
- ORDER BY lastUpdatedAt DESC`,
1193
+ ORDER BY lastUpdatedAt DESC
1194
+ LIMIT 200`,
1322
1195
  )
1323
1196
  .all()
1324
1197
  .map(rowToAgentTask);
@@ -1363,33 +1236,51 @@ export function markTasksNotified(taskIds: string[]): number {
1363
1236
  return result.changes;
1364
1237
  }
1365
1238
 
1239
+ /**
1240
+ * Reset notifiedAt for tasks, allowing them to be re-delivered on next poll.
1241
+ * Used when a trigger was consumed but the session that should process it failed.
1242
+ * This prevents permanent notification loss from the mark-before-process race.
1243
+ */
1244
+ export function resetTasksNotified(taskIds: string[]): number {
1245
+ if (taskIds.length === 0) return 0;
1246
+
1247
+ const placeholders = taskIds.map(() => "?").join(",");
1248
+
1249
+ const result = getDb().run(
1250
+ `UPDATE agent_tasks SET notifiedAt = NULL
1251
+ WHERE id IN (${placeholders}) AND notifiedAt IS NOT NULL`,
1252
+ taskIds,
1253
+ );
1254
+
1255
+ return result.changes;
1256
+ }
1257
+
1366
1258
  export function getInProgressSlackTasks(): AgentTask[] {
1367
1259
  return getDb()
1368
1260
  .prepare<AgentTaskRow, []>(
1369
1261
  `SELECT * FROM agent_tasks
1370
- WHERE source = 'slack'
1371
- AND slackChannelId IS NOT NULL
1262
+ WHERE slackChannelId IS NOT NULL
1372
1263
  AND status = 'in_progress'
1373
- ORDER BY lastUpdatedAt DESC`,
1264
+ ORDER BY lastUpdatedAt DESC
1265
+ LIMIT 200`,
1374
1266
  )
1375
1267
  .all()
1376
1268
  .map(rowToAgentTask);
1377
1269
  }
1378
1270
 
1379
1271
  /**
1380
- * Find an agent that has an active task or inbox message in a specific Slack thread.
1381
- * Used for routing thread follow-up messages to the same agent.
1382
- * Checks both tasks (for workers) and inbox_messages (for leads).
1272
+ * Find the most recent agent associated with a specific Slack thread.
1273
+ * No status filter returns the last agent that touched this thread regardless of task state.
1274
+ * This is intentional: follow-up messages should route to the same agent even after task completion.
1275
+ * Callers (e.g. assistant.ts) apply their own status checks (e.g. agent.status !== "offline").
1383
1276
  */
1384
1277
  export function getAgentWorkingOnThread(channelId: string, threadTs: string): Agent | null {
1385
- // First check tasks (for workers)
1386
1278
  const taskRow = getDb()
1387
1279
  .prepare<AgentTaskRow, [string, string]>(
1388
1280
  `SELECT * FROM agent_tasks
1389
1281
  WHERE source = 'slack'
1390
1282
  AND slackChannelId = ?
1391
1283
  AND slackThreadTs = ?
1392
- AND status IN ('in_progress', 'pending')
1393
1284
  ORDER BY createdAt DESC
1394
1285
  LIMIT 1`,
1395
1286
  )
@@ -1397,21 +1288,45 @@ export function getAgentWorkingOnThread(channelId: string, threadTs: string): Ag
1397
1288
 
1398
1289
  if (taskRow?.agentId) return getAgentById(taskRow.agentId);
1399
1290
 
1400
- // Then check inbox_messages (for leads)
1401
- const inboxRow = getDb()
1402
- .prepare<{ agentId: string }, [string, string]>(
1403
- `SELECT agentId FROM inbox_messages
1291
+ return null;
1292
+ }
1293
+
1294
+ /**
1295
+ * Find the latest active (in_progress or pending) task in a specific Slack thread.
1296
+ * Used for dependency chaining in additive Slack buffer.
1297
+ */
1298
+ export function getLatestActiveTaskInThread(channelId: string, threadTs: string): AgentTask | null {
1299
+ const row = getDb()
1300
+ .prepare<AgentTaskRow, [string, string]>(
1301
+ `SELECT * FROM agent_tasks
1404
1302
  WHERE source = 'slack'
1405
1303
  AND slackChannelId = ?
1406
1304
  AND slackThreadTs = ?
1407
- ORDER BY createdAt DESC
1305
+ AND status IN ('in_progress', 'pending')
1306
+ ORDER BY createdAt DESC, rowid DESC
1408
1307
  LIMIT 1`,
1409
1308
  )
1410
1309
  .get(channelId, threadTs);
1411
1310
 
1412
- if (inboxRow?.agentId) return getAgentById(inboxRow.agentId);
1311
+ return row ? rowToAgentTask(row) : null;
1312
+ }
1413
1313
 
1414
- return null;
1314
+ /**
1315
+ * Find the most recent task in a Slack thread, regardless of source or status.
1316
+ * Unlike getAgentWorkingOnThread (which filters source='slack'), this finds ALL tasks
1317
+ * including worker tasks that inherited Slack metadata via parentTaskId.
1318
+ */
1319
+ export function getMostRecentTaskInThread(channelId: string, threadTs: string): AgentTask | null {
1320
+ const row = getDb()
1321
+ .prepare<AgentTaskRow, [string, string]>(
1322
+ `SELECT * FROM agent_tasks
1323
+ WHERE slackChannelId = ?
1324
+ AND slackThreadTs = ?
1325
+ ORDER BY createdAt DESC
1326
+ LIMIT 1`,
1327
+ )
1328
+ .get(channelId, threadTs);
1329
+ return row ? rowToAgentTask(row) : null;
1415
1330
  }
1416
1331
 
1417
1332
  export function completeTask(id: string, output?: string): AgentTask | null {
@@ -1434,6 +1349,17 @@ export function completeTask(id: string, output?: string): AgentTask | null {
1434
1349
  newValue: "completed",
1435
1350
  });
1436
1351
  } catch {}
1352
+ try {
1353
+ import("../workflows/event-bus").then(({ workflowEventBus }) => {
1354
+ workflowEventBus.emit("task.completed", {
1355
+ taskId: id,
1356
+ output,
1357
+ agentId: row.agentId,
1358
+ workflowRunId: row.workflowRunId,
1359
+ workflowRunStepId: row.workflowRunStepId,
1360
+ });
1361
+ });
1362
+ } catch {}
1437
1363
  }
1438
1364
 
1439
1365
  return row ? rowToAgentTask(row) : null;
@@ -1454,6 +1380,17 @@ export function failTask(id: string, reason: string): AgentTask | null {
1454
1380
  metadata: { reason },
1455
1381
  });
1456
1382
  } catch {}
1383
+ try {
1384
+ import("../workflows/event-bus").then(({ workflowEventBus }) => {
1385
+ workflowEventBus.emit("task.failed", {
1386
+ taskId: id,
1387
+ failureReason: reason,
1388
+ agentId: row.agentId,
1389
+ workflowRunId: row.workflowRunId,
1390
+ workflowRunStepId: row.workflowRunStepId,
1391
+ });
1392
+ });
1393
+ } catch {}
1457
1394
  }
1458
1395
  return row ? rowToAgentTask(row) : null;
1459
1396
  }
@@ -1462,8 +1399,9 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
1462
1399
  const oldTask = getTaskById(id);
1463
1400
  if (!oldTask) return null;
1464
1401
 
1465
- // Only cancel tasks that are in progress or pending
1466
- if (!["pending", "in_progress"].includes(oldTask.status)) {
1402
+ // Only cancel tasks that are not already in a terminal state
1403
+ const terminalStatuses = ["completed", "failed", "cancelled"];
1404
+ if (terminalStatuses.includes(oldTask.status)) {
1467
1405
  return null;
1468
1406
  }
1469
1407
 
@@ -1482,6 +1420,16 @@ export function cancelTask(id: string, reason?: string): AgentTask | null {
1482
1420
  metadata: reason ? { reason } : undefined,
1483
1421
  });
1484
1422
  } catch {}
1423
+ try {
1424
+ import("../workflows/event-bus").then(({ workflowEventBus }) => {
1425
+ workflowEventBus.emit("task.cancelled", {
1426
+ taskId: id,
1427
+ agentId: row.agentId,
1428
+ workflowRunId: row.workflowRunId,
1429
+ workflowRunStepId: row.workflowRunStepId,
1430
+ });
1431
+ });
1432
+ } catch {}
1485
1433
  }
1486
1434
 
1487
1435
  return row ? rowToAgentTask(row) : null;
@@ -1612,6 +1560,15 @@ export function updateTaskProgress(id: string, progress: string): AgentTask | nu
1612
1560
  newValue: progress,
1613
1561
  });
1614
1562
  } catch {}
1563
+ try {
1564
+ import("../workflows/event-bus").then(({ workflowEventBus }) => {
1565
+ workflowEventBus.emit("task.progress", {
1566
+ taskId: id,
1567
+ progress,
1568
+ agentId: row.agentId,
1569
+ });
1570
+ });
1571
+ } catch {}
1615
1572
  }
1616
1573
  return row ? rowToAgentTask(row) : null;
1617
1574
  }
@@ -1770,15 +1727,62 @@ export interface CreateTaskOptions {
1770
1727
  slackChannelId?: string;
1771
1728
  slackThreadTs?: string;
1772
1729
  slackUserId?: string;
1773
- githubRepo?: string;
1774
- githubEventType?: string;
1775
- githubNumber?: number;
1776
- githubCommentId?: number;
1777
- githubAuthor?: string;
1778
- githubUrl?: string;
1730
+ vcsProvider?: "github" | "gitlab";
1731
+ vcsRepo?: string;
1732
+ vcsEventType?: string;
1733
+ vcsNumber?: number;
1734
+ vcsCommentId?: number;
1735
+ vcsAuthor?: string;
1736
+ vcsUrl?: string;
1737
+ agentmailInboxId?: string;
1738
+ agentmailMessageId?: string;
1739
+ agentmailThreadId?: string;
1779
1740
  mentionMessageId?: string;
1780
1741
  mentionChannelId?: string;
1781
1742
  epicId?: string;
1743
+ dir?: string;
1744
+ parentTaskId?: string;
1745
+ model?: string;
1746
+ scheduleId?: string;
1747
+ workflowRunId?: string;
1748
+ workflowRunStepId?: string;
1749
+ sourceTaskId?: string;
1750
+ outputSchema?: Record<string, unknown>;
1751
+ }
1752
+
1753
+ /**
1754
+ * Find recent tasks within a time window for deduplication checks.
1755
+ * Returns tasks created in the last N minutes, optionally filtered by creator or target agent.
1756
+ */
1757
+ export function findRecentSimilarTasks(opts: {
1758
+ windowMinutes?: number;
1759
+ creatorAgentId?: string;
1760
+ agentId?: string;
1761
+ limit?: number;
1762
+ }): AgentTask[] {
1763
+ const since = new Date(Date.now() - (opts.windowMinutes ?? 10) * 60 * 1000).toISOString();
1764
+ const conditions: string[] = ["createdAt > ?"];
1765
+ const params: (string | number)[] = [since];
1766
+
1767
+ // Exclude completed/failed/cancelled tasks — only active or recently created
1768
+ conditions.push("status NOT IN ('completed', 'failed', 'cancelled')");
1769
+
1770
+ if (opts.creatorAgentId) {
1771
+ conditions.push("creatorAgentId = ?");
1772
+ params.push(opts.creatorAgentId);
1773
+ }
1774
+ if (opts.agentId) {
1775
+ conditions.push("agentId = ?");
1776
+ params.push(opts.agentId);
1777
+ }
1778
+
1779
+ const limit = opts.limit ?? 50;
1780
+ const query = `SELECT * FROM agent_tasks WHERE ${conditions.join(" AND ")} ORDER BY createdAt DESC LIMIT ${limit}`;
1781
+
1782
+ return getDb()
1783
+ .prepare<AgentTaskRow, (string | number)[]>(query)
1784
+ .all(...params)
1785
+ .map(rowToAgentTask);
1782
1786
  }
1783
1787
 
1784
1788
  export function createTaskExtended(task: string, options?: CreateTaskOptions): AgentTask {
@@ -1792,15 +1796,51 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1792
1796
  ? "backlog"
1793
1797
  : "unassigned";
1794
1798
 
1799
+ // Inherit Slack/AgentMail metadata from parent task (unless explicitly overridden)
1800
+ if (options?.parentTaskId) {
1801
+ const parent = getTaskById(options.parentTaskId);
1802
+ if (parent) {
1803
+ if (parent.slackChannelId && !options.slackChannelId) {
1804
+ options.slackChannelId = parent.slackChannelId;
1805
+ }
1806
+ if (parent.slackThreadTs && !options.slackThreadTs) {
1807
+ options.slackThreadTs = parent.slackThreadTs;
1808
+ }
1809
+ if (parent.slackUserId && !options.slackUserId) {
1810
+ options.slackUserId = parent.slackUserId;
1811
+ }
1812
+ if (parent.agentmailInboxId && !options.agentmailInboxId) {
1813
+ options.agentmailInboxId = parent.agentmailInboxId;
1814
+ }
1815
+ if (parent.agentmailThreadId && !options.agentmailThreadId) {
1816
+ options.agentmailThreadId = parent.agentmailThreadId;
1817
+ }
1818
+ }
1819
+ }
1820
+
1821
+ // Auto-inherit Slack metadata from the creator's source task (deterministic via sourceTaskId)
1822
+ // Priority: explicit params > parentTaskId inheritance > sourceTaskId lookup
1823
+ // sourceTaskId is set by the adapter's X-Source-Task-Id header — each adapter carries its taskId natively
1824
+ if (options?.creatorAgentId && !options.slackChannelId && options.sourceTaskId) {
1825
+ const sourceTask = getTaskById(options.sourceTaskId);
1826
+ if (sourceTask?.slackChannelId) {
1827
+ options.slackChannelId = sourceTask.slackChannelId;
1828
+ options.slackThreadTs = sourceTask.slackThreadTs;
1829
+ options.slackUserId = sourceTask.slackUserId;
1830
+ }
1831
+ }
1832
+
1795
1833
  const row = getDb()
1796
1834
  .prepare<AgentTaskRow, (string | number | null)[]>(
1797
1835
  `INSERT INTO agent_tasks (
1798
1836
  id, agentId, creatorAgentId, task, status, source,
1799
1837
  taskType, tags, priority, dependsOn, offeredTo, offeredAt,
1800
1838
  slackChannelId, slackThreadTs, slackUserId,
1801
- githubRepo, githubEventType, githubNumber, githubCommentId, githubAuthor, githubUrl,
1802
- mentionMessageId, mentionChannelId, epicId, createdAt, lastUpdatedAt
1803
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1839
+ vcsProvider, vcsRepo, vcsEventType, vcsNumber, vcsCommentId, vcsAuthor, vcsUrl,
1840
+ agentmailInboxId, agentmailMessageId, agentmailThreadId,
1841
+ mentionMessageId, mentionChannelId, epicId, dir, parentTaskId, model, scheduleId,
1842
+ workflowRunId, workflowRunStepId, outputSchema, createdAt, lastUpdatedAt
1843
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
1804
1844
  )
1805
1845
  .get(
1806
1846
  id,
@@ -1818,15 +1858,26 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1818
1858
  options?.slackChannelId ?? null,
1819
1859
  options?.slackThreadTs ?? null,
1820
1860
  options?.slackUserId ?? null,
1821
- options?.githubRepo ?? null,
1822
- options?.githubEventType ?? null,
1823
- options?.githubNumber ?? null,
1824
- options?.githubCommentId ?? null,
1825
- options?.githubAuthor ?? null,
1826
- options?.githubUrl ?? null,
1861
+ options?.vcsProvider ?? null,
1862
+ options?.vcsRepo ?? null,
1863
+ options?.vcsEventType ?? null,
1864
+ options?.vcsNumber ?? null,
1865
+ options?.vcsCommentId ?? null,
1866
+ options?.vcsAuthor ?? null,
1867
+ options?.vcsUrl ?? null,
1868
+ options?.agentmailInboxId ?? null,
1869
+ options?.agentmailMessageId ?? null,
1870
+ options?.agentmailThreadId ?? null,
1827
1871
  options?.mentionMessageId ?? null,
1828
1872
  options?.mentionChannelId ?? null,
1829
1873
  options?.epicId ?? null,
1874
+ options?.dir ?? null,
1875
+ options?.parentTaskId ?? null,
1876
+ options?.model ?? null,
1877
+ options?.scheduleId ?? null,
1878
+ options?.workflowRunId ?? null,
1879
+ options?.workflowRunStepId ?? null,
1880
+ options?.outputSchema ? JSON.stringify(options.outputSchema) : null,
1830
1881
  now,
1831
1882
  now,
1832
1883
  );
@@ -1843,18 +1894,32 @@ export function createTaskExtended(task: string, options?: CreateTaskOptions): A
1843
1894
  });
1844
1895
  } catch {}
1845
1896
 
1897
+ try {
1898
+ import("../workflows/event-bus").then(({ workflowEventBus }) => {
1899
+ workflowEventBus.emit("task.created", {
1900
+ taskId: row.id,
1901
+ task: row.task,
1902
+ source: row.source,
1903
+ tags: options?.tags ?? [],
1904
+ agentId: row.agentId,
1905
+ workflowRunId: row.workflowRunId,
1906
+ workflowRunStepId: row.workflowRunStepId,
1907
+ });
1908
+ });
1909
+ } catch {}
1910
+
1846
1911
  return rowToAgentTask(row);
1847
1912
  }
1848
1913
 
1849
1914
  export function claimTask(taskId: string, agentId: string): AgentTask | null {
1850
- const task = getTaskById(taskId);
1851
- if (!task) return null;
1852
- if (task.status !== "unassigned") return null;
1853
-
1915
+ // Atomic claim: single UPDATE with WHERE guard ensures exactly-once claiming.
1916
+ // No pre-read needed — the WHERE clause handles the race condition.
1917
+ // Status goes directly to 'in_progress' because the claiming session is
1918
+ // already working on the task (prevents duplicate task_assigned triggers).
1854
1919
  const now = new Date().toISOString();
1855
1920
  const row = getDb()
1856
1921
  .prepare<AgentTaskRow, [string, string, string]>(
1857
- `UPDATE agent_tasks SET agentId = ?, status = 'pending', lastUpdatedAt = ?
1922
+ `UPDATE agent_tasks SET agentId = ?, status = 'in_progress', lastUpdatedAt = ?
1858
1923
  WHERE id = ? AND status = 'unassigned' RETURNING *`,
1859
1924
  )
1860
1925
  .get(agentId, now, taskId);
@@ -1866,7 +1931,7 @@ export function claimTask(taskId: string, agentId: string): AgentTask | null {
1866
1931
  agentId,
1867
1932
  taskId,
1868
1933
  oldValue: "unassigned",
1869
- newValue: "pending",
1934
+ newValue: "in_progress",
1870
1935
  });
1871
1936
  } catch {}
1872
1937
  }
@@ -1877,13 +1942,14 @@ export function claimTask(taskId: string, agentId: string): AgentTask | null {
1877
1942
  export function releaseTask(taskId: string): AgentTask | null {
1878
1943
  const task = getTaskById(taskId);
1879
1944
  if (!task) return null;
1880
- if (task.status !== "pending") return null;
1945
+ // Allow releasing both 'pending' (directly assigned) and 'in_progress' (pool-claimed) tasks
1946
+ if (task.status !== "pending" && task.status !== "in_progress") return null;
1881
1947
 
1882
1948
  const now = new Date().toISOString();
1883
1949
  const row = getDb()
1884
1950
  .prepare<AgentTaskRow, [string, string]>(
1885
1951
  `UPDATE agent_tasks SET agentId = NULL, status = 'unassigned', lastUpdatedAt = ?
1886
- WHERE id = ? AND status = 'pending' RETURNING *`,
1952
+ WHERE id = ? AND status IN ('pending', 'in_progress') RETURNING *`,
1887
1953
  )
1888
1954
  .get(now, taskId);
1889
1955
 
@@ -1893,7 +1959,7 @@ export function releaseTask(taskId: string): AgentTask | null {
1893
1959
  eventType: "task_released",
1894
1960
  agentId: task.agentId ?? undefined,
1895
1961
  taskId,
1896
- oldValue: "pending",
1962
+ oldValue: task.status,
1897
1963
  newValue: "unassigned",
1898
1964
  });
1899
1965
  } catch {}
@@ -2095,6 +2161,16 @@ export function getUnassignedTasksCount(): number {
2095
2161
  return result?.count ?? 0;
2096
2162
  }
2097
2163
 
2164
+ /** Get unassigned task IDs, ordered by priority (highest first) then creation time */
2165
+ export function getUnassignedTaskIds(limit = 10): string[] {
2166
+ const rows = getDb()
2167
+ .prepare<{ id: string }, [number]>(
2168
+ "SELECT id FROM agent_tasks WHERE status = 'unassigned' ORDER BY priority DESC, createdAt ASC LIMIT ?",
2169
+ )
2170
+ .all(limit);
2171
+ return rows.map((r) => r.id);
2172
+ }
2173
+
2098
2174
  // ============================================================================
2099
2175
  // Dependency Checking
2100
2176
  // ============================================================================
@@ -2123,50 +2199,14 @@ export function checkDependencies(taskId: string): {
2123
2199
  // Agent Profile Operations
2124
2200
  // ============================================================================
2125
2201
 
2126
- /**
2127
- * Generate default CLAUDE.md content for a new agent
2128
- */
2129
- export function generateDefaultClaudeMd(agent: {
2130
- name: string;
2131
- description?: string;
2132
- role?: string;
2133
- capabilities?: string[];
2134
- }): string {
2135
- const lines = [`# Agent: ${agent.name}`, ""];
2136
-
2137
- if (agent.description) {
2138
- lines.push(agent.description, "");
2139
- }
2140
-
2141
- if (agent.role) {
2142
- lines.push("## Role", agent.role, "");
2143
- }
2144
-
2145
- if (agent.capabilities && agent.capabilities.length > 0) {
2146
- lines.push("## Capabilities");
2147
- for (const cap of agent.capabilities) {
2148
- lines.push(`- ${cap}`);
2149
- }
2150
- lines.push("");
2151
- }
2152
-
2153
- lines.push(
2154
- "---",
2155
- "",
2156
- "## Notes",
2157
- "",
2158
- "If you need to remember something, write it down here. This section persists across sessions.",
2159
- "",
2160
- "### Learnings",
2161
- "",
2162
- "### Preferences",
2163
- "",
2164
- "### Important Context",
2165
- "",
2166
- );
2167
-
2168
- return lines.join("\n");
2169
- }
2202
+ // Default markdown template generators moved to src/prompts/defaults.ts
2203
+ // Re-export for backwards compatibility with any external consumers
2204
+ export {
2205
+ generateDefaultClaudeMd,
2206
+ generateDefaultIdentityMd,
2207
+ generateDefaultSoulMd,
2208
+ generateDefaultToolsMd,
2209
+ } from "../prompts/defaults.ts";
2170
2210
 
2171
2211
  export function updateAgentProfile(
2172
2212
  id: string,
@@ -2175,51 +2215,110 @@ export function updateAgentProfile(
2175
2215
  role?: string;
2176
2216
  capabilities?: string[];
2177
2217
  claudeMd?: string;
2218
+ soulMd?: string;
2219
+ identityMd?: string;
2220
+ setupScript?: string;
2221
+ toolsMd?: string;
2178
2222
  },
2223
+ meta?: VersionMeta,
2179
2224
  ): Agent | null {
2180
- const agent = getAgentById(id);
2181
- if (!agent) return null;
2225
+ const database = getDb();
2182
2226
 
2183
- const now = new Date().toISOString();
2184
- const row = getDb()
2185
- .prepare<
2186
- AgentRow,
2187
- [string | null, string | null, string | null, string | null, string, string]
2188
- >(
2189
- `UPDATE agents SET
2190
- description = COALESCE(?, description),
2191
- role = COALESCE(?, role),
2192
- capabilities = COALESCE(?, capabilities),
2193
- claudeMd = COALESCE(?, claudeMd),
2194
- lastUpdatedAt = ?
2195
- WHERE id = ? RETURNING *`,
2196
- )
2197
- .get(
2198
- updates.description ?? null,
2199
- updates.role ?? null,
2200
- updates.capabilities ? JSON.stringify(updates.capabilities) : null,
2201
- updates.claudeMd ?? null,
2202
- now,
2203
- id,
2204
- );
2227
+ return database.transaction(() => {
2228
+ // Get current agent state for version comparison
2229
+ const current = database
2230
+ .prepare<AgentRow, [string]>("SELECT * FROM agents WHERE id = ?")
2231
+ .get(id);
2232
+ if (!current) return null;
2205
2233
 
2206
- return row ? rowToAgent(row) : null;
2207
- }
2234
+ // Create context versions for changed fields
2235
+ for (const field of VERSIONABLE_FIELDS) {
2236
+ const newValue = updates[field];
2237
+ if (newValue === undefined || newValue === null) continue;
2208
2238
 
2209
- export function updateAgentName(id: string, newName: string): Agent | null {
2210
- // Check if another agent already has this name
2211
- const existingAgent = getDb()
2212
- .prepare<AgentRow, [string, string]>("SELECT * FROM agents WHERE name = ? AND id != ?")
2213
- .get(newName, id);
2239
+ const currentValue = current[field] ?? "";
2240
+ const newHash = computeContentHash(newValue);
2241
+ const currentHash = computeContentHash(currentValue);
2214
2242
 
2215
- if (existingAgent) {
2216
- throw new Error("Agent name already exists");
2217
- }
2243
+ if (newHash === currentHash) continue; // No actual change
2218
2244
 
2219
- const now = new Date().toISOString();
2220
- const row = getDb()
2221
- .prepare<AgentRow, [string, string, string]>(
2222
- "UPDATE agents SET name = ?, lastUpdatedAt = ? WHERE id = ? RETURNING *",
2245
+ const latestVersion = getLatestContextVersion(id, field);
2246
+ const version = (latestVersion?.version ?? 0) + 1;
2247
+
2248
+ createContextVersion({
2249
+ agentId: id,
2250
+ field,
2251
+ content: newValue,
2252
+ version,
2253
+ changeSource: meta?.changeSource ?? "api",
2254
+ changedByAgentId: meta?.changedByAgentId ?? null,
2255
+ changeReason: meta?.changeReason ?? null,
2256
+ contentHash: newHash,
2257
+ previousVersionId: latestVersion?.id ?? null,
2258
+ });
2259
+ }
2260
+
2261
+ // Proceed with existing UPDATE logic
2262
+ const now = new Date().toISOString();
2263
+ const row = database
2264
+ .prepare<
2265
+ AgentRow,
2266
+ [
2267
+ string | null,
2268
+ string | null,
2269
+ string | null,
2270
+ string | null,
2271
+ string | null,
2272
+ string | null,
2273
+ string | null,
2274
+ string | null,
2275
+ string,
2276
+ string,
2277
+ ]
2278
+ >(
2279
+ `UPDATE agents SET
2280
+ description = COALESCE(?, description),
2281
+ role = COALESCE(?, role),
2282
+ capabilities = COALESCE(?, capabilities),
2283
+ claudeMd = COALESCE(?, claudeMd),
2284
+ soulMd = COALESCE(?, soulMd),
2285
+ identityMd = COALESCE(?, identityMd),
2286
+ setupScript = COALESCE(?, setupScript),
2287
+ toolsMd = COALESCE(?, toolsMd),
2288
+ lastUpdatedAt = ?
2289
+ WHERE id = ? RETURNING *`,
2290
+ )
2291
+ .get(
2292
+ updates.description ?? null,
2293
+ updates.role ?? null,
2294
+ updates.capabilities ? JSON.stringify(updates.capabilities) : null,
2295
+ updates.claudeMd ?? null,
2296
+ updates.soulMd ?? null,
2297
+ updates.identityMd ?? null,
2298
+ updates.setupScript ?? null,
2299
+ updates.toolsMd ?? null,
2300
+ now,
2301
+ id,
2302
+ );
2303
+
2304
+ return row ? rowToAgent(row) : null;
2305
+ })();
2306
+ }
2307
+
2308
+ export function updateAgentName(id: string, newName: string): Agent | null {
2309
+ // Check if another agent already has this name
2310
+ const existingAgent = getDb()
2311
+ .prepare<AgentRow, [string, string]>("SELECT * FROM agents WHERE name = ? AND id != ?")
2312
+ .get(newName, id);
2313
+
2314
+ if (existingAgent) {
2315
+ throw new Error("Agent name already exists");
2316
+ }
2317
+
2318
+ const now = new Date().toISOString();
2319
+ const row = getDb()
2320
+ .prepare<AgentRow, [string, string, string]>(
2321
+ "UPDATE agents SET name = ?, lastUpdatedAt = ? WHERE id = ? RETURNING *",
2223
2322
  )
2224
2323
  .get(newName, now, id);
2225
2324
 
@@ -2337,6 +2436,11 @@ export function getAllChannels(): Channel[] {
2337
2436
  .map(rowToChannel);
2338
2437
  }
2339
2438
 
2439
+ export function deleteChannel(id: string): boolean {
2440
+ const result = getDb().prepare("DELETE FROM channels WHERE id = ?").run(id);
2441
+ return result.changes > 0;
2442
+ }
2443
+
2340
2444
  export function postMessage(
2341
2445
  channelId: string,
2342
2446
  agentId: string | null,
@@ -3183,8 +3287,8 @@ const sessionCostQueries = {
3183
3287
  ),
3184
3288
 
3185
3289
  getByTaskId: () =>
3186
- getDb().prepare<SessionCostRow, [string]>(
3187
- "SELECT * FROM session_costs WHERE taskId = ? ORDER BY createdAt DESC",
3290
+ getDb().prepare<SessionCostRow, [string, number]>(
3291
+ "SELECT * FROM session_costs WHERE taskId = ? ORDER BY createdAt DESC LIMIT ?",
3188
3292
  ),
3189
3293
 
3190
3294
  getByAgentId: () =>
@@ -3251,8 +3355,8 @@ export function createSessionCost(input: CreateSessionCostInput): SessionCost {
3251
3355
  };
3252
3356
  }
3253
3357
 
3254
- export function getSessionCostsByTaskId(taskId: string): SessionCost[] {
3255
- return sessionCostQueries.getByTaskId().all(taskId).map(rowToSessionCost);
3358
+ export function getSessionCostsByTaskId(taskId: string, limit = 500): SessionCost[] {
3359
+ return sessionCostQueries.getByTaskId().all(taskId, limit).map(rowToSessionCost);
3256
3360
  }
3257
3361
 
3258
3362
  export function getSessionCostsByAgentId(agentId: string, limit = 100): SessionCost[] {
@@ -3263,6 +3367,224 @@ export function getAllSessionCosts(limit = 100): SessionCost[] {
3263
3367
  return sessionCostQueries.getAll().all(limit).map(rowToSessionCost);
3264
3368
  }
3265
3369
 
3370
+ // --- Date-filtered session costs (P1) ---
3371
+
3372
+ export function getSessionCostsFiltered(opts: {
3373
+ agentId?: string;
3374
+ startDate?: string;
3375
+ endDate?: string;
3376
+ limit?: number;
3377
+ }): SessionCost[] {
3378
+ const conditions: string[] = [];
3379
+ const params: (string | number)[] = [];
3380
+
3381
+ if (opts.agentId) {
3382
+ conditions.push("agentId = ?");
3383
+ params.push(opts.agentId);
3384
+ }
3385
+ if (opts.startDate) {
3386
+ conditions.push("createdAt >= ?");
3387
+ params.push(opts.startDate);
3388
+ }
3389
+ if (opts.endDate) {
3390
+ conditions.push("createdAt <= ?");
3391
+ params.push(opts.endDate);
3392
+ }
3393
+
3394
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3395
+ const limit = opts.limit ?? 100;
3396
+ params.push(limit);
3397
+
3398
+ return getDb()
3399
+ .prepare<SessionCostRow, (string | number)[]>(
3400
+ `SELECT * FROM session_costs ${where} ORDER BY createdAt DESC LIMIT ?`,
3401
+ )
3402
+ .all(...params)
3403
+ .map(rowToSessionCost);
3404
+ }
3405
+
3406
+ // --- Aggregation queries (P0) ---
3407
+
3408
+ export interface SessionCostSummaryTotals {
3409
+ totalCostUsd: number;
3410
+ totalInputTokens: number;
3411
+ totalOutputTokens: number;
3412
+ totalCacheReadTokens: number;
3413
+ totalCacheWriteTokens: number;
3414
+ totalDurationMs: number;
3415
+ totalSessions: number;
3416
+ avgCostPerSession: number;
3417
+ }
3418
+
3419
+ export interface SessionCostDailyRow {
3420
+ date: string;
3421
+ costUsd: number;
3422
+ inputTokens: number;
3423
+ outputTokens: number;
3424
+ sessions: number;
3425
+ }
3426
+
3427
+ export interface SessionCostByAgentRow {
3428
+ agentId: string;
3429
+ costUsd: number;
3430
+ inputTokens: number;
3431
+ outputTokens: number;
3432
+ sessions: number;
3433
+ durationMs: number;
3434
+ }
3435
+
3436
+ export function getSessionCostSummary(opts: {
3437
+ startDate?: string;
3438
+ endDate?: string;
3439
+ agentId?: string;
3440
+ groupBy?: "day" | "agent" | "both";
3441
+ }): {
3442
+ totals: SessionCostSummaryTotals;
3443
+ daily: SessionCostDailyRow[];
3444
+ byAgent: SessionCostByAgentRow[];
3445
+ } {
3446
+ const conditions: string[] = [];
3447
+ const params: string[] = [];
3448
+
3449
+ if (opts.startDate) {
3450
+ conditions.push("createdAt >= ?");
3451
+ params.push(opts.startDate);
3452
+ }
3453
+ if (opts.endDate) {
3454
+ conditions.push("createdAt <= ?");
3455
+ params.push(opts.endDate);
3456
+ }
3457
+ if (opts.agentId) {
3458
+ conditions.push("agentId = ?");
3459
+ params.push(opts.agentId);
3460
+ }
3461
+
3462
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3463
+
3464
+ // Totals
3465
+ type TotalsRow = {
3466
+ totalCostUsd: number;
3467
+ totalInputTokens: number;
3468
+ totalOutputTokens: number;
3469
+ totalCacheReadTokens: number;
3470
+ totalCacheWriteTokens: number;
3471
+ totalDurationMs: number;
3472
+ totalSessions: number;
3473
+ };
3474
+
3475
+ const totalsRow = getDb()
3476
+ .prepare<TotalsRow, string[]>(
3477
+ `SELECT
3478
+ COALESCE(SUM(totalCostUsd), 0) as totalCostUsd,
3479
+ COALESCE(SUM(inputTokens), 0) as totalInputTokens,
3480
+ COALESCE(SUM(outputTokens), 0) as totalOutputTokens,
3481
+ COALESCE(SUM(cacheReadTokens), 0) as totalCacheReadTokens,
3482
+ COALESCE(SUM(cacheWriteTokens), 0) as totalCacheWriteTokens,
3483
+ COALESCE(SUM(durationMs), 0) as totalDurationMs,
3484
+ COUNT(*) as totalSessions
3485
+ FROM session_costs ${where}`,
3486
+ )
3487
+ .get(...params);
3488
+
3489
+ const totals: SessionCostSummaryTotals = totalsRow
3490
+ ? {
3491
+ ...totalsRow,
3492
+ avgCostPerSession:
3493
+ totalsRow.totalSessions > 0 ? totalsRow.totalCostUsd / totalsRow.totalSessions : 0,
3494
+ }
3495
+ : {
3496
+ totalCostUsd: 0,
3497
+ totalInputTokens: 0,
3498
+ totalOutputTokens: 0,
3499
+ totalCacheReadTokens: 0,
3500
+ totalCacheWriteTokens: 0,
3501
+ totalDurationMs: 0,
3502
+ totalSessions: 0,
3503
+ avgCostPerSession: 0,
3504
+ };
3505
+
3506
+ // Daily breakdown
3507
+ const groupBy = opts.groupBy ?? "both";
3508
+ let daily: SessionCostDailyRow[] = [];
3509
+ if (groupBy === "day" || groupBy === "both") {
3510
+ daily = getDb()
3511
+ .prepare<
3512
+ {
3513
+ date: string;
3514
+ costUsd: number;
3515
+ inputTokens: number;
3516
+ outputTokens: number;
3517
+ sessions: number;
3518
+ },
3519
+ string[]
3520
+ >(
3521
+ `SELECT
3522
+ DATE(createdAt) as date,
3523
+ COALESCE(SUM(totalCostUsd), 0) as costUsd,
3524
+ COALESCE(SUM(inputTokens), 0) as inputTokens,
3525
+ COALESCE(SUM(outputTokens), 0) as outputTokens,
3526
+ COUNT(*) as sessions
3527
+ FROM session_costs ${where}
3528
+ GROUP BY DATE(createdAt)
3529
+ ORDER BY date ASC`,
3530
+ )
3531
+ .all(...params);
3532
+ }
3533
+
3534
+ // Per-agent breakdown
3535
+ let byAgent: SessionCostByAgentRow[] = [];
3536
+ if (groupBy === "agent" || groupBy === "both") {
3537
+ byAgent = getDb()
3538
+ .prepare<
3539
+ {
3540
+ agentId: string;
3541
+ costUsd: number;
3542
+ inputTokens: number;
3543
+ outputTokens: number;
3544
+ sessions: number;
3545
+ durationMs: number;
3546
+ },
3547
+ string[]
3548
+ >(
3549
+ `SELECT
3550
+ agentId,
3551
+ COALESCE(SUM(totalCostUsd), 0) as costUsd,
3552
+ COALESCE(SUM(inputTokens), 0) as inputTokens,
3553
+ COALESCE(SUM(outputTokens), 0) as outputTokens,
3554
+ COUNT(*) as sessions,
3555
+ COALESCE(SUM(durationMs), 0) as durationMs
3556
+ FROM session_costs ${where}
3557
+ GROUP BY agentId
3558
+ ORDER BY costUsd DESC`,
3559
+ )
3560
+ .all(...params);
3561
+ }
3562
+
3563
+ return { totals, daily, byAgent };
3564
+ }
3565
+
3566
+ // --- Dashboard cost summary (P4) ---
3567
+
3568
+ export interface DashboardCostSummary {
3569
+ costToday: number;
3570
+ costMtd: number;
3571
+ }
3572
+
3573
+ export function getDashboardCostSummary(): DashboardCostSummary {
3574
+ type CostRow = { costToday: number; costMtd: number };
3575
+ const row = getDb()
3576
+ .prepare<CostRow, []>(
3577
+ `SELECT
3578
+ COALESCE(SUM(CASE WHEN createdAt >= date('now') THEN totalCostUsd ELSE 0 END), 0) as costToday,
3579
+ COALESCE(SUM(totalCostUsd), 0) as costMtd
3580
+ FROM session_costs
3581
+ WHERE createdAt >= date('now', 'start of month')`,
3582
+ )
3583
+ .get();
3584
+
3585
+ return row ?? { costToday: 0, costMtd: 0 };
3586
+ }
3587
+
3266
3588
  // ============================================================================
3267
3589
  // Inbox Message Operations
3268
3590
  // ============================================================================
@@ -3302,7 +3624,7 @@ function rowToInboxMessage(row: InboxMessageRow): InboxMessage {
3302
3624
  }
3303
3625
 
3304
3626
  export interface CreateInboxMessageOptions {
3305
- source?: "slack";
3627
+ source?: "slack" | "agentmail";
3306
3628
  slackChannelId?: string;
3307
3629
  slackThreadTs?: string;
3308
3630
  slackUserId?: string;
@@ -3435,6 +3757,115 @@ export function releaseStaleProcessingInbox(timeoutMinutes: number = 30): number
3435
3757
  return result.changes;
3436
3758
  }
3437
3759
 
3760
+ // ============================================================================
3761
+ // Concurrent Context (for lead session awareness)
3762
+ // ============================================================================
3763
+
3764
+ export interface ConcurrentContext {
3765
+ processingInboxMessages: Array<{
3766
+ id: string;
3767
+ content: string;
3768
+ source: string;
3769
+ slackChannelId: string | null;
3770
+ slackThreadTs: string | null;
3771
+ createdAt: string;
3772
+ }>;
3773
+ recentTaskDelegations: Array<{
3774
+ id: string;
3775
+ task: string;
3776
+ agentId: string | null;
3777
+ agentName: string | null;
3778
+ creatorAgentId: string | null;
3779
+ status: string;
3780
+ createdAt: string;
3781
+ }>;
3782
+ activeSwarmTasks: Array<{
3783
+ id: string;
3784
+ task: string;
3785
+ agentId: string | null;
3786
+ agentName: string | null;
3787
+ status: string;
3788
+ createdAt: string;
3789
+ progress: string | null;
3790
+ }>;
3791
+ }
3792
+
3793
+ /**
3794
+ * Get concurrent context for lead session awareness.
3795
+ * Returns processing inbox messages, recent task delegations by leads,
3796
+ * and currently active (in-progress) tasks across the swarm.
3797
+ */
3798
+ export function getConcurrentContext(): ConcurrentContext {
3799
+ // 1. Inbox messages currently being processed (status = 'processing')
3800
+ const processingInboxMessages = getDb()
3801
+ .prepare<
3802
+ {
3803
+ id: string;
3804
+ content: string;
3805
+ source: string;
3806
+ slackChannelId: string | null;
3807
+ slackThreadTs: string | null;
3808
+ createdAt: string;
3809
+ },
3810
+ []
3811
+ >(
3812
+ "SELECT id, content, source, slackChannelId, slackThreadTs, createdAt FROM inbox_messages WHERE status = 'processing' ORDER BY createdAt DESC",
3813
+ )
3814
+ .all();
3815
+
3816
+ // 2. Tasks created in the last 5 minutes by lead agents
3817
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
3818
+ const recentTaskDelegations = getDb()
3819
+ .prepare<
3820
+ {
3821
+ id: string;
3822
+ task: string;
3823
+ agentId: string | null;
3824
+ agentName: string | null;
3825
+ creatorAgentId: string | null;
3826
+ status: string;
3827
+ createdAt: string;
3828
+ },
3829
+ [string]
3830
+ >(
3831
+ `SELECT t.id, t.task, t.agentId, a.name as agentName, t.creatorAgentId, t.status, t.createdAt
3832
+ FROM agent_tasks t
3833
+ LEFT JOIN agents a ON t.agentId = a.id
3834
+ WHERE t.createdAt > ?
3835
+ AND t.creatorAgentId IN (SELECT id FROM agents WHERE isLead = 1)
3836
+ ORDER BY t.createdAt DESC`,
3837
+ )
3838
+ .all(fiveMinutesAgo);
3839
+
3840
+ // 3. Currently in-progress tasks across the swarm
3841
+ const activeSwarmTasks = getDb()
3842
+ .prepare<
3843
+ {
3844
+ id: string;
3845
+ task: string;
3846
+ agentId: string | null;
3847
+ agentName: string | null;
3848
+ status: string;
3849
+ createdAt: string;
3850
+ progress: string | null;
3851
+ },
3852
+ []
3853
+ >(
3854
+ `SELECT t.id, t.task, t.agentId, a.name as agentName, t.status, t.createdAt, t.progress
3855
+ FROM agent_tasks t
3856
+ LEFT JOIN agents a ON t.agentId = a.id
3857
+ WHERE t.status = 'in_progress'
3858
+ ORDER BY t.createdAt DESC`,
3859
+ )
3860
+ .all();
3861
+
3862
+ return {
3863
+ processingInboxMessages,
3864
+ recentTaskDelegations,
3865
+ activeSwarmTasks,
3866
+ };
3867
+ }
3868
+
3438
3869
  // ============================================================================
3439
3870
  // Scheduled Task Queries
3440
3871
  // ============================================================================
@@ -3455,6 +3886,11 @@ type ScheduledTaskRow = {
3455
3886
  nextRunAt: string | null;
3456
3887
  createdByAgentId: string | null;
3457
3888
  timezone: string;
3889
+ consecutiveErrors: number | null;
3890
+ lastErrorAt: string | null;
3891
+ lastErrorMessage: string | null;
3892
+ model: string | null;
3893
+ scheduleType: string;
3458
3894
  createdAt: string;
3459
3895
  lastUpdatedAt: string;
3460
3896
  };
@@ -3476,6 +3912,11 @@ function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
3476
3912
  nextRunAt: row.nextRunAt ?? undefined,
3477
3913
  createdByAgentId: row.createdByAgentId ?? undefined,
3478
3914
  timezone: row.timezone,
3915
+ consecutiveErrors: row.consecutiveErrors ?? 0,
3916
+ lastErrorAt: row.lastErrorAt ?? undefined,
3917
+ lastErrorMessage: row.lastErrorMessage ?? undefined,
3918
+ model: (row.model as "haiku" | "sonnet" | "opus" | null) ?? undefined,
3919
+ scheduleType: row.scheduleType as "recurring" | "one_time",
3479
3920
  createdAt: row.createdAt,
3480
3921
  lastUpdatedAt: row.lastUpdatedAt,
3481
3922
  };
@@ -3484,6 +3925,8 @@ function rowToScheduledTask(row: ScheduledTaskRow): ScheduledTask {
3484
3925
  export interface ScheduledTaskFilters {
3485
3926
  enabled?: boolean;
3486
3927
  name?: string;
3928
+ scheduleType?: "recurring" | "one_time";
3929
+ hideCompleted?: boolean;
3487
3930
  }
3488
3931
 
3489
3932
  export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask[] {
@@ -3500,6 +3943,15 @@ export function getScheduledTasks(filters?: ScheduledTaskFilters): ScheduledTask
3500
3943
  params.push(`%${filters.name}%`);
3501
3944
  }
3502
3945
 
3946
+ if (filters?.scheduleType) {
3947
+ query += " AND scheduleType = ?";
3948
+ params.push(filters.scheduleType);
3949
+ }
3950
+
3951
+ if (filters?.hideCompleted !== false) {
3952
+ query += " AND NOT (scheduleType = 'one_time' AND enabled = 0)";
3953
+ }
3954
+
3503
3955
  query += " ORDER BY name ASC";
3504
3956
 
3505
3957
  return getDb()
@@ -3536,6 +3988,8 @@ export interface CreateScheduledTaskData {
3536
3988
  nextRunAt?: string;
3537
3989
  createdByAgentId?: string;
3538
3990
  timezone?: string;
3991
+ model?: string;
3992
+ scheduleType?: "recurring" | "one_time";
3539
3993
  }
3540
3994
 
3541
3995
  export function createScheduledTask(data: CreateScheduledTaskData): ScheduledTask {
@@ -3547,8 +4001,8 @@ export function createScheduledTask(data: CreateScheduledTaskData): ScheduledTas
3547
4001
  `INSERT INTO scheduled_tasks (
3548
4002
  id, name, description, cronExpression, intervalMs, taskTemplate,
3549
4003
  taskType, tags, priority, targetAgentId, enabled, nextRunAt,
3550
- createdByAgentId, timezone, createdAt, lastUpdatedAt
3551
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
4004
+ createdByAgentId, timezone, model, scheduleType, createdAt, lastUpdatedAt
4005
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
3552
4006
  )
3553
4007
  .get(
3554
4008
  id,
@@ -3565,6 +4019,8 @@ export function createScheduledTask(data: CreateScheduledTaskData): ScheduledTas
3565
4019
  data.nextRunAt ?? null,
3566
4020
  data.createdByAgentId ?? null,
3567
4021
  data.timezone ?? "UTC",
4022
+ data.model ?? null,
4023
+ data.scheduleType ?? "recurring",
3568
4024
  now,
3569
4025
  now,
3570
4026
  );
@@ -3585,8 +4041,13 @@ export interface UpdateScheduledTaskData {
3585
4041
  targetAgentId?: string | null;
3586
4042
  enabled?: boolean;
3587
4043
  lastRunAt?: string;
3588
- nextRunAt?: string;
4044
+ nextRunAt?: string | null;
3589
4045
  timezone?: string;
4046
+ consecutiveErrors?: number;
4047
+ lastErrorAt?: string | null;
4048
+ lastErrorMessage?: string | null;
4049
+ model?: string | null;
4050
+ scheduleType?: "recurring" | "one_time";
3590
4051
  lastUpdatedAt?: string;
3591
4052
  }
3592
4053
 
@@ -3649,6 +4110,26 @@ export function updateScheduledTask(
3649
4110
  updates.push("timezone = ?");
3650
4111
  params.push(data.timezone);
3651
4112
  }
4113
+ if (data.consecutiveErrors !== undefined) {
4114
+ updates.push("consecutiveErrors = ?");
4115
+ params.push(data.consecutiveErrors);
4116
+ }
4117
+ if (data.lastErrorAt !== undefined) {
4118
+ updates.push("lastErrorAt = ?");
4119
+ params.push(data.lastErrorAt);
4120
+ }
4121
+ if (data.lastErrorMessage !== undefined) {
4122
+ updates.push("lastErrorMessage = ?");
4123
+ params.push(data.lastErrorMessage);
4124
+ }
4125
+ if (data.model !== undefined) {
4126
+ updates.push("model = ?");
4127
+ params.push(data.model);
4128
+ }
4129
+ if (data.scheduleType !== undefined) {
4130
+ updates.push("scheduleType = ?");
4131
+ params.push(data.scheduleType);
4132
+ }
3652
4133
 
3653
4134
  if (updates.length === 0) {
3654
4135
  return getScheduledTaskById(id);
@@ -3710,8 +4191,10 @@ type EpicRow = {
3710
4191
  planDocPath: string | null;
3711
4192
  slackChannelId: string | null;
3712
4193
  slackThreadTs: string | null;
3713
- githubRepo: string | null;
3714
- githubMilestone: string | null;
4194
+ vcsProvider: string | null;
4195
+ vcsRepo: string | null;
4196
+ vcsMilestone: string | null;
4197
+ nextSteps: string | null;
3715
4198
  createdAt: string;
3716
4199
  lastUpdatedAt: string;
3717
4200
  startedAt: string | null;
@@ -3737,8 +4220,10 @@ function rowToEpic(row: EpicRow): Epic {
3737
4220
  planDocPath: row.planDocPath ?? undefined,
3738
4221
  slackChannelId: row.slackChannelId ?? undefined,
3739
4222
  slackThreadTs: row.slackThreadTs ?? undefined,
3740
- githubRepo: row.githubRepo ?? undefined,
3741
- githubMilestone: row.githubMilestone ?? undefined,
4223
+ vcsProvider: (row.vcsProvider as "github" | "gitlab" | null) ?? undefined,
4224
+ vcsRepo: row.vcsRepo ?? undefined,
4225
+ vcsMilestone: row.vcsMilestone ?? undefined,
4226
+ nextSteps: row.nextSteps ?? undefined,
3742
4227
  createdAt: row.createdAt,
3743
4228
  lastUpdatedAt: row.lastUpdatedAt,
3744
4229
  startedAt: row.startedAt ?? undefined,
@@ -3814,8 +4299,9 @@ export interface CreateEpicData {
3814
4299
  planDocPath?: string;
3815
4300
  slackChannelId?: string;
3816
4301
  slackThreadTs?: string;
3817
- githubRepo?: string;
3818
- githubMilestone?: string;
4302
+ vcsProvider?: "github" | "gitlab";
4303
+ vcsRepo?: string;
4304
+ vcsMilestone?: string;
3819
4305
  }
3820
4306
 
3821
4307
  export function createEpic(data: CreateEpicData): Epic {
@@ -3838,9 +4324,9 @@ export function createEpic(data: CreateEpicData): Epic {
3838
4324
  `INSERT INTO epics (
3839
4325
  id, name, description, goal, prd, plan, status, priority, tags,
3840
4326
  createdByAgentId, leadAgentId, channelId, researchDocPath, planDocPath,
3841
- slackChannelId, slackThreadTs, githubRepo, githubMilestone,
4327
+ slackChannelId, slackThreadTs, vcsProvider, vcsRepo, vcsMilestone,
3842
4328
  createdAt, lastUpdatedAt
3843
- ) VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
4329
+ ) VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
3844
4330
  )
3845
4331
  .get(
3846
4332
  id,
@@ -3858,8 +4344,9 @@ export function createEpic(data: CreateEpicData): Epic {
3858
4344
  data.planDocPath ?? null,
3859
4345
  data.slackChannelId ?? null,
3860
4346
  data.slackThreadTs ?? null,
3861
- data.githubRepo ?? null,
3862
- data.githubMilestone ?? null,
4347
+ data.vcsProvider ?? null,
4348
+ data.vcsRepo ?? null,
4349
+ data.vcsMilestone ?? null,
3863
4350
  now,
3864
4351
  now,
3865
4352
  );
@@ -3898,8 +4385,10 @@ export interface UpdateEpicData {
3898
4385
  planDocPath?: string;
3899
4386
  slackChannelId?: string;
3900
4387
  slackThreadTs?: string;
3901
- githubRepo?: string;
3902
- githubMilestone?: string;
4388
+ vcsProvider?: "github" | "gitlab";
4389
+ vcsRepo?: string;
4390
+ vcsMilestone?: string;
4391
+ nextSteps?: string;
3903
4392
  }
3904
4393
 
3905
4394
  export function updateEpic(id: string, data: UpdateEpicData): Epic | null {
@@ -3973,13 +4462,21 @@ export function updateEpic(id: string, data: UpdateEpicData): Epic | null {
3973
4462
  updates.push("slackThreadTs = ?");
3974
4463
  params.push(data.slackThreadTs);
3975
4464
  }
3976
- if (data.githubRepo !== undefined) {
3977
- updates.push("githubRepo = ?");
3978
- params.push(data.githubRepo);
4465
+ if (data.vcsProvider !== undefined) {
4466
+ updates.push("vcsProvider = ?");
4467
+ params.push(data.vcsProvider);
4468
+ }
4469
+ if (data.vcsRepo !== undefined) {
4470
+ updates.push("vcsRepo = ?");
4471
+ params.push(data.vcsRepo);
4472
+ }
4473
+ if (data.vcsMilestone !== undefined) {
4474
+ updates.push("vcsMilestone = ?");
4475
+ params.push(data.vcsMilestone);
3979
4476
  }
3980
- if (data.githubMilestone !== undefined) {
3981
- updates.push("githubMilestone = ?");
3982
- params.push(data.githubMilestone);
4477
+ if (data.nextSteps !== undefined) {
4478
+ updates.push("nextSteps = ?");
4479
+ params.push(data.nextSteps);
3983
4480
  }
3984
4481
 
3985
4482
  params.push(id);
@@ -4179,3 +4676,2049 @@ export function markEpicsProgressNotified(epicIds: string[]): number {
4179
4676
 
4180
4677
  return result.changes;
4181
4678
  }
4679
+
4680
+ // ============================================================================
4681
+ // Swarm Config Operations (Centralized Environment/Config Management)
4682
+ // ============================================================================
4683
+
4684
+ type SwarmConfigRow = {
4685
+ id: string;
4686
+ scope: string;
4687
+ scopeId: string | null;
4688
+ key: string;
4689
+ value: string;
4690
+ isSecret: number; // SQLite boolean
4691
+ envPath: string | null;
4692
+ description: string | null;
4693
+ createdAt: string;
4694
+ lastUpdatedAt: string;
4695
+ };
4696
+
4697
+ function rowToSwarmConfig(row: SwarmConfigRow): SwarmConfig {
4698
+ return {
4699
+ id: row.id,
4700
+ scope: row.scope as "global" | "agent" | "repo",
4701
+ scopeId: row.scopeId ?? null,
4702
+ key: row.key,
4703
+ value: row.value,
4704
+ isSecret: row.isSecret === 1,
4705
+ envPath: row.envPath ?? null,
4706
+ description: row.description ?? null,
4707
+ createdAt: row.createdAt,
4708
+ lastUpdatedAt: row.lastUpdatedAt,
4709
+ };
4710
+ }
4711
+
4712
+ /**
4713
+ * Mask secret values in config entries for API responses.
4714
+ */
4715
+ export function maskSecrets(configs: SwarmConfig[]): SwarmConfig[] {
4716
+ return configs.map((c) => (c.isSecret ? { ...c, value: "********" } : c));
4717
+ }
4718
+
4719
+ /**
4720
+ * Write config values to .env files on disk when `envPath` is set.
4721
+ * Groups configs by envPath, reads existing file, updates/adds matching keys, writes back.
4722
+ */
4723
+ function writeEnvFile(configs: SwarmConfig[]): void {
4724
+ const { readFileSync, writeFileSync } = require("node:fs");
4725
+
4726
+ const byPath = new Map<string, SwarmConfig[]>();
4727
+ for (const config of configs) {
4728
+ if (!config.envPath) continue;
4729
+ const existing = byPath.get(config.envPath) ?? [];
4730
+ existing.push(config);
4731
+ byPath.set(config.envPath, existing);
4732
+ }
4733
+
4734
+ for (const [envPath, entries] of byPath) {
4735
+ let lines: string[] = [];
4736
+ try {
4737
+ const content = readFileSync(envPath, "utf-8") as string;
4738
+ lines = content.split("\n");
4739
+ } catch {
4740
+ // File doesn't exist yet, start empty
4741
+ }
4742
+
4743
+ for (const entry of entries) {
4744
+ const prefix = `${entry.key}=`;
4745
+ const lineIndex = lines.findIndex((l) => l.startsWith(prefix));
4746
+ const newLine = `${entry.key}=${entry.value}`;
4747
+ if (lineIndex >= 0) {
4748
+ lines[lineIndex] = newLine;
4749
+ } else {
4750
+ lines.push(newLine);
4751
+ }
4752
+ }
4753
+
4754
+ const output = `${lines.filter((l) => l !== "").join("\n")}\n`;
4755
+ writeFileSync(envPath, output, "utf-8");
4756
+ }
4757
+ }
4758
+
4759
+ /**
4760
+ * List config entries with optional filters.
4761
+ */
4762
+ export function getSwarmConfigs(filters?: {
4763
+ scope?: string;
4764
+ scopeId?: string;
4765
+ key?: string;
4766
+ }): SwarmConfig[] {
4767
+ const conditions: string[] = [];
4768
+ const params: string[] = [];
4769
+
4770
+ if (filters?.scope) {
4771
+ conditions.push("scope = ?");
4772
+ params.push(filters.scope);
4773
+ }
4774
+ if (filters?.scopeId) {
4775
+ conditions.push("scopeId = ?");
4776
+ params.push(filters.scopeId);
4777
+ }
4778
+ if (filters?.key) {
4779
+ conditions.push("key = ?");
4780
+ params.push(filters.key);
4781
+ }
4782
+
4783
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
4784
+ const query = `SELECT * FROM swarm_config ${whereClause} ORDER BY key ASC`;
4785
+
4786
+ return getDb()
4787
+ .prepare<SwarmConfigRow, string[]>(query)
4788
+ .all(...params)
4789
+ .map(rowToSwarmConfig);
4790
+ }
4791
+
4792
+ /**
4793
+ * Get a single config entry by ID.
4794
+ */
4795
+ export function getSwarmConfigById(id: string): SwarmConfig | null {
4796
+ const row = getDb()
4797
+ .prepare<SwarmConfigRow, [string]>("SELECT * FROM swarm_config WHERE id = ?")
4798
+ .get(id);
4799
+ return row ? rowToSwarmConfig(row) : null;
4800
+ }
4801
+
4802
+ /**
4803
+ * Upsert a config entry. Inserts or updates by (scope, scopeId, key) unique constraint.
4804
+ */
4805
+ export function upsertSwarmConfig(data: {
4806
+ scope: "global" | "agent" | "repo";
4807
+ scopeId?: string | null;
4808
+ key: string;
4809
+ value: string;
4810
+ isSecret?: boolean;
4811
+ envPath?: string | null;
4812
+ description?: string | null;
4813
+ }): SwarmConfig {
4814
+ const now = new Date().toISOString();
4815
+ const scopeId = data.scope === "global" ? null : (data.scopeId ?? null);
4816
+ const isSecret = data.isSecret ? 1 : 0;
4817
+ const envPath = data.envPath ?? null;
4818
+ const description = data.description ?? null;
4819
+
4820
+ // Manual check for existing entry because SQLite's UNIQUE constraint
4821
+ // treats NULL != NULL, so ON CONFLICT never fires when scopeId is NULL (global scope).
4822
+ const existing =
4823
+ scopeId === null
4824
+ ? getDb()
4825
+ .prepare<{ id: string }, [string, string]>(
4826
+ "SELECT id FROM swarm_config WHERE scope = ? AND scopeId IS NULL AND key = ?",
4827
+ )
4828
+ .get(data.scope, data.key)
4829
+ : getDb()
4830
+ .prepare<{ id: string }, [string, string, string]>(
4831
+ "SELECT id FROM swarm_config WHERE scope = ? AND scopeId = ? AND key = ?",
4832
+ )
4833
+ .get(data.scope, scopeId, data.key);
4834
+
4835
+ let row: SwarmConfigRow | null;
4836
+
4837
+ if (existing) {
4838
+ row = getDb()
4839
+ .prepare<SwarmConfigRow, [string, number, string | null, string | null, string, string]>(
4840
+ `UPDATE swarm_config SET value = ?, isSecret = ?, envPath = ?, description = ?, lastUpdatedAt = ?
4841
+ WHERE id = ? RETURNING *`,
4842
+ )
4843
+ .get(data.value, isSecret, envPath, description, now, existing.id);
4844
+ } else {
4845
+ const id = crypto.randomUUID();
4846
+ row = getDb()
4847
+ .prepare<
4848
+ SwarmConfigRow,
4849
+ [
4850
+ string,
4851
+ string,
4852
+ string | null,
4853
+ string,
4854
+ string,
4855
+ number,
4856
+ string | null,
4857
+ string | null,
4858
+ string,
4859
+ string,
4860
+ ]
4861
+ >(
4862
+ `INSERT INTO swarm_config (id, scope, scopeId, key, value, isSecret, envPath, description, createdAt, lastUpdatedAt)
4863
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
4864
+ )
4865
+ .get(id, data.scope, scopeId, data.key, data.value, isSecret, envPath, description, now, now);
4866
+ }
4867
+
4868
+ if (!row) throw new Error("Failed to upsert swarm config");
4869
+
4870
+ const config = rowToSwarmConfig(row);
4871
+
4872
+ // Write to envPath if set
4873
+ if (config.envPath) {
4874
+ try {
4875
+ writeEnvFile([config]);
4876
+ } catch (e) {
4877
+ console.error(`Failed to write env file ${config.envPath}:`, e);
4878
+ }
4879
+ }
4880
+
4881
+ return config;
4882
+ }
4883
+
4884
+ /**
4885
+ * Delete a config entry by ID.
4886
+ */
4887
+ export function deleteSwarmConfig(id: string): boolean {
4888
+ const result = getDb().run("DELETE FROM swarm_config WHERE id = ?", [id]);
4889
+ return result.changes > 0;
4890
+ }
4891
+
4892
+ /**
4893
+ * Get resolved (merged) config for a given agent and/or repo.
4894
+ * Scope resolution: repo > agent > global (most-specific wins).
4895
+ * Returns one entry per unique key with the most-specific scope winning.
4896
+ */
4897
+ export function getResolvedConfig(agentId?: string, repoId?: string): SwarmConfig[] {
4898
+ // Start with global configs
4899
+ const configMap = new Map<string, SwarmConfig>();
4900
+
4901
+ const globalConfigs = getSwarmConfigs({ scope: "global" });
4902
+ for (const config of globalConfigs) {
4903
+ configMap.set(config.key, config);
4904
+ }
4905
+
4906
+ // Overlay agent configs (agent wins over global)
4907
+ if (agentId) {
4908
+ const agentConfigs = getSwarmConfigs({ scope: "agent", scopeId: agentId });
4909
+ for (const config of agentConfigs) {
4910
+ configMap.set(config.key, config);
4911
+ }
4912
+ }
4913
+
4914
+ // Overlay repo configs (repo wins over agent and global)
4915
+ if (repoId) {
4916
+ const repoConfigs = getSwarmConfigs({ scope: "repo", scopeId: repoId });
4917
+ for (const config of repoConfigs) {
4918
+ configMap.set(config.key, config);
4919
+ }
4920
+ }
4921
+
4922
+ return Array.from(configMap.values()).sort((a, b) => a.key.localeCompare(b.key));
4923
+ }
4924
+
4925
+ // ============================================================================
4926
+ // Swarm Repos Functions (Centralized Repository Management)
4927
+ // ============================================================================
4928
+
4929
+ type SwarmRepoRow = {
4930
+ id: string;
4931
+ url: string;
4932
+ name: string;
4933
+ clonePath: string;
4934
+ defaultBranch: string;
4935
+ autoClone: number; // SQLite boolean
4936
+ createdAt: string;
4937
+ lastUpdatedAt: string;
4938
+ };
4939
+
4940
+ function rowToSwarmRepo(row: SwarmRepoRow): SwarmRepo {
4941
+ return {
4942
+ id: row.id,
4943
+ url: row.url,
4944
+ name: row.name,
4945
+ clonePath: row.clonePath,
4946
+ defaultBranch: row.defaultBranch,
4947
+ autoClone: row.autoClone === 1,
4948
+ createdAt: row.createdAt,
4949
+ lastUpdatedAt: row.lastUpdatedAt,
4950
+ };
4951
+ }
4952
+
4953
+ export function getSwarmRepos(filters?: { autoClone?: boolean; name?: string }): SwarmRepo[] {
4954
+ const conditions: string[] = [];
4955
+ const params: (string | number)[] = [];
4956
+
4957
+ if (filters?.autoClone !== undefined) {
4958
+ conditions.push("autoClone = ?");
4959
+ params.push(filters.autoClone ? 1 : 0);
4960
+ }
4961
+ if (filters?.name) {
4962
+ conditions.push("name = ?");
4963
+ params.push(filters.name);
4964
+ }
4965
+
4966
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
4967
+ const query = `SELECT * FROM swarm_repos ${whereClause} ORDER BY name ASC`;
4968
+
4969
+ return getDb()
4970
+ .prepare<SwarmRepoRow, (string | number)[]>(query)
4971
+ .all(...params)
4972
+ .map(rowToSwarmRepo);
4973
+ }
4974
+
4975
+ export function getSwarmRepoById(id: string): SwarmRepo | null {
4976
+ const row = getDb()
4977
+ .prepare<SwarmRepoRow, [string]>("SELECT * FROM swarm_repos WHERE id = ?")
4978
+ .get(id);
4979
+ return row ? rowToSwarmRepo(row) : null;
4980
+ }
4981
+
4982
+ export function getSwarmRepoByName(name: string): SwarmRepo | null {
4983
+ const row = getDb()
4984
+ .prepare<SwarmRepoRow, [string]>("SELECT * FROM swarm_repos WHERE name = ?")
4985
+ .get(name);
4986
+ return row ? rowToSwarmRepo(row) : null;
4987
+ }
4988
+
4989
+ export function getSwarmRepoByUrl(url: string): SwarmRepo | null {
4990
+ const row = getDb()
4991
+ .prepare<SwarmRepoRow, [string]>("SELECT * FROM swarm_repos WHERE url = ?")
4992
+ .get(url);
4993
+ return row ? rowToSwarmRepo(row) : null;
4994
+ }
4995
+
4996
+ export function createSwarmRepo(data: {
4997
+ url: string;
4998
+ name: string;
4999
+ clonePath?: string;
5000
+ defaultBranch?: string;
5001
+ autoClone?: boolean;
5002
+ }): SwarmRepo {
5003
+ const id = crypto.randomUUID();
5004
+ const now = new Date().toISOString();
5005
+ const clonePath = data.clonePath || `/workspace/repos/${data.name}`;
5006
+
5007
+ const row = getDb()
5008
+ .prepare<SwarmRepoRow, [string, string, string, string, string, number, string, string]>(
5009
+ `INSERT INTO swarm_repos (id, url, name, clonePath, defaultBranch, autoClone, createdAt, lastUpdatedAt)
5010
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
5011
+ )
5012
+ .get(
5013
+ id,
5014
+ data.url,
5015
+ data.name,
5016
+ clonePath,
5017
+ data.defaultBranch ?? "main",
5018
+ data.autoClone !== false ? 1 : 0,
5019
+ now,
5020
+ now,
5021
+ );
5022
+
5023
+ if (!row) throw new Error("Failed to create repo");
5024
+ return rowToSwarmRepo(row);
5025
+ }
5026
+
5027
+ export function updateSwarmRepo(
5028
+ id: string,
5029
+ updates: Partial<{
5030
+ url: string;
5031
+ name: string;
5032
+ clonePath: string;
5033
+ defaultBranch: string;
5034
+ autoClone: boolean;
5035
+ }>,
5036
+ ): SwarmRepo | null {
5037
+ const setClauses: string[] = [];
5038
+ const params: (string | number)[] = [];
5039
+
5040
+ const stringFields = ["url", "name", "clonePath", "defaultBranch"] as const;
5041
+ for (const field of stringFields) {
5042
+ if (updates[field] !== undefined) {
5043
+ setClauses.push(`${field} = ?`);
5044
+ params.push(updates[field]);
5045
+ }
5046
+ }
5047
+ if (updates.autoClone !== undefined) {
5048
+ setClauses.push("autoClone = ?");
5049
+ params.push(updates.autoClone ? 1 : 0);
5050
+ }
5051
+
5052
+ if (setClauses.length === 0) return getSwarmRepoById(id);
5053
+
5054
+ setClauses.push("lastUpdatedAt = ?");
5055
+ params.push(new Date().toISOString());
5056
+ params.push(id);
5057
+
5058
+ const row = getDb()
5059
+ .prepare<SwarmRepoRow, (string | number)[]>(
5060
+ `UPDATE swarm_repos SET ${setClauses.join(", ")} WHERE id = ? RETURNING *`,
5061
+ )
5062
+ .get(...params);
5063
+
5064
+ return row ? rowToSwarmRepo(row) : null;
5065
+ }
5066
+
5067
+ export function deleteSwarmRepo(id: string): boolean {
5068
+ const result = getDb().run("DELETE FROM swarm_repos WHERE id = ?", [id]);
5069
+ return result.changes > 0;
5070
+ }
5071
+
5072
+ // ============================================================================
5073
+ // Agent Memory Functions
5074
+ // ============================================================================
5075
+
5076
+ type AgentMemoryRow = {
5077
+ id: string;
5078
+ agentId: string | null;
5079
+ scope: string;
5080
+ name: string;
5081
+ content: string;
5082
+ summary: string | null;
5083
+ embedding: Buffer | null;
5084
+ source: string;
5085
+ sourceTaskId: string | null;
5086
+ sourcePath: string | null;
5087
+ chunkIndex: number;
5088
+ totalChunks: number;
5089
+ tags: string;
5090
+ createdAt: string;
5091
+ accessedAt: string;
5092
+ };
5093
+
5094
+ function rowToAgentMemory(row: AgentMemoryRow): AgentMemory {
5095
+ return {
5096
+ id: row.id,
5097
+ agentId: row.agentId,
5098
+ scope: row.scope as AgentMemoryScope,
5099
+ name: row.name,
5100
+ content: row.content,
5101
+ summary: row.summary,
5102
+ source: row.source as AgentMemorySource,
5103
+ sourceTaskId: row.sourceTaskId,
5104
+ sourcePath: row.sourcePath,
5105
+ chunkIndex: row.chunkIndex,
5106
+ totalChunks: row.totalChunks,
5107
+ tags: JSON.parse(row.tags || "[]"),
5108
+ createdAt: row.createdAt,
5109
+ accessedAt: row.accessedAt,
5110
+ };
5111
+ }
5112
+
5113
+ export interface CreateMemoryOptions {
5114
+ agentId?: string | null;
5115
+ scope: AgentMemoryScope;
5116
+ name: string;
5117
+ content: string;
5118
+ summary?: string | null;
5119
+ embedding?: Buffer | null;
5120
+ source: AgentMemorySource;
5121
+ sourceTaskId?: string | null;
5122
+ sourcePath?: string | null;
5123
+ chunkIndex?: number;
5124
+ totalChunks?: number;
5125
+ tags?: string[];
5126
+ }
5127
+
5128
+ export function createMemory(data: CreateMemoryOptions): AgentMemory {
5129
+ const id = crypto.randomUUID();
5130
+ const now = new Date().toISOString();
5131
+ const row = getDb()
5132
+ .prepare<
5133
+ AgentMemoryRow,
5134
+ [
5135
+ string,
5136
+ string | null,
5137
+ string,
5138
+ string,
5139
+ string,
5140
+ string | null,
5141
+ Buffer | null,
5142
+ string,
5143
+ string | null,
5144
+ string | null,
5145
+ number,
5146
+ number,
5147
+ string,
5148
+ string,
5149
+ string,
5150
+ ]
5151
+ >(
5152
+ `INSERT INTO agent_memory (id, agentId, scope, name, content, summary, embedding, source, sourceTaskId, sourcePath, chunkIndex, totalChunks, tags, createdAt, accessedAt)
5153
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
5154
+ )
5155
+ .get(
5156
+ id,
5157
+ data.agentId ?? null,
5158
+ data.scope,
5159
+ data.name,
5160
+ data.content,
5161
+ data.summary ?? null,
5162
+ data.embedding ?? null,
5163
+ data.source,
5164
+ data.sourceTaskId ?? null,
5165
+ data.sourcePath ?? null,
5166
+ data.chunkIndex ?? 0,
5167
+ data.totalChunks ?? 1,
5168
+ JSON.stringify(data.tags ?? []),
5169
+ now,
5170
+ now,
5171
+ );
5172
+
5173
+ if (!row) throw new Error("Failed to create memory");
5174
+ return rowToAgentMemory(row);
5175
+ }
5176
+
5177
+ export function getMemoryById(id: string): AgentMemory | null {
5178
+ const row = getDb()
5179
+ .prepare<AgentMemoryRow, [string]>("SELECT * FROM agent_memory WHERE id = ?")
5180
+ .get(id);
5181
+ if (!row) return null;
5182
+
5183
+ // Update accessedAt
5184
+ getDb()
5185
+ .prepare("UPDATE agent_memory SET accessedAt = ? WHERE id = ?")
5186
+ .run(new Date().toISOString(), id);
5187
+
5188
+ return rowToAgentMemory(row);
5189
+ }
5190
+
5191
+ export function updateMemoryEmbedding(id: string, embedding: Buffer): void {
5192
+ getDb().prepare("UPDATE agent_memory SET embedding = ? WHERE id = ?").run(embedding, id);
5193
+ }
5194
+
5195
+ export interface SearchMemoriesOptions {
5196
+ scope?: "agent" | "swarm" | "all";
5197
+ limit?: number;
5198
+ source?: AgentMemorySource;
5199
+ isLead?: boolean;
5200
+ }
5201
+
5202
+ export function searchMemoriesByVector(
5203
+ queryEmbedding: Float32Array,
5204
+ agentId: string,
5205
+ options: SearchMemoriesOptions = {},
5206
+ ): (AgentMemory & { similarity: number })[] {
5207
+ const { scope = "all", limit = 10, source, isLead = false } = options;
5208
+
5209
+ // Build WHERE clause
5210
+ const conditions: string[] = ["embedding IS NOT NULL"];
5211
+ const params: (string | null)[] = [];
5212
+
5213
+ if (!isLead) {
5214
+ // Workers see their own agent-scoped + all swarm-scoped
5215
+ if (scope === "agent") {
5216
+ conditions.push("agentId = ? AND scope = 'agent'");
5217
+ params.push(agentId);
5218
+ } else if (scope === "swarm") {
5219
+ conditions.push("scope = 'swarm'");
5220
+ } else {
5221
+ // "all" - own agent + swarm
5222
+ conditions.push("(agentId = ? OR scope = 'swarm')");
5223
+ params.push(agentId);
5224
+ }
5225
+ } else {
5226
+ // Leads see everything
5227
+ if (scope === "agent") {
5228
+ conditions.push("scope = 'agent'");
5229
+ } else if (scope === "swarm") {
5230
+ conditions.push("scope = 'swarm'");
5231
+ }
5232
+ // "all" for lead = no scope filter needed
5233
+ }
5234
+
5235
+ if (source) {
5236
+ conditions.push("source = ?");
5237
+ params.push(source);
5238
+ }
5239
+
5240
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
5241
+
5242
+ const rows = getDb()
5243
+ .prepare<AgentMemoryRow, (string | null)[]>(`SELECT * FROM agent_memory ${whereClause}`)
5244
+ .all(...params);
5245
+
5246
+ // Import cosine similarity inline to avoid circular deps
5247
+ const { cosineSimilarity, deserializeEmbedding } = require("./embedding");
5248
+
5249
+ // Compute similarities and sort
5250
+ const results: (AgentMemory & { similarity: number })[] = [];
5251
+ for (const row of rows) {
5252
+ if (!row.embedding) continue;
5253
+ const embedding = deserializeEmbedding(row.embedding);
5254
+ // Skip embeddings with mismatched dimensions (can happen if embedding model changes)
5255
+ if (embedding.length !== queryEmbedding.length) continue;
5256
+ const similarity = cosineSimilarity(queryEmbedding, embedding) as number;
5257
+ results.push({ ...rowToAgentMemory(row), similarity });
5258
+ }
5259
+
5260
+ results.sort((a, b) => b.similarity - a.similarity);
5261
+ return results.slice(0, limit);
5262
+ }
5263
+
5264
+ export interface ListMemoriesOptions {
5265
+ scope?: "agent" | "swarm" | "all";
5266
+ limit?: number;
5267
+ offset?: number;
5268
+ isLead?: boolean;
5269
+ }
5270
+
5271
+ export function listMemoriesByAgent(
5272
+ agentId: string,
5273
+ options: ListMemoriesOptions = {},
5274
+ ): AgentMemory[] {
5275
+ const { scope = "all", limit = 20, offset = 0, isLead = false } = options;
5276
+
5277
+ const conditions: string[] = [];
5278
+ const params: (string | number)[] = [];
5279
+
5280
+ if (!isLead) {
5281
+ if (scope === "agent") {
5282
+ conditions.push("agentId = ? AND scope = 'agent'");
5283
+ params.push(agentId);
5284
+ } else if (scope === "swarm") {
5285
+ conditions.push("scope = 'swarm'");
5286
+ } else {
5287
+ conditions.push("(agentId = ? OR scope = 'swarm')");
5288
+ params.push(agentId);
5289
+ }
5290
+ } else {
5291
+ if (scope === "agent") {
5292
+ conditions.push("scope = 'agent'");
5293
+ } else if (scope === "swarm") {
5294
+ conditions.push("scope = 'swarm'");
5295
+ }
5296
+ }
5297
+
5298
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
5299
+
5300
+ params.push(limit, offset);
5301
+
5302
+ const rows = getDb()
5303
+ .prepare<AgentMemoryRow, (string | number)[]>(
5304
+ `SELECT * FROM agent_memory ${whereClause} ORDER BY createdAt DESC LIMIT ? OFFSET ?`,
5305
+ )
5306
+ .all(...params);
5307
+
5308
+ return rows.map(rowToAgentMemory);
5309
+ }
5310
+
5311
+ export function deleteMemoriesBySourcePath(sourcePath: string, agentId: string): number {
5312
+ const result = getDb()
5313
+ .prepare("DELETE FROM agent_memory WHERE sourcePath = ? AND agentId = ?")
5314
+ .run(sourcePath, agentId);
5315
+ return result.changes;
5316
+ }
5317
+
5318
+ export function deleteMemory(id: string): boolean {
5319
+ const result = getDb().prepare("DELETE FROM agent_memory WHERE id = ?").run(id);
5320
+ return result.changes > 0;
5321
+ }
5322
+
5323
+ export function getMemoryStats(agentId: string): {
5324
+ total: number;
5325
+ bySource: Record<string, number>;
5326
+ byScope: Record<string, number>;
5327
+ } {
5328
+ const total = getDb()
5329
+ .prepare<{ count: number }, [string]>(
5330
+ "SELECT COUNT(*) as count FROM agent_memory WHERE agentId = ?",
5331
+ )
5332
+ .get(agentId);
5333
+
5334
+ const bySourceRows = getDb()
5335
+ .prepare<{ source: string; count: number }, [string]>(
5336
+ "SELECT source, COUNT(*) as count FROM agent_memory WHERE agentId = ? GROUP BY source",
5337
+ )
5338
+ .all(agentId);
5339
+
5340
+ const byScopeRows = getDb()
5341
+ .prepare<{ scope: string; count: number }, [string]>(
5342
+ "SELECT scope, COUNT(*) as count FROM agent_memory WHERE agentId = ? GROUP BY scope",
5343
+ )
5344
+ .all(agentId);
5345
+
5346
+ const bySource: Record<string, number> = {};
5347
+ for (const row of bySourceRows) {
5348
+ bySource[row.source] = row.count;
5349
+ }
5350
+
5351
+ const byScope: Record<string, number> = {};
5352
+ for (const row of byScopeRows) {
5353
+ byScope[row.scope] = row.count;
5354
+ }
5355
+
5356
+ return { total: total?.count ?? 0, bySource, byScope };
5357
+ }
5358
+
5359
+ // ============================================================================
5360
+ // AgentMail Inbox Mapping Queries
5361
+ // ============================================================================
5362
+
5363
+ export interface AgentMailInboxMapping {
5364
+ id: string;
5365
+ inboxId: string;
5366
+ agentId: string;
5367
+ inboxEmail: string | null;
5368
+ createdAt: string;
5369
+ }
5370
+
5371
+ export function getAgentMailInboxMapping(inboxId: string): AgentMailInboxMapping | null {
5372
+ return (
5373
+ getDb()
5374
+ .prepare<AgentMailInboxMapping, [string]>(
5375
+ "SELECT * FROM agentmail_inbox_mappings WHERE inboxId = ?",
5376
+ )
5377
+ .get(inboxId) ?? null
5378
+ );
5379
+ }
5380
+
5381
+ export function getAgentMailInboxMappingsByAgent(agentId: string): AgentMailInboxMapping[] {
5382
+ return getDb()
5383
+ .prepare<AgentMailInboxMapping, [string]>(
5384
+ "SELECT * FROM agentmail_inbox_mappings WHERE agentId = ? ORDER BY createdAt DESC",
5385
+ )
5386
+ .all(agentId);
5387
+ }
5388
+
5389
+ export function getAllAgentMailInboxMappings(): AgentMailInboxMapping[] {
5390
+ return getDb()
5391
+ .prepare<AgentMailInboxMapping, []>(
5392
+ "SELECT * FROM agentmail_inbox_mappings ORDER BY createdAt DESC",
5393
+ )
5394
+ .all();
5395
+ }
5396
+
5397
+ export function createAgentMailInboxMapping(
5398
+ inboxId: string,
5399
+ agentId: string,
5400
+ inboxEmail?: string,
5401
+ ): AgentMailInboxMapping {
5402
+ const id = crypto.randomUUID();
5403
+ const now = new Date().toISOString();
5404
+
5405
+ const row = getDb()
5406
+ .prepare<AgentMailInboxMapping, [string, string, string, string | null, string]>(
5407
+ `INSERT INTO agentmail_inbox_mappings (id, inboxId, agentId, inboxEmail, createdAt)
5408
+ VALUES (?, ?, ?, ?, ?)
5409
+ ON CONFLICT(inboxId) DO UPDATE SET agentId = excluded.agentId, inboxEmail = excluded.inboxEmail
5410
+ RETURNING *`,
5411
+ )
5412
+ .get(id, inboxId, agentId, inboxEmail ?? null, now);
5413
+
5414
+ if (!row) throw new Error("Failed to create AgentMail inbox mapping");
5415
+ return row;
5416
+ }
5417
+
5418
+ export function deleteAgentMailInboxMapping(inboxId: string): boolean {
5419
+ const result = getDb()
5420
+ .prepare("DELETE FROM agentmail_inbox_mappings WHERE inboxId = ?")
5421
+ .run(inboxId);
5422
+ return result.changes > 0;
5423
+ }
5424
+
5425
+ /**
5426
+ * Find the most recent task by AgentMail thread ID
5427
+ * Includes completed/failed tasks to maintain thread continuity via parentTaskId
5428
+ */
5429
+ export function findTaskByAgentMailThread(agentmailThreadId: string): AgentTask | null {
5430
+ const row = getDb()
5431
+ .prepare<AgentTaskRow, [string]>(
5432
+ `SELECT * FROM agent_tasks
5433
+ WHERE agentmailThreadId = ?
5434
+ ORDER BY createdAt DESC
5435
+ LIMIT 1`,
5436
+ )
5437
+ .get(agentmailThreadId);
5438
+ return row ? rowToAgentTask(row) : null;
5439
+ }
5440
+
5441
+ // ============================================================================
5442
+ // Active Sessions (runner session tracking for concurrency awareness)
5443
+ // ============================================================================
5444
+
5445
+ export function insertActiveSession(session: {
5446
+ agentId: string;
5447
+ taskId?: string;
5448
+ triggerType: string;
5449
+ inboxMessageId?: string;
5450
+ taskDescription?: string;
5451
+ runnerSessionId?: string;
5452
+ }): ActiveSession {
5453
+ const id = crypto.randomUUID();
5454
+ const now = new Date().toISOString();
5455
+
5456
+ const row = getDb()
5457
+ .prepare<
5458
+ ActiveSession,
5459
+ [
5460
+ string,
5461
+ string,
5462
+ string | null,
5463
+ string,
5464
+ string | null,
5465
+ string | null,
5466
+ string | null,
5467
+ string,
5468
+ string,
5469
+ ]
5470
+ >(
5471
+ `INSERT INTO active_sessions (id, agentId, taskId, triggerType, inboxMessageId, taskDescription, runnerSessionId, startedAt, lastHeartbeatAt)
5472
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5473
+ RETURNING *`,
5474
+ )
5475
+ .get(
5476
+ id,
5477
+ session.agentId,
5478
+ session.taskId ?? null,
5479
+ session.triggerType,
5480
+ session.inboxMessageId ?? null,
5481
+ session.taskDescription ?? null,
5482
+ session.runnerSessionId ?? null,
5483
+ now,
5484
+ now,
5485
+ );
5486
+
5487
+ if (!row) throw new Error("Failed to insert active session");
5488
+ return row;
5489
+ }
5490
+
5491
+ export function deleteActiveSession(taskId: string): boolean {
5492
+ const result = getDb().prepare("DELETE FROM active_sessions WHERE taskId = ?").run(taskId);
5493
+ return result.changes > 0;
5494
+ }
5495
+
5496
+ export function deleteActiveSessionById(id: string): boolean {
5497
+ const result = getDb().prepare("DELETE FROM active_sessions WHERE id = ?").run(id);
5498
+ return result.changes > 0;
5499
+ }
5500
+
5501
+ export function getActiveSessions(agentId?: string): ActiveSession[] {
5502
+ if (agentId) {
5503
+ return getDb()
5504
+ .prepare<ActiveSession, [string]>(
5505
+ "SELECT * FROM active_sessions WHERE agentId = ? ORDER BY startedAt DESC",
5506
+ )
5507
+ .all(agentId);
5508
+ }
5509
+ return getDb()
5510
+ .prepare<ActiveSession, []>("SELECT * FROM active_sessions ORDER BY startedAt DESC")
5511
+ .all();
5512
+ }
5513
+
5514
+ export function heartbeatActiveSession(taskId: string): boolean {
5515
+ const now = new Date().toISOString();
5516
+ const result = getDb()
5517
+ .prepare("UPDATE active_sessions SET lastHeartbeatAt = ? WHERE taskId = ?")
5518
+ .run(now, taskId);
5519
+ return result.changes > 0;
5520
+ }
5521
+
5522
+ export function cleanupStaleSessions(maxAgeMinutes = 30): number {
5523
+ const cutoff = new Date(Date.now() - maxAgeMinutes * 60 * 1000).toISOString();
5524
+ const result = getDb()
5525
+ .prepare("DELETE FROM active_sessions WHERE lastHeartbeatAt < ?")
5526
+ .run(cutoff);
5527
+ return result.changes;
5528
+ }
5529
+
5530
+ export function cleanupAgentSessions(agentId: string): number {
5531
+ const result = getDb().prepare("DELETE FROM active_sessions WHERE agentId = ?").run(agentId);
5532
+ return result.changes;
5533
+ }
5534
+
5535
+ /** Update providerSessionId on an active session identified by taskId */
5536
+ export function updateActiveSessionProviderSessionId(
5537
+ taskId: string,
5538
+ providerSessionId: string,
5539
+ ): boolean {
5540
+ const result = getDb()
5541
+ .prepare("UPDATE active_sessions SET providerSessionId = ? WHERE taskId = ?")
5542
+ .run(providerSessionId, taskId);
5543
+ return result.changes > 0;
5544
+ }
5545
+
5546
+ /**
5547
+ * Reassociate session logs from a runner session to a real task ID.
5548
+ * Used when a pool task is claimed — logs were stored under a random UUID,
5549
+ * this updates them to use the real task ID.
5550
+ * Idempotent — safe to call multiple times.
5551
+ */
5552
+ export function reassociateSessionLogs(runnerSessionId: string, realTaskId: string): number {
5553
+ const result = getDb()
5554
+ .prepare("UPDATE session_logs SET taskId = ? WHERE sessionId = ? AND taskId != ?")
5555
+ .run(realTaskId, runnerSessionId, realTaskId);
5556
+ return result.changes;
5557
+ }
5558
+
5559
+ // ============================================================================
5560
+ // Heartbeat / Triage Query Functions
5561
+ // ============================================================================
5562
+
5563
+ /**
5564
+ * Get in_progress tasks that haven't been updated within the given threshold.
5565
+ * Used by the heartbeat to detect potentially stalled tasks.
5566
+ */
5567
+ export function getStalledInProgressTasks(thresholdMinutes: number = 30): AgentTask[] {
5568
+ const cutoff = new Date(Date.now() - thresholdMinutes * 60 * 1000).toISOString();
5569
+ return getDb()
5570
+ .prepare<AgentTaskRow, [string]>(
5571
+ `SELECT * FROM agent_tasks
5572
+ WHERE status = 'in_progress' AND lastUpdatedAt < ?
5573
+ ORDER BY lastUpdatedAt ASC`,
5574
+ )
5575
+ .all(cutoff)
5576
+ .map(rowToAgentTask);
5577
+ }
5578
+
5579
+ /**
5580
+ * Get idle, non-lead, non-offline agents that have capacity for more tasks.
5581
+ * Used by the heartbeat for auto-assignment of pool tasks.
5582
+ */
5583
+ export function getIdleWorkersWithCapacity(): Agent[] {
5584
+ const agents = getDb()
5585
+ .prepare<AgentRow, []>(
5586
+ `SELECT * FROM agents
5587
+ WHERE status = 'idle' AND isLead = 0`,
5588
+ )
5589
+ .all()
5590
+ .map(rowToAgent);
5591
+
5592
+ return agents.filter((agent) => {
5593
+ const activeCount = getActiveTaskCount(agent.id);
5594
+ return activeCount < (agent.maxTasks ?? 1);
5595
+ });
5596
+ }
5597
+
5598
+ /**
5599
+ * Get unassigned pool tasks ordered by priority (DESC) then creation time (ASC).
5600
+ * Used by the heartbeat for auto-assignment.
5601
+ */
5602
+ export function getUnassignedPoolTasks(limit: number = 10): AgentTask[] {
5603
+ return getDb()
5604
+ .prepare<AgentTaskRow, [number]>(
5605
+ `SELECT * FROM agent_tasks
5606
+ WHERE status = 'unassigned'
5607
+ ORDER BY priority DESC, createdAt ASC
5608
+ LIMIT ?`,
5609
+ )
5610
+ .all(limit)
5611
+ .map(rowToAgentTask);
5612
+ }
5613
+
5614
+ // ============================================================================
5615
+ // Workflow CRUD
5616
+ // ============================================================================
5617
+
5618
+ type WorkflowRow = {
5619
+ id: string;
5620
+ name: string;
5621
+ description: string | null;
5622
+ enabled: number;
5623
+ definition: string;
5624
+ triggers: string;
5625
+ cooldown: string | null;
5626
+ input: string | null;
5627
+ triggerSchema: string | null;
5628
+ dir: string | null;
5629
+ vcs_repo: string | null;
5630
+ createdByAgentId: string | null;
5631
+ createdAt: string;
5632
+ lastUpdatedAt: string;
5633
+ };
5634
+
5635
+ function rowToWorkflow(row: WorkflowRow): Workflow {
5636
+ return {
5637
+ id: row.id,
5638
+ name: row.name,
5639
+ description: row.description ?? undefined,
5640
+ enabled: row.enabled === 1,
5641
+ definition: JSON.parse(row.definition) as WorkflowDefinition,
5642
+ triggers: JSON.parse(row.triggers) as TriggerConfig[],
5643
+ cooldown: row.cooldown ? (JSON.parse(row.cooldown) as CooldownConfig) : undefined,
5644
+ input: row.input ? (JSON.parse(row.input) as Record<string, InputValue>) : undefined,
5645
+ triggerSchema: row.triggerSchema
5646
+ ? (JSON.parse(row.triggerSchema) as Record<string, unknown>)
5647
+ : undefined,
5648
+ dir: row.dir ?? undefined,
5649
+ vcsRepo: row.vcs_repo ?? undefined,
5650
+ createdByAgentId: row.createdByAgentId ?? undefined,
5651
+ createdAt: row.createdAt,
5652
+ lastUpdatedAt: row.lastUpdatedAt,
5653
+ };
5654
+ }
5655
+
5656
+ export function createWorkflow(data: {
5657
+ name: string;
5658
+ description?: string;
5659
+ definition: WorkflowDefinition;
5660
+ triggers?: TriggerConfig[];
5661
+ cooldown?: CooldownConfig;
5662
+ input?: Record<string, InputValue>;
5663
+ triggerSchema?: Record<string, unknown>;
5664
+ dir?: string;
5665
+ vcsRepo?: string;
5666
+ createdByAgentId?: string;
5667
+ }): Workflow {
5668
+ const id = crypto.randomUUID();
5669
+ const row = getDb()
5670
+ .prepare<
5671
+ WorkflowRow,
5672
+ [
5673
+ string,
5674
+ string,
5675
+ string | null,
5676
+ string,
5677
+ string,
5678
+ string | null,
5679
+ string | null,
5680
+ string | null,
5681
+ string | null,
5682
+ string | null,
5683
+ string | null,
5684
+ ]
5685
+ >(
5686
+ `INSERT INTO workflows (id, name, description, definition, triggers, cooldown, input, triggerSchema, dir, vcs_repo, createdByAgentId)
5687
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
5688
+ )
5689
+ .get(
5690
+ id,
5691
+ data.name,
5692
+ data.description ?? null,
5693
+ JSON.stringify(data.definition),
5694
+ JSON.stringify(data.triggers ?? []),
5695
+ data.cooldown ? JSON.stringify(data.cooldown) : null,
5696
+ data.input ? JSON.stringify(data.input) : null,
5697
+ data.triggerSchema ? JSON.stringify(data.triggerSchema) : null,
5698
+ data.dir ?? null,
5699
+ data.vcsRepo ?? null,
5700
+ data.createdByAgentId ?? null,
5701
+ );
5702
+ if (!row) throw new Error("Failed to create workflow");
5703
+ return rowToWorkflow(row);
5704
+ }
5705
+
5706
+ export function getWorkflow(id: string): Workflow | null {
5707
+ const row = getDb()
5708
+ .prepare<WorkflowRow, [string]>("SELECT * FROM workflows WHERE id = ?")
5709
+ .get(id);
5710
+ return row ? rowToWorkflow(row) : null;
5711
+ }
5712
+
5713
+ export function listWorkflows(filters?: { enabled?: boolean }): Workflow[] {
5714
+ let query = "SELECT * FROM workflows WHERE 1=1";
5715
+ const params: (string | number)[] = [];
5716
+ if (filters?.enabled !== undefined) {
5717
+ query += " AND enabled = ?";
5718
+ params.push(filters.enabled ? 1 : 0);
5719
+ }
5720
+ query += " ORDER BY name ASC";
5721
+ return getDb()
5722
+ .prepare<WorkflowRow, (string | number)[]>(query)
5723
+ .all(...params)
5724
+ .map(rowToWorkflow);
5725
+ }
5726
+
5727
+ export function updateWorkflow(
5728
+ id: string,
5729
+ data: {
5730
+ name?: string;
5731
+ description?: string;
5732
+ enabled?: boolean;
5733
+ definition?: WorkflowDefinition;
5734
+ triggers?: TriggerConfig[];
5735
+ cooldown?: CooldownConfig | null;
5736
+ input?: Record<string, InputValue> | null;
5737
+ triggerSchema?: Record<string, unknown> | null;
5738
+ dir?: string | null;
5739
+ vcsRepo?: string | null;
5740
+ },
5741
+ ): Workflow | null {
5742
+ const updates: string[] = [];
5743
+ const params: (string | number | null)[] = [];
5744
+ if (data.name !== undefined) {
5745
+ updates.push("name = ?");
5746
+ params.push(data.name);
5747
+ }
5748
+ if (data.description !== undefined) {
5749
+ updates.push("description = ?");
5750
+ params.push(data.description);
5751
+ }
5752
+ if (data.enabled !== undefined) {
5753
+ updates.push("enabled = ?");
5754
+ params.push(data.enabled ? 1 : 0);
5755
+ }
5756
+ if (data.definition !== undefined) {
5757
+ updates.push("definition = ?");
5758
+ params.push(JSON.stringify(data.definition));
5759
+ }
5760
+ if (data.triggers !== undefined) {
5761
+ updates.push("triggers = ?");
5762
+ params.push(JSON.stringify(data.triggers));
5763
+ }
5764
+ if (data.cooldown !== undefined) {
5765
+ updates.push("cooldown = ?");
5766
+ params.push(data.cooldown ? JSON.stringify(data.cooldown) : null);
5767
+ }
5768
+ if (data.input !== undefined) {
5769
+ updates.push("input = ?");
5770
+ params.push(data.input ? JSON.stringify(data.input) : null);
5771
+ }
5772
+ if (data.triggerSchema !== undefined) {
5773
+ updates.push("triggerSchema = ?");
5774
+ params.push(data.triggerSchema ? JSON.stringify(data.triggerSchema) : null);
5775
+ }
5776
+ if (data.dir !== undefined) {
5777
+ updates.push("dir = ?");
5778
+ params.push(data.dir ?? null);
5779
+ }
5780
+ if (data.vcsRepo !== undefined) {
5781
+ updates.push("vcs_repo = ?");
5782
+ params.push(data.vcsRepo ?? null);
5783
+ }
5784
+ if (updates.length === 0) return getWorkflow(id);
5785
+ updates.push("lastUpdatedAt = ?");
5786
+ params.push(new Date().toISOString());
5787
+ params.push(id);
5788
+ const row = getDb()
5789
+ .prepare<WorkflowRow, (string | number | null)[]>(
5790
+ `UPDATE workflows SET ${updates.join(", ")} WHERE id = ? RETURNING *`,
5791
+ )
5792
+ .get(...params);
5793
+ return row ? rowToWorkflow(row) : null;
5794
+ }
5795
+
5796
+ export function deleteWorkflow(id: string): boolean {
5797
+ const db = getDb();
5798
+ // Cascade delete in FK-safe order:
5799
+ // 1. Unlink agent_tasks (they reference steps and runs)
5800
+ db.run(
5801
+ `UPDATE agent_tasks SET workflowRunId = NULL, workflowRunStepId = NULL WHERE workflowRunId IN (SELECT id FROM workflow_runs WHERE workflowId = ?)`,
5802
+ [id],
5803
+ );
5804
+ // 2. Delete steps (they reference runs)
5805
+ db.run(
5806
+ `DELETE FROM workflow_run_steps WHERE runId IN (SELECT id FROM workflow_runs WHERE workflowId = ?)`,
5807
+ [id],
5808
+ );
5809
+ // 3. Delete runs (they reference workflow)
5810
+ db.run("DELETE FROM workflow_runs WHERE workflowId = ?", [id]);
5811
+ // 4. Delete workflow
5812
+ const result = db.run("DELETE FROM workflows WHERE id = ?", [id]);
5813
+ return result.changes > 0;
5814
+ }
5815
+
5816
+ /**
5817
+ * Find enabled workflows that have a schedule trigger matching the given scheduleId.
5818
+ * Uses SQLite JSON functions to query into the triggers JSON array.
5819
+ */
5820
+ export function getWorkflowsByScheduleId(scheduleId: string): Workflow[] {
5821
+ const rows = getDb()
5822
+ .prepare<WorkflowRow, [string]>(
5823
+ `SELECT w.* FROM workflows w, json_each(w.triggers) AS t
5824
+ WHERE w.enabled = 1
5825
+ AND json_extract(t.value, '$.type') = 'schedule'
5826
+ AND json_extract(t.value, '$.scheduleId') = ?`,
5827
+ )
5828
+ .all(scheduleId);
5829
+ return rows.map(rowToWorkflow);
5830
+ }
5831
+
5832
+ // ============================================================================
5833
+ // Workflow Run CRUD
5834
+ // ============================================================================
5835
+
5836
+ type WorkflowRunRow = {
5837
+ id: string;
5838
+ workflowId: string;
5839
+ status: string;
5840
+ triggerData: string | null;
5841
+ context: string | null;
5842
+ error: string | null;
5843
+ startedAt: string;
5844
+ lastUpdatedAt: string;
5845
+ finishedAt: string | null;
5846
+ };
5847
+
5848
+ function rowToWorkflowRun(row: WorkflowRunRow): WorkflowRun {
5849
+ return {
5850
+ id: row.id,
5851
+ workflowId: row.workflowId,
5852
+ status: row.status as WorkflowRunStatus,
5853
+ triggerData: row.triggerData ? JSON.parse(row.triggerData) : undefined,
5854
+ context: row.context ? (JSON.parse(row.context) as Record<string, unknown>) : undefined,
5855
+ error: row.error ?? undefined,
5856
+ startedAt: row.startedAt,
5857
+ lastUpdatedAt: row.lastUpdatedAt,
5858
+ finishedAt: row.finishedAt ?? undefined,
5859
+ };
5860
+ }
5861
+
5862
+ export function createWorkflowRun(data: {
5863
+ id: string;
5864
+ workflowId: string;
5865
+ triggerData?: unknown;
5866
+ }): WorkflowRun {
5867
+ const now = new Date().toISOString();
5868
+ const row = getDb()
5869
+ .prepare<WorkflowRunRow, [string, string, string, string | null]>(
5870
+ `INSERT INTO workflow_runs (id, workflowId, startedAt, triggerData) VALUES (?, ?, ?, ?) RETURNING *`,
5871
+ )
5872
+ .get(data.id, data.workflowId, now, data.triggerData ? JSON.stringify(data.triggerData) : null);
5873
+ if (!row) throw new Error("Failed to create workflow run");
5874
+ return rowToWorkflowRun(row);
5875
+ }
5876
+
5877
+ export function getWorkflowRun(id: string): WorkflowRun | null {
5878
+ const row = getDb()
5879
+ .prepare<WorkflowRunRow, [string]>("SELECT * FROM workflow_runs WHERE id = ?")
5880
+ .get(id);
5881
+ return row ? rowToWorkflowRun(row) : null;
5882
+ }
5883
+
5884
+ export function updateWorkflowRun(
5885
+ id: string,
5886
+ data: {
5887
+ status?: WorkflowRunStatus;
5888
+ context?: Record<string, unknown>;
5889
+ error?: string;
5890
+ finishedAt?: string;
5891
+ },
5892
+ ): WorkflowRun | null {
5893
+ const updates: string[] = [];
5894
+ const params: (string | null)[] = [];
5895
+ if (data.status !== undefined) {
5896
+ updates.push("status = ?");
5897
+ params.push(data.status);
5898
+ }
5899
+ if (data.context !== undefined) {
5900
+ updates.push("context = ?");
5901
+ params.push(JSON.stringify(data.context));
5902
+ }
5903
+ if (data.error !== undefined) {
5904
+ updates.push("error = ?");
5905
+ params.push(data.error);
5906
+ }
5907
+ if (data.finishedAt !== undefined) {
5908
+ updates.push("finishedAt = ?");
5909
+ params.push(data.finishedAt);
5910
+ }
5911
+ if (updates.length === 0) return getWorkflowRun(id);
5912
+ updates.push("lastUpdatedAt = ?");
5913
+ params.push(new Date().toISOString());
5914
+ params.push(id);
5915
+ const row = getDb()
5916
+ .prepare<WorkflowRunRow, (string | null)[]>(
5917
+ `UPDATE workflow_runs SET ${updates.join(", ")} WHERE id = ? RETURNING *`,
5918
+ )
5919
+ .get(...params);
5920
+ return row ? rowToWorkflowRun(row) : null;
5921
+ }
5922
+
5923
+ export function listWorkflowRuns(workflowId: string): WorkflowRun[] {
5924
+ return getDb()
5925
+ .prepare<WorkflowRunRow, [string]>(
5926
+ "SELECT * FROM workflow_runs WHERE workflowId = ? ORDER BY startedAt DESC",
5927
+ )
5928
+ .all(workflowId)
5929
+ .map(rowToWorkflowRun);
5930
+ }
5931
+
5932
+ // ============================================================================
5933
+ // Workflow Run Step CRUD
5934
+ // ============================================================================
5935
+
5936
+ type WorkflowRunStepRow = {
5937
+ id: string;
5938
+ runId: string;
5939
+ nodeId: string;
5940
+ nodeType: string;
5941
+ status: string;
5942
+ input: string | null;
5943
+ output: string | null;
5944
+ error: string | null;
5945
+ startedAt: string;
5946
+ finishedAt: string | null;
5947
+ retryCount: number;
5948
+ maxRetries: number;
5949
+ nextRetryAt: string | null;
5950
+ idempotencyKey: string | null;
5951
+ diagnostics: string | null;
5952
+ nextPort: string | null;
5953
+ };
5954
+
5955
+ function rowToWorkflowRunStep(row: WorkflowRunStepRow): WorkflowRunStep {
5956
+ return {
5957
+ id: row.id,
5958
+ runId: row.runId,
5959
+ nodeId: row.nodeId,
5960
+ nodeType: row.nodeType,
5961
+ status: row.status as WorkflowRunStepStatus,
5962
+ input: row.input ? JSON.parse(row.input) : undefined,
5963
+ output: row.output ? JSON.parse(row.output) : undefined,
5964
+ error: row.error ?? undefined,
5965
+ startedAt: row.startedAt,
5966
+ finishedAt: row.finishedAt ?? undefined,
5967
+ retryCount: row.retryCount,
5968
+ maxRetries: row.maxRetries,
5969
+ nextRetryAt: row.nextRetryAt ?? undefined,
5970
+ idempotencyKey: row.idempotencyKey ?? undefined,
5971
+ diagnostics: row.diagnostics ?? undefined,
5972
+ nextPort: row.nextPort ?? undefined,
5973
+ };
5974
+ }
5975
+
5976
+ export function createWorkflowRunStep(data: {
5977
+ id: string;
5978
+ runId: string;
5979
+ nodeId: string;
5980
+ nodeType: string;
5981
+ input?: unknown;
5982
+ }): WorkflowRunStep {
5983
+ const now = new Date().toISOString();
5984
+ const row = getDb()
5985
+ .prepare<WorkflowRunStepRow, [string, string, string, string, string, string | null]>(
5986
+ `INSERT INTO workflow_run_steps (id, runId, nodeId, nodeType, status, startedAt, input)
5987
+ VALUES (?, ?, ?, ?, 'running', ?, ?) RETURNING *`,
5988
+ )
5989
+ .get(
5990
+ data.id,
5991
+ data.runId,
5992
+ data.nodeId,
5993
+ data.nodeType,
5994
+ now,
5995
+ data.input ? JSON.stringify(data.input) : null,
5996
+ );
5997
+ if (!row) throw new Error("Failed to create workflow run step");
5998
+ return rowToWorkflowRunStep(row);
5999
+ }
6000
+
6001
+ export function getWorkflowRunStep(id: string): WorkflowRunStep | null {
6002
+ const row = getDb()
6003
+ .prepare<WorkflowRunStepRow, [string]>("SELECT * FROM workflow_run_steps WHERE id = ?")
6004
+ .get(id);
6005
+ return row ? rowToWorkflowRunStep(row) : null;
6006
+ }
6007
+
6008
+ export function updateWorkflowRunStep(
6009
+ id: string,
6010
+ data: {
6011
+ status?: WorkflowRunStepStatus;
6012
+ output?: unknown;
6013
+ error?: string;
6014
+ finishedAt?: string;
6015
+ retryCount?: number;
6016
+ maxRetries?: number;
6017
+ nextRetryAt?: string | null;
6018
+ idempotencyKey?: string;
6019
+ diagnostics?: string;
6020
+ nextPort?: string;
6021
+ },
6022
+ ): WorkflowRunStep | null {
6023
+ const updates: string[] = [];
6024
+ const params: (string | number | null)[] = [];
6025
+ if (data.status !== undefined) {
6026
+ updates.push("status = ?");
6027
+ params.push(data.status);
6028
+ }
6029
+ if (data.output !== undefined) {
6030
+ updates.push("output = ?");
6031
+ params.push(JSON.stringify(data.output));
6032
+ }
6033
+ if (data.error !== undefined) {
6034
+ updates.push("error = ?");
6035
+ params.push(data.error);
6036
+ }
6037
+ if (data.finishedAt !== undefined) {
6038
+ updates.push("finishedAt = ?");
6039
+ params.push(data.finishedAt);
6040
+ }
6041
+ if (data.retryCount !== undefined) {
6042
+ updates.push("retryCount = ?");
6043
+ params.push(data.retryCount);
6044
+ }
6045
+ if (data.maxRetries !== undefined) {
6046
+ updates.push("maxRetries = ?");
6047
+ params.push(data.maxRetries);
6048
+ }
6049
+ if (data.nextRetryAt !== undefined) {
6050
+ updates.push("nextRetryAt = ?");
6051
+ params.push(data.nextRetryAt);
6052
+ }
6053
+ if (data.idempotencyKey !== undefined) {
6054
+ updates.push("idempotencyKey = ?");
6055
+ params.push(data.idempotencyKey);
6056
+ }
6057
+ if (data.diagnostics !== undefined) {
6058
+ updates.push("diagnostics = ?");
6059
+ params.push(data.diagnostics);
6060
+ }
6061
+ if (data.nextPort !== undefined) {
6062
+ updates.push("nextPort = ?");
6063
+ params.push(data.nextPort);
6064
+ }
6065
+ if (updates.length === 0) return getWorkflowRunStep(id);
6066
+ params.push(id);
6067
+ const row = getDb()
6068
+ .prepare<WorkflowRunStepRow, (string | number | null)[]>(
6069
+ `UPDATE workflow_run_steps SET ${updates.join(", ")} WHERE id = ? RETURNING *`,
6070
+ )
6071
+ .get(...params);
6072
+ return row ? rowToWorkflowRunStep(row) : null;
6073
+ }
6074
+
6075
+ export function getWorkflowRunStepsByRunId(runId: string): WorkflowRunStep[] {
6076
+ return getDb()
6077
+ .prepare<WorkflowRunStepRow, [string]>(
6078
+ "SELECT * FROM workflow_run_steps WHERE runId = ? ORDER BY startedAt ASC",
6079
+ )
6080
+ .all(runId)
6081
+ .map(rowToWorkflowRunStep);
6082
+ }
6083
+
6084
+ // --- Stuck Workflow Run Recovery ---
6085
+
6086
+ export interface StuckWorkflowRun {
6087
+ runId: string;
6088
+ stepId: string;
6089
+ nodeId: string;
6090
+ taskStatus: string;
6091
+ taskOutput: string | null;
6092
+ workflowId: string;
6093
+ }
6094
+
6095
+ export function getStuckWorkflowRuns(): StuckWorkflowRun[] {
6096
+ return getDb()
6097
+ .prepare<StuckWorkflowRun, []>(
6098
+ `SELECT
6099
+ wr.id as runId,
6100
+ wrs.id as stepId,
6101
+ wrs.nodeId,
6102
+ at.status as taskStatus,
6103
+ at.output as taskOutput,
6104
+ wr.workflowId
6105
+ FROM workflow_runs wr
6106
+ JOIN workflow_run_steps wrs ON wrs.runId = wr.id AND wrs.status = 'waiting'
6107
+ JOIN agent_tasks at ON at.workflowRunStepId = wrs.id
6108
+ WHERE wr.status = 'waiting'
6109
+ AND at.status IN ('completed', 'failed', 'cancelled')`,
6110
+ )
6111
+ .all();
6112
+ }
6113
+
6114
+ // --- New Workflow Query Functions ---
6115
+
6116
+ export function getLastSuccessfulRun(workflowId: string): WorkflowRun | null {
6117
+ const row = getDb()
6118
+ .prepare<WorkflowRunRow, [string]>(
6119
+ `SELECT * FROM workflow_runs
6120
+ WHERE workflowId = ? AND status = 'completed'
6121
+ ORDER BY finishedAt DESC LIMIT 1`,
6122
+ )
6123
+ .get(workflowId);
6124
+ return row ? rowToWorkflowRun(row) : null;
6125
+ }
6126
+
6127
+ export function getRetryableSteps(): WorkflowRunStep[] {
6128
+ const now = new Date().toISOString();
6129
+ return getDb()
6130
+ .prepare<WorkflowRunStepRow, [string]>(
6131
+ `SELECT * FROM workflow_run_steps
6132
+ WHERE status = 'failed'
6133
+ AND nextRetryAt IS NOT NULL
6134
+ AND nextRetryAt <= ?
6135
+ ORDER BY nextRetryAt ASC`,
6136
+ )
6137
+ .all(now)
6138
+ .map(rowToWorkflowRunStep);
6139
+ }
6140
+
6141
+ export function getCompletedStepNodeIds(runId: string): string[] {
6142
+ const rows = getDb()
6143
+ .prepare<{ nodeId: string }, [string]>(
6144
+ `SELECT nodeId FROM workflow_run_steps
6145
+ WHERE runId = ? AND status = 'completed'`,
6146
+ )
6147
+ .all(runId);
6148
+ return rows.map((r) => r.nodeId);
6149
+ }
6150
+
6151
+ export function getTaskByWorkflowRunStepId(stepId: string): AgentTask | null {
6152
+ const row = getDb()
6153
+ .prepare<AgentTaskRow, [string]>(
6154
+ "SELECT * FROM agent_tasks WHERE workflowRunStepId = ? LIMIT 1",
6155
+ )
6156
+ .get(stepId);
6157
+ return row ? rowToAgentTask(row) : null;
6158
+ }
6159
+
6160
+ export function getStepByIdempotencyKey(key: string): WorkflowRunStep | null {
6161
+ const row = getDb()
6162
+ .prepare<WorkflowRunStepRow, [string]>(
6163
+ "SELECT * FROM workflow_run_steps WHERE idempotencyKey = ?",
6164
+ )
6165
+ .get(key);
6166
+ return row ? rowToWorkflowRunStep(row) : null;
6167
+ }
6168
+
6169
+ // --- Workflow Version History ---
6170
+
6171
+ type WorkflowVersionRow = {
6172
+ id: string;
6173
+ workflowId: string;
6174
+ version: number;
6175
+ snapshot: string;
6176
+ changedByAgentId: string | null;
6177
+ createdAt: string;
6178
+ };
6179
+
6180
+ function rowToWorkflowVersion(row: WorkflowVersionRow): WorkflowVersion {
6181
+ return {
6182
+ id: row.id,
6183
+ workflowId: row.workflowId,
6184
+ version: row.version,
6185
+ snapshot: JSON.parse(row.snapshot) as WorkflowSnapshot,
6186
+ changedByAgentId: row.changedByAgentId ?? undefined,
6187
+ createdAt: row.createdAt,
6188
+ };
6189
+ }
6190
+
6191
+ export function createWorkflowVersion(data: {
6192
+ workflowId: string;
6193
+ version: number;
6194
+ snapshot: WorkflowSnapshot;
6195
+ changedByAgentId?: string;
6196
+ }): WorkflowVersion {
6197
+ const id = crypto.randomUUID();
6198
+ const row = getDb()
6199
+ .prepare<WorkflowVersionRow, [string, string, number, string, string | null]>(
6200
+ `INSERT INTO workflow_versions (id, workflowId, version, snapshot, changedByAgentId)
6201
+ VALUES (?, ?, ?, ?, ?) RETURNING *`,
6202
+ )
6203
+ .get(
6204
+ id,
6205
+ data.workflowId,
6206
+ data.version,
6207
+ JSON.stringify(data.snapshot),
6208
+ data.changedByAgentId ?? null,
6209
+ );
6210
+ if (!row) throw new Error("Failed to create workflow version");
6211
+ return rowToWorkflowVersion(row);
6212
+ }
6213
+
6214
+ export function getWorkflowVersions(workflowId: string): WorkflowVersion[] {
6215
+ return getDb()
6216
+ .prepare<WorkflowVersionRow, [string]>(
6217
+ "SELECT * FROM workflow_versions WHERE workflowId = ? ORDER BY version DESC",
6218
+ )
6219
+ .all(workflowId)
6220
+ .map(rowToWorkflowVersion);
6221
+ }
6222
+
6223
+ export function getWorkflowVersion(workflowId: string, version: number): WorkflowVersion | null {
6224
+ const row = getDb()
6225
+ .prepare<WorkflowVersionRow, [string, number]>(
6226
+ "SELECT * FROM workflow_versions WHERE workflowId = ? AND version = ?",
6227
+ )
6228
+ .get(workflowId, version);
6229
+ return row ? rowToWorkflowVersion(row) : null;
6230
+ }
6231
+
6232
+ // ============================================================================
6233
+ // Prompt Template Operations
6234
+ // ============================================================================
6235
+
6236
+ type PromptTemplateRow = {
6237
+ id: string;
6238
+ eventType: string;
6239
+ scope: string;
6240
+ scopeId: string | null;
6241
+ state: string;
6242
+ body: string;
6243
+ isDefault: number; // SQLite boolean
6244
+ version: number;
6245
+ createdBy: string | null;
6246
+ createdAt: string;
6247
+ updatedAt: string;
6248
+ };
6249
+
6250
+ type PromptTemplateHistoryRow = {
6251
+ id: string;
6252
+ templateId: string;
6253
+ version: number;
6254
+ body: string;
6255
+ state: string;
6256
+ changedBy: string | null;
6257
+ changedAt: string;
6258
+ changeReason: string | null;
6259
+ };
6260
+
6261
+ function rowToPromptTemplate(row: PromptTemplateRow): PromptTemplate {
6262
+ return {
6263
+ id: row.id,
6264
+ eventType: row.eventType,
6265
+ scope: row.scope as "global" | "agent" | "repo",
6266
+ scopeId: row.scopeId ?? null,
6267
+ state: row.state as "enabled" | "default_prompt_fallback" | "skip_event",
6268
+ body: row.body,
6269
+ isDefault: row.isDefault === 1,
6270
+ version: row.version,
6271
+ createdBy: row.createdBy ?? null,
6272
+ createdAt: row.createdAt,
6273
+ updatedAt: row.updatedAt,
6274
+ };
6275
+ }
6276
+
6277
+ function rowToPromptTemplateHistory(row: PromptTemplateHistoryRow): PromptTemplateHistory {
6278
+ return {
6279
+ id: row.id,
6280
+ templateId: row.templateId,
6281
+ version: row.version,
6282
+ body: row.body,
6283
+ state: row.state,
6284
+ changedBy: row.changedBy ?? null,
6285
+ changedAt: row.changedAt,
6286
+ changeReason: row.changeReason ?? null,
6287
+ };
6288
+ }
6289
+
6290
+ /**
6291
+ * List prompt templates with optional filters.
6292
+ */
6293
+ export function getPromptTemplates(filters?: {
6294
+ eventType?: string;
6295
+ scope?: string;
6296
+ scopeId?: string;
6297
+ isDefault?: boolean;
6298
+ }): PromptTemplate[] {
6299
+ const conditions: string[] = [];
6300
+ const params: (string | number)[] = [];
6301
+
6302
+ if (filters?.eventType) {
6303
+ conditions.push("eventType = ?");
6304
+ params.push(filters.eventType);
6305
+ }
6306
+ if (filters?.scope) {
6307
+ conditions.push("scope = ?");
6308
+ params.push(filters.scope);
6309
+ }
6310
+ if (filters?.scopeId) {
6311
+ conditions.push("scopeId = ?");
6312
+ params.push(filters.scopeId);
6313
+ }
6314
+ if (filters?.isDefault !== undefined) {
6315
+ conditions.push("isDefault = ?");
6316
+ params.push(filters.isDefault ? 1 : 0);
6317
+ }
6318
+
6319
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
6320
+ const query = `SELECT * FROM prompt_templates ${whereClause} ORDER BY eventType ASC`;
6321
+
6322
+ return getDb()
6323
+ .prepare<PromptTemplateRow, (string | number)[]>(query)
6324
+ .all(...params)
6325
+ .map(rowToPromptTemplate);
6326
+ }
6327
+
6328
+ /**
6329
+ * Get a single prompt template by ID.
6330
+ */
6331
+ export function getPromptTemplateById(id: string): PromptTemplate | null {
6332
+ const row = getDb()
6333
+ .prepare<PromptTemplateRow, [string]>("SELECT * FROM prompt_templates WHERE id = ?")
6334
+ .get(id);
6335
+ return row ? rowToPromptTemplate(row) : null;
6336
+ }
6337
+
6338
+ /**
6339
+ * Upsert a prompt template. Inserts or updates by (eventType, scope, scopeId) unique constraint.
6340
+ * Creates a history entry on both insert and update.
6341
+ */
6342
+ export function upsertPromptTemplate(data: {
6343
+ eventType: string;
6344
+ scope: "global" | "agent" | "repo";
6345
+ scopeId?: string | null;
6346
+ state?: "enabled" | "default_prompt_fallback" | "skip_event";
6347
+ body: string;
6348
+ createdBy?: string | null;
6349
+ changedBy?: string | null;
6350
+ changeReason?: string | null;
6351
+ isDefault?: boolean;
6352
+ }): PromptTemplate {
6353
+ const now = new Date().toISOString();
6354
+ const scopeId = data.scope === "global" ? null : (data.scopeId ?? null);
6355
+ const state = data.state ?? "enabled";
6356
+ const createdBy = data.createdBy ?? data.changedBy ?? null;
6357
+ const changedBy = data.changedBy ?? data.createdBy ?? null;
6358
+ const changeReason = data.changeReason ?? null;
6359
+
6360
+ // Manual check for existing entry because SQLite's UNIQUE constraint
6361
+ // treats NULL != NULL, so ON CONFLICT never fires when scopeId is NULL (global scope).
6362
+ const existing =
6363
+ scopeId === null
6364
+ ? getDb()
6365
+ .prepare<PromptTemplateRow, [string, string]>(
6366
+ "SELECT * FROM prompt_templates WHERE eventType = ? AND scope = ? AND scopeId IS NULL",
6367
+ )
6368
+ .get(data.eventType, data.scope)
6369
+ : getDb()
6370
+ .prepare<PromptTemplateRow, [string, string, string]>(
6371
+ "SELECT * FROM prompt_templates WHERE eventType = ? AND scope = ? AND scopeId = ?",
6372
+ )
6373
+ .get(data.eventType, data.scope, scopeId);
6374
+
6375
+ let row: PromptTemplateRow | null;
6376
+
6377
+ if (existing) {
6378
+ // If upserting at global scope and existing record has isDefault=true, flip it to false
6379
+ const newIsDefault =
6380
+ data.scope === "global" && existing.isDefault === 1 ? 0 : existing.isDefault;
6381
+ const newVersion = existing.version + 1;
6382
+
6383
+ row = getDb()
6384
+ .prepare<PromptTemplateRow, [string, string, number, number, string, string]>(
6385
+ `UPDATE prompt_templates SET body = ?, state = ?, isDefault = ?, version = ?, updatedAt = ?
6386
+ WHERE id = ? RETURNING *`,
6387
+ )
6388
+ .get(data.body, state, newIsDefault, newVersion, now, existing.id);
6389
+
6390
+ // Create history entry for the update
6391
+ getDb()
6392
+ .prepare(
6393
+ `INSERT INTO prompt_template_history (id, templateId, version, body, state, changedBy, changedAt, changeReason)
6394
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
6395
+ )
6396
+ .run(
6397
+ crypto.randomUUID(),
6398
+ existing.id,
6399
+ newVersion,
6400
+ data.body,
6401
+ state,
6402
+ changedBy,
6403
+ now,
6404
+ changeReason,
6405
+ );
6406
+ } else {
6407
+ const id = crypto.randomUUID();
6408
+ row = getDb()
6409
+ .prepare<
6410
+ PromptTemplateRow,
6411
+ [
6412
+ string,
6413
+ string,
6414
+ string,
6415
+ string | null,
6416
+ string,
6417
+ string,
6418
+ number,
6419
+ number,
6420
+ string | null,
6421
+ string,
6422
+ string,
6423
+ ]
6424
+ >(
6425
+ `INSERT INTO prompt_templates (id, eventType, scope, scopeId, state, body, isDefault, version, createdBy, createdAt, updatedAt)
6426
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
6427
+ )
6428
+ .get(
6429
+ id,
6430
+ data.eventType,
6431
+ data.scope,
6432
+ scopeId,
6433
+ state,
6434
+ data.body,
6435
+ data.isDefault ? 1 : 0,
6436
+ 1,
6437
+ createdBy,
6438
+ now,
6439
+ now,
6440
+ );
6441
+
6442
+ // Create history entry for the insert
6443
+ getDb()
6444
+ .prepare(
6445
+ `INSERT INTO prompt_template_history (id, templateId, version, body, state, changedBy, changedAt, changeReason)
6446
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
6447
+ )
6448
+ .run(
6449
+ crypto.randomUUID(),
6450
+ id,
6451
+ 1,
6452
+ data.body,
6453
+ state,
6454
+ changedBy,
6455
+ now,
6456
+ changeReason ?? "Initial creation",
6457
+ );
6458
+ }
6459
+
6460
+ if (!row) throw new Error("Failed to upsert prompt template");
6461
+ return rowToPromptTemplate(row);
6462
+ }
6463
+
6464
+ /**
6465
+ * Delete a prompt template by ID. Guards against deleting default templates.
6466
+ * Does NOT delete history rows (intentional for audit trail).
6467
+ */
6468
+ export function deletePromptTemplate(id: string): boolean {
6469
+ const existing = getDb()
6470
+ .prepare<PromptTemplateRow, [string]>("SELECT * FROM prompt_templates WHERE id = ?")
6471
+ .get(id);
6472
+
6473
+ if (!existing) return false;
6474
+ if (existing.isDefault === 1) {
6475
+ throw new Error(
6476
+ "Cannot delete a default prompt template. Use resetPromptTemplateToDefault instead.",
6477
+ );
6478
+ }
6479
+
6480
+ const result = getDb().run("DELETE FROM prompt_templates WHERE id = ?", [id]);
6481
+ return result.changes > 0;
6482
+ }
6483
+
6484
+ /**
6485
+ * Reset a prompt template to its default state.
6486
+ * Sets body to defaultBody, isDefault=true, state='enabled', bumps version.
6487
+ */
6488
+ export function resetPromptTemplateToDefault(id: string, defaultBody: string): PromptTemplate {
6489
+ const now = new Date().toISOString();
6490
+ const existing = getDb()
6491
+ .prepare<PromptTemplateRow, [string]>("SELECT * FROM prompt_templates WHERE id = ?")
6492
+ .get(id);
6493
+
6494
+ if (!existing) throw new Error(`Prompt template ${id} not found`);
6495
+
6496
+ const newVersion = existing.version + 1;
6497
+
6498
+ const row = getDb()
6499
+ .prepare<PromptTemplateRow, [string, number, string, string]>(
6500
+ `UPDATE prompt_templates SET body = ?, state = 'enabled', isDefault = 1, version = ?, updatedAt = ?
6501
+ WHERE id = ? RETURNING *`,
6502
+ )
6503
+ .get(defaultBody, newVersion, now, id);
6504
+
6505
+ if (!row) throw new Error("Failed to reset prompt template to default");
6506
+
6507
+ // Create history entry
6508
+ getDb()
6509
+ .prepare(
6510
+ `INSERT INTO prompt_template_history (id, templateId, version, body, state, changedBy, changedAt, changeReason)
6511
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
6512
+ )
6513
+ .run(
6514
+ crypto.randomUUID(),
6515
+ id,
6516
+ newVersion,
6517
+ defaultBody,
6518
+ "enabled",
6519
+ null,
6520
+ now,
6521
+ "Reset to default",
6522
+ );
6523
+
6524
+ return rowToPromptTemplate(row);
6525
+ }
6526
+
6527
+ /**
6528
+ * Get version history for a prompt template, ordered by version DESC.
6529
+ */
6530
+ export function getPromptTemplateHistory(templateId: string): PromptTemplateHistory[] {
6531
+ return getDb()
6532
+ .prepare<PromptTemplateHistoryRow, [string]>(
6533
+ "SELECT * FROM prompt_template_history WHERE templateId = ? ORDER BY version DESC",
6534
+ )
6535
+ .all(templateId)
6536
+ .map(rowToPromptTemplateHistory);
6537
+ }
6538
+
6539
+ /**
6540
+ * Resolve the best prompt template for a given eventType using scope precedence.
6541
+ *
6542
+ * Two-pass resolution:
6543
+ * Pass 1 (exact match): Try exact eventType at agent → repo → global scope.
6544
+ * Pass 2 (wildcard): Generate wildcards from eventType (e.g. "github.pull_request.*", "github.*")
6545
+ * and try each at agent → repo → global scope.
6546
+ *
6547
+ * Exact match at ANY scope always beats wildcard at ANY scope.
6548
+ *
6549
+ * State behavior:
6550
+ * - 'enabled': return the template
6551
+ * - 'skip_event': return { skip: true }
6552
+ * - 'default_prompt_fallback': continue to next scope level
6553
+ */
6554
+ export function resolvePromptTemplate(
6555
+ eventType: string,
6556
+ agentId?: string,
6557
+ repoId?: string,
6558
+ ): { template: PromptTemplate } | { skip: true } | null {
6559
+ // Helper to look up a template at a specific scope
6560
+ const lookupAtScope = (
6561
+ et: string,
6562
+ scope: "global" | "agent" | "repo",
6563
+ scopeId: string | null,
6564
+ ): PromptTemplateRow | undefined => {
6565
+ if (scopeId === null) {
6566
+ return (
6567
+ getDb()
6568
+ .prepare<PromptTemplateRow, [string, string]>(
6569
+ "SELECT * FROM prompt_templates WHERE eventType = ? AND scope = ? AND scopeId IS NULL",
6570
+ )
6571
+ .get(et, scope) ?? undefined
6572
+ );
6573
+ }
6574
+ return (
6575
+ getDb()
6576
+ .prepare<PromptTemplateRow, [string, string, string]>(
6577
+ "SELECT * FROM prompt_templates WHERE eventType = ? AND scope = ? AND scopeId = ?",
6578
+ )
6579
+ .get(et, scope, scopeId) ?? undefined
6580
+ );
6581
+ };
6582
+
6583
+ // Try resolution at the scope chain for a given eventType string
6584
+ const tryResolve = (et: string): { template: PromptTemplate } | { skip: true } | "continue" => {
6585
+ // Build scope chain: agent → repo → global
6586
+ const scopeChain: Array<{ scope: "global" | "agent" | "repo"; scopeId: string | null }> = [];
6587
+ if (agentId) scopeChain.push({ scope: "agent", scopeId: agentId });
6588
+ if (repoId) scopeChain.push({ scope: "repo", scopeId: repoId });
6589
+ scopeChain.push({ scope: "global", scopeId: null });
6590
+
6591
+ for (const { scope, scopeId } of scopeChain) {
6592
+ const row = lookupAtScope(et, scope, scopeId);
6593
+ if (!row) continue;
6594
+
6595
+ if (row.state === "enabled") {
6596
+ return { template: rowToPromptTemplate(row) };
6597
+ }
6598
+ if (row.state === "skip_event") {
6599
+ return { skip: true };
6600
+ }
6601
+ // default_prompt_fallback: continue to next scope
6602
+ }
6603
+
6604
+ return "continue";
6605
+ };
6606
+
6607
+ // Pass 1: exact match
6608
+ const exactResult = tryResolve(eventType);
6609
+ if (exactResult !== "continue") return exactResult;
6610
+
6611
+ // Pass 2: wildcard matching
6612
+ // e.g. "github.pull_request.review_submitted" → ["github.pull_request.*", "github.*"]
6613
+ const parts = eventType.split(".");
6614
+ const wildcards: string[] = [];
6615
+ for (let i = parts.length - 1; i >= 1; i--) {
6616
+ wildcards.push(`${parts.slice(0, i).join(".")}.*`);
6617
+ }
6618
+
6619
+ for (const wildcard of wildcards) {
6620
+ const wildcardResult = tryResolve(wildcard);
6621
+ if (wildcardResult !== "continue") return wildcardResult;
6622
+ }
6623
+
6624
+ return null;
6625
+ }
6626
+
6627
+ /**
6628
+ * Checkout a prompt template to a specific version from history.
6629
+ * Copies body and state from the history entry into the live record, bumps version.
6630
+ */
6631
+ export function checkoutPromptTemplate(id: string, targetVersion: number): PromptTemplate {
6632
+ const now = new Date().toISOString();
6633
+
6634
+ const existing = getDb()
6635
+ .prepare<PromptTemplateRow, [string]>("SELECT * FROM prompt_templates WHERE id = ?")
6636
+ .get(id);
6637
+ if (!existing) throw new Error(`Prompt template ${id} not found`);
6638
+
6639
+ const historyEntry = getDb()
6640
+ .prepare<PromptTemplateHistoryRow, [string, number]>(
6641
+ "SELECT * FROM prompt_template_history WHERE templateId = ? AND version = ?",
6642
+ )
6643
+ .get(id, targetVersion);
6644
+ if (!historyEntry)
6645
+ throw new Error(`No history entry at version ${targetVersion} for template ${id}`);
6646
+
6647
+ const newVersion = existing.version + 1;
6648
+
6649
+ const row = getDb()
6650
+ .prepare<PromptTemplateRow, [string, string, number, string, string]>(
6651
+ `UPDATE prompt_templates SET body = ?, state = ?, version = ?, updatedAt = ?
6652
+ WHERE id = ? RETURNING *`,
6653
+ )
6654
+ .get(historyEntry.body, historyEntry.state, newVersion, now, id);
6655
+
6656
+ if (!row) throw new Error("Failed to checkout prompt template");
6657
+
6658
+ // Create history entry for the checkout
6659
+ getDb()
6660
+ .prepare(
6661
+ `INSERT INTO prompt_template_history (id, templateId, version, body, state, changedBy, changedAt, changeReason)
6662
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
6663
+ )
6664
+ .run(
6665
+ crypto.randomUUID(),
6666
+ id,
6667
+ newVersion,
6668
+ historyEntry.body,
6669
+ historyEntry.state,
6670
+ null,
6671
+ now,
6672
+ `Checked out from version ${targetVersion}`,
6673
+ );
6674
+
6675
+ return rowToPromptTemplate(row);
6676
+ }
6677
+
6678
+ // ─── Channel Activity Cursors ─────────────────────────────────────────────────
6679
+
6680
+ type ChannelActivityCursorRow = {
6681
+ channelId: string;
6682
+ lastSeenTs: string;
6683
+ updatedAt: string;
6684
+ };
6685
+
6686
+ export interface ChannelActivityCursor {
6687
+ channelId: string;
6688
+ lastSeenTs: string;
6689
+ updatedAt: string;
6690
+ }
6691
+
6692
+ function rowToChannelActivityCursor(row: ChannelActivityCursorRow): ChannelActivityCursor {
6693
+ return {
6694
+ channelId: row.channelId,
6695
+ lastSeenTs: row.lastSeenTs,
6696
+ updatedAt: row.updatedAt,
6697
+ };
6698
+ }
6699
+
6700
+ export function getAllChannelActivityCursors(): ChannelActivityCursor[] {
6701
+ return getDb()
6702
+ .prepare<ChannelActivityCursorRow, []>("SELECT * FROM channel_activity_cursors")
6703
+ .all()
6704
+ .map(rowToChannelActivityCursor);
6705
+ }
6706
+
6707
+ export function getChannelActivityCursor(channelId: string): ChannelActivityCursor | null {
6708
+ const row = getDb()
6709
+ .prepare<ChannelActivityCursorRow, [string]>(
6710
+ "SELECT * FROM channel_activity_cursors WHERE channelId = ?",
6711
+ )
6712
+ .get(channelId);
6713
+ return row ? rowToChannelActivityCursor(row) : null;
6714
+ }
6715
+
6716
+ export function upsertChannelActivityCursor(channelId: string, lastSeenTs: string): void {
6717
+ getDb()
6718
+ .prepare(
6719
+ `INSERT INTO channel_activity_cursors (channelId, lastSeenTs, updatedAt)
6720
+ VALUES (?, ?, datetime('now'))
6721
+ ON CONFLICT(channelId) DO UPDATE SET lastSeenTs = excluded.lastSeenTs, updatedAt = excluded.updatedAt`,
6722
+ )
6723
+ .run(channelId, lastSeenTs);
6724
+ }