@desplega.ai/agent-swarm 1.131.0 → 1.133.1

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 (265) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-w53mbns9.js → actions-cskpayd7.js} +11 -9
  3. package/dist/{anthropic-messages-rjmd2e7n.js → anthropic-messages-tpxtbx7b.js} +28 -12
  4. package/dist/{app-m9gwqsqv.js → app-ang77nbb.js} +8 -6
  5. package/dist/{assistant-bp65m6f3.js → assistant-b4exebdw.js} +16 -13
  6. package/dist/{azure-openai-responses-c0yjmbfd.js → azure-openai-responses-m3921t4y.js} +8 -8
  7. package/dist/{boot-reembed-62p0at2k.js → boot-reembed-ps2aftpb.js} +9 -7
  8. package/dist/{boot-reembed-xmfp6wdh.js → boot-reembed-znyk3nxb.js} +10 -8
  9. package/dist/{boot-scrub-logs-rr8q2dms.js → boot-scrub-logs-2ax3xgz1.js} +7 -5
  10. package/dist/{claude-adapter-2y47mrkd.js → claude-adapter-3ct8kgxw.js} +13 -9
  11. package/dist/{claude-managed-adapter-gv19tdew.js → claude-managed-adapter-akx4ntf2.js} +2 -2
  12. package/dist/{cli-t1xze5qh.js → cli-13w2hwkh.js} +1 -1
  13. package/dist/{cli-efxfnq6q.js → cli-1532szb1.js} +8 -6
  14. package/dist/{cli-d2tfkcga.js → cli-23d3rseb.js} +33 -17
  15. package/dist/{cli-nm5gtp1x.js → cli-2a75z9c5.js} +196 -7
  16. package/dist/{cli-fywms69x.js → cli-2t30rj0d.js} +1 -1
  17. package/dist/{cli-cvgjwnh1.js → cli-3c3tdgab.js} +10 -10
  18. package/dist/{cli-pm6xbh84.js → cli-3zzxa9jz.js} +46 -16
  19. package/dist/{cli-cdeg08wx.js → cli-4k6233gc.js} +1 -1
  20. package/dist/{cli-t55yw3a1.js → cli-5p2tz05r.js} +2 -2
  21. package/dist/{cli-0rcyq6d9.js → cli-5z2yhzvd.js} +125 -47
  22. package/dist/{cli-3kf51j15.js → cli-6zfq31nj.js} +9 -3
  23. package/dist/{cli-er3a4bb0.js → cli-8m9de10a.js} +6 -1
  24. package/dist/{cli-gm9cbhbq.js → cli-8t14vg9w.js} +27 -8
  25. package/dist/{cli-72q8m8gk.js → cli-9nfc2es8.js} +6 -6
  26. package/dist/{cli-5ze9rv97.js → cli-9ta3pwms.js} +11 -4
  27. package/dist/{cli-78xwvkhj.js → cli-avr0ns7z.js} +2 -2
  28. package/dist/{cli-n6h8qt8n.js → cli-c897wyhw.js} +12 -7
  29. package/dist/{cli-2q72qz2s.js → cli-dgk20ft7.js} +15 -6
  30. package/dist/cli-et1bbwqf.js +13 -0
  31. package/dist/{cli-bqw0mt1n.js → cli-f5w27pj3.js} +3 -3
  32. package/dist/{cli-2wadx7p2.js → cli-f8dre4bz.js} +2 -2
  33. package/dist/{cli-e7ckcg4v.js → cli-ftwpv451.js} +170 -387
  34. package/dist/{cli-g6b8x1h8.js → cli-g854s2dw.js} +1 -1
  35. package/dist/{cli-5kx36evn.js → cli-ggmdxeqa.js} +13 -8
  36. package/dist/cli-hkwfe3qj.js +530 -0
  37. package/dist/{cli-5dsbcjjw.js → cli-jqwnqx9m.js} +1 -1
  38. package/dist/{cli-9dr5qzc0.js → cli-ny122vh4.js} +21 -10
  39. package/dist/{cli-5v4rw6yd.js → cli-pvzqhbs9.js} +2 -2
  40. package/dist/cli-pzq5rjym.js +15 -0
  41. package/dist/{cli-xpxde7rm.js → cli-qes8ef67.js} +48 -30
  42. package/dist/{cli-sajgpkqg.js → cli-qs9hhsvb.js} +1723 -325
  43. package/dist/cli-qys3rhhz.js +27 -0
  44. package/dist/{cli-rzbpmxdr.js → cli-r6tpm37h.js} +2 -2
  45. package/dist/{cli-qv61hp37.js → cli-rch80c76.js} +2 -2
  46. package/dist/{cli-0258203w.js → cli-rpp0x4zy.js} +137 -17
  47. package/dist/{cli-5n6e5yfn.js → cli-s1k0wxhe.js} +12 -7
  48. package/dist/{cli-psh3c4va.js → cli-sfd9v7sj.js} +1 -1
  49. package/dist/{cli-70xqj0z3.js → cli-sh3ytsyj.js} +49 -39
  50. package/dist/{cli-zsspvvm2.js → cli-w1mp9jy7.js} +1 -1
  51. package/dist/{cli-d3x3x1c9.js → cli-xdb4mpnp.js} +2 -2
  52. package/dist/{cli-57khth04.js → cli-xmyqv00z.js} +120 -4
  53. package/dist/{cli-yw8vghx2.js → cli-xv8t8p3c.js} +65 -32
  54. package/dist/{cli-f1am9szv.js → cli-xxv5pn4f.js} +1 -1
  55. package/dist/{cli-t76vw5db.js → cli-y08jvxzx.js} +601 -140
  56. package/dist/{cli-a4jzdpa3.js → cli-zfbf3srj.js} +650 -245
  57. package/dist/{cli-a40rnf71.js → cli-zmq89k34.js} +6 -6
  58. package/dist/{cli-qmdnhpej.js → cli-zr9q011v.js} +23 -13
  59. package/dist/cli.js +22 -22
  60. package/dist/{codex-adapter-d24q75wk.js → codex-adapter-bef93pq4.js} +9 -7
  61. package/dist/{codex-hook-hq54t9ft.js → codex-hook-e8dba65p.js} +3 -2
  62. package/dist/{codex-session-runner-g50vcsex.js → codex-session-runner-5pkxys7x.js} +9 -7
  63. package/dist/{commands-c3nrc5hg.js → commands-e23s1bff.js} +9 -7
  64. package/dist/{db-nt7fg3tw.js → db-m3nabatn.js} +17 -5
  65. package/dist/{google-generative-ai-hysrd8qx.js → google-generative-ai-8maw55xh.js} +11 -8
  66. package/dist/{google-vertex-p5arrga0.js → google-vertex-8vjqmx8g.js} +11 -8
  67. package/dist/{handlers-fkr6swat.js → handlers-akazedvr.js} +16 -13
  68. package/dist/{hook-77wpvnjc.js → hook-ze4cpf0v.js} +55 -41
  69. package/dist/{hook-g019yehy.js → hook-zfqyyenw.js} +10 -7
  70. package/dist/{http-jewj0syk.js → http-sja0n6kw.js} +730 -150
  71. package/dist/index-0gr8vd6k.js +39 -0
  72. package/dist/{index-7n2xpbd4.js → index-354vkhdv.js} +154 -74
  73. package/dist/{index-070v4eyb.js → index-5qthek5t.js} +2 -3
  74. package/dist/{index-hfvggd0z.js → index-95t3cqq5.js} +16 -14
  75. package/dist/{index-9znhar3x.js → index-sea66619.js} +3 -4
  76. package/dist/{index-6gm5he8h.js → index-vpn36mma.js} +15 -13
  77. package/dist/{keepalive-j0fjvqfd.js → keepalive-3sp3meqj.js} +16 -10
  78. package/dist/{lead-1n8x30xe.js → lead-mefeewkf.js} +39 -36
  79. package/dist/{maintenance-gvev11sa.js → maintenance-8tbrbjfg.js} +10 -8
  80. package/dist/mistral-conversations-00zrp7g4.js +717 -0
  81. package/dist/{oauth-refresh-sweep-s9ecsvh0.js → oauth-refresh-sweep-srjwjz6m.js} +8 -6
  82. package/dist/{onboard-pvm5b38w.js → onboard-mt2tgyjg.js} +7 -7
  83. package/dist/{openai-codex-responses-5g27pd6f.js → openai-codex-responses-map32h2s.js} +18 -18
  84. package/dist/{openai-completions-3vkmfz2a.js → openai-completions-w871b1cq.js} +12 -11
  85. package/dist/{openai-responses-dg4t7j7a.js → openai-responses-b0j7xnw8.js} +15 -12
  86. package/dist/{opencode-adapter-1gm38aba.js → opencode-adapter-b39jefcj.js} +5 -3
  87. package/dist/{openrouter-images-g3gm8931.js → openrouter-images-srfkywy0.js} +4 -4
  88. package/dist/{otel-impl-p6jvgd9c.js → otel-impl-z6pxt3j9.js} +224 -9
  89. package/dist/{pi-mono-adapter-t091s27g.js → pi-mono-adapter-btvqmj58.js} +1138 -408
  90. package/dist/{pricing-refresh-k49rcyed.js → pricing-refresh-kyq8cezh.js} +9 -7
  91. package/dist/{rbac-roles-cr80g96t.js → rbac-roles-3ryfrfna.js} +8 -6
  92. package/dist/{rbac-roles-30pspvy7.js → rbac-roles-6qfvbee1.js} +9 -7
  93. package/dist/{render-v2-xm457ng1.js → render-v2-p61ppjey.js} +8 -6
  94. package/dist/{seed-pricing-yhpv35fr.js → seed-pricing-j56bbj4y.js} +8 -6
  95. package/dist/{setup-nytnmwwr.js → setup-tpr6c1kn.js} +2 -2
  96. package/dist/{worker-msytr2cw.js → worker-9w1v0mbb.js} +39 -36
  97. package/openapi.json +542 -16
  98. package/package.json +6 -6
  99. package/src/be/budget-refusal-notify.ts +14 -7
  100. package/src/be/db-queries/task-artifacts.ts +182 -0
  101. package/src/be/db.ts +783 -169
  102. package/src/be/events.ts +5 -0
  103. package/src/be/migrations/130_scratch_script_retention_grace.sql +6 -0
  104. package/src/be/migrations/131_session_logs_created_at_index.sql +2 -0
  105. package/src/be/migrations/132_multi_runtime_instances.sql +44 -0
  106. package/src/be/migrations/133_workflow_run_trigger_type.sql +12 -0
  107. package/src/be/migrations/135_backfill_task_pull_request_attachments.sql +228 -0
  108. package/src/be/migrations/136_task_requester_provenance.sql +37 -0
  109. package/src/be/migrations/137_memory_retrieval_composite_index.sql +1 -0
  110. package/src/be/multi-runtime.ts +430 -0
  111. package/src/be/scripts/db.ts +43 -1
  112. package/src/be/scripts/extract-schema.ts +3 -1
  113. package/src/be/scripts/retention.ts +164 -0
  114. package/src/be/scripts/run-saved.ts +16 -1
  115. package/src/be/scripts/typecheck.ts +3 -2
  116. package/src/be/seed-skills/index.ts +7 -0
  117. package/src/be/steering.ts +1 -1
  118. package/src/be/swarm-config-guard.ts +18 -0
  119. package/src/commands/credential-wait.ts +54 -0
  120. package/src/commands/onboard/steps/core-credentials.tsx +1 -1
  121. package/src/commands/onboard/steps/custom-templates.tsx +1 -1
  122. package/src/commands/onboard/steps/harness-credentials.tsx +1 -1
  123. package/src/commands/onboard/steps/health-check.tsx +1 -1
  124. package/src/commands/onboard/steps/start.tsx +1 -1
  125. package/src/commands/profile-sync-audit.ts +56 -0
  126. package/src/commands/profile-sync.ts +287 -15
  127. package/src/commands/provider-credentials.ts +46 -19
  128. package/src/commands/runner.ts +196 -33
  129. package/src/commands/templates.ts +22 -0
  130. package/src/github/handlers.ts +55 -9
  131. package/src/heartbeat/heartbeat.ts +71 -12
  132. package/src/hooks/hook.ts +49 -33
  133. package/src/http/active-sessions.ts +11 -1
  134. package/src/http/agents.ts +261 -30
  135. package/src/http/all-routes.ts +1 -0
  136. package/src/http/config.ts +13 -2
  137. package/src/http/core.ts +102 -4
  138. package/src/http/db-query-bounded.ts +313 -0
  139. package/src/http/db-query-shared.ts +249 -0
  140. package/src/http/db-query.ts +54 -42
  141. package/src/http/events.ts +2 -0
  142. package/src/http/index.ts +27 -2
  143. package/src/http/mcp-bridge.ts +37 -3
  144. package/src/http/mcp.ts +12 -0
  145. package/src/http/memory.ts +13 -2
  146. package/src/http/openapi.ts +1 -0
  147. package/src/http/poll.ts +70 -27
  148. package/src/http/route-def.ts +43 -9
  149. package/src/http/schedules.ts +2 -2
  150. package/src/http/scripts.ts +31 -4
  151. package/src/http/session-data.ts +44 -0
  152. package/src/http/tasks.ts +2 -2
  153. package/src/http/users.ts +74 -52
  154. package/src/http/webhooks.ts +5 -2
  155. package/src/http/workflows.ts +1 -4
  156. package/src/http/x.ts +14 -0
  157. package/src/integrations/kapso/inbound.ts +1 -1
  158. package/src/jira/outbound.ts +39 -8
  159. package/src/jira/webhook-lifecycle.ts +13 -2
  160. package/src/linear/outbound.ts +43 -10
  161. package/src/oauth/keepalive.ts +13 -2
  162. package/src/prompts/base-prompt.ts +1 -1
  163. package/src/prompts/session-templates.ts +45 -7
  164. package/src/providers/claude-adapter.ts +6 -0
  165. package/src/providers/claude-managed-models.ts +34 -10
  166. package/src/providers/codex-adapter.ts +6 -0
  167. package/src/providers/opencode-adapter.ts +6 -0
  168. package/src/providers/pi-mono-adapter.ts +8 -0
  169. package/src/providers/pricing-sources.md +24 -0
  170. package/src/queue-stall-alarm.ts +216 -0
  171. package/src/scheduler/scheduler.ts +7 -0
  172. package/src/script-workflows/executor.ts +2 -1
  173. package/src/scripts-runtime/executors/native.ts +2 -1
  174. package/src/scripts-runtime/executors/types.ts +5 -0
  175. package/src/scripts-runtime/loader.ts +5 -0
  176. package/src/scripts-runtime/swarm-config.ts +8 -0
  177. package/src/scripts-runtime/swarm-sdk.ts +7 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/tests/budget-claim-gate.test.ts +20 -0
  181. package/src/tests/claude-adapter.test.ts +32 -0
  182. package/src/tests/claude-managed-adapter.test.ts +38 -7
  183. package/src/tests/codex-adapter.test.ts +30 -0
  184. package/src/tests/create-task-validation.test.ts +128 -0
  185. package/src/tests/credential-wait.test.ts +67 -1
  186. package/src/tests/db-query.test.ts +711 -2
  187. package/src/tests/http-api-integration.test.ts +81 -0
  188. package/src/tests/http-users.test.ts +166 -1
  189. package/src/tests/identity-field-budget.test.ts +176 -0
  190. package/src/tests/mcp-tools-user.test.ts +34 -0
  191. package/src/tests/mcp-user-route.test.ts +17 -0
  192. package/src/tests/metrics-http.test.ts +44 -0
  193. package/src/tests/multi-runtime-registration.test.ts +2703 -0
  194. package/src/tests/openapi-response-contract.test.ts +24 -0
  195. package/src/tests/openapi-route-headers.test.ts +83 -0
  196. package/src/tests/opencode-adapter.test.ts +40 -0
  197. package/src/tests/outbound-listener-rejection.test.ts +138 -0
  198. package/src/tests/pi-mono-adapter.test.ts +98 -0
  199. package/src/tests/profile-sync-warning.test.ts +277 -0
  200. package/src/tests/profile-sync.test.ts +232 -6
  201. package/src/tests/prompt-template-session.test.ts +32 -3
  202. package/src/tests/queue-stall-alarm.test.ts +226 -0
  203. package/src/tests/requester-comms.test.ts +32 -0
  204. package/src/tests/runner-requester-profile.test.ts +38 -0
  205. package/src/tests/sandboxed-process.test.ts +45 -0
  206. package/src/tests/schedule-target-type.test.ts +38 -0
  207. package/src/tests/script-connections.test.ts +16 -3
  208. package/src/tests/scripts-external-api.test.ts +8 -3
  209. package/src/tests/scripts-http.test.ts +12 -0
  210. package/src/tests/scripts-retention.test.ts +384 -0
  211. package/src/tests/scripts-runtime-identity.test.ts +375 -0
  212. package/src/tests/scripts-runtime.test.ts +4 -3
  213. package/src/tests/sdk-allowlist.test.ts +47 -0
  214. package/src/tests/send-task-requested-by.test.ts +9 -0
  215. package/src/tests/session-costs.test.ts +506 -1
  216. package/src/tests/shutdown-error-scrubbing.test.ts +92 -0
  217. package/src/tests/steering-core.test.ts +28 -0
  218. package/src/tests/task-artifacts.test.ts +386 -0
  219. package/src/tests/task-attachments-schema.test.ts +17 -1
  220. package/src/tests/task-completion-idempotency.test.ts +57 -0
  221. package/src/tests/task-pull-request-backfill-migration.test.ts +321 -0
  222. package/src/tests/update-profile-api.test.ts +67 -39
  223. package/src/tests/update-profile-auth.test.ts +48 -4
  224. package/src/tests/user-attribution-migration.test.ts +54 -0
  225. package/src/tests/user-identity.test.ts +72 -0
  226. package/src/tests/workflow-async-v2.test.ts +39 -1
  227. package/src/tests/workflow-http-v2.test.ts +3 -2
  228. package/src/tests/workflow-mcp-trigger-schema.test.ts +52 -3
  229. package/src/tests/workflow-retry-v2.test.ts +45 -1
  230. package/src/tests/workflow-swarm-script.test.ts +14 -5
  231. package/src/tools/db-query.ts +18 -8
  232. package/src/tools/get-task-details.ts +24 -3
  233. package/src/tools/manage-user.ts +36 -3
  234. package/src/tools/poll-task.ts +93 -14
  235. package/src/tools/script-common.ts +5 -0
  236. package/src/tools/send-task.ts +1 -4
  237. package/src/tools/store-progress.ts +13 -2
  238. package/src/tools/swarm-config/delete-config.ts +9 -0
  239. package/src/tools/swarm-config/set-config.ts +3 -2
  240. package/src/tools/task-action.ts +30 -0
  241. package/src/tools/task-tool-ctx.ts +8 -1
  242. package/src/tools/update-profile.ts +16 -21
  243. package/src/tools/utils.ts +5 -0
  244. package/src/tools/workflows/trigger-workflow.ts +7 -1
  245. package/src/types.ts +165 -1
  246. package/src/utils/github-pull-request.ts +76 -0
  247. package/src/utils/identity-field-budget.ts +73 -0
  248. package/src/utils/multi-runtime.ts +20 -0
  249. package/src/utils/requester-comms.ts +24 -0
  250. package/src/utils/sandboxed-process.ts +40 -14
  251. package/src/workflows/engine.ts +22 -2
  252. package/src/workflows/executors/swarm-script.ts +24 -1
  253. package/src/workflows/resume.ts +19 -11
  254. package/src/workflows/retry-poller.ts +1 -0
  255. package/src/workflows/triggers.ts +4 -0
  256. package/templates/schedules/daily-compounding-reflection/content.md +12 -3
  257. package/templates/skills/db-query-guidance/config.json +14 -0
  258. package/templates/skills/db-query-guidance/content.md +57 -0
  259. package/dist/cli-3te4jdkm.js +0 -225
  260. package/dist/cli-bmze986d.js +0 -7
  261. package/dist/cli-kh3ba2zf.js +0 -607
  262. package/dist/index-hp1kdbjj.js +0 -36
  263. package/dist/mistral-conversations-81fwav0j.js +0 -30578
  264. package/dist/otel-nqa12wf3.js +0 -3149
  265. package/dist/{pi-messages-tqj8q6b9.js → pi-messages-sssy32x4.js} +3 -3
@@ -40,7 +40,7 @@ const scheduleUpdateBodySchema = z.object({
40
40
  taskTemplate: z.string().optional(),
41
41
  taskType: z.string().optional(),
42
42
  tags: z.array(z.string()).optional(),
43
- priority: z.number().int().optional(),
43
+ priority: z.number().int().min(0).max(100).optional(),
44
44
  targetAgentId: z.string().nullable().optional(),
45
45
  enabled: z.boolean().optional(),
46
46
  timezone: z.string().optional(),
@@ -96,7 +96,7 @@ const createSchedule = route({
96
96
  taskTemplate: z.string().min(1).optional(),
97
97
  taskType: z.string().optional(),
98
98
  tags: z.array(z.string()).optional(),
99
- priority: z.number().int().optional(),
99
+ priority: z.number().int().min(0).max(100).optional(),
100
100
  targetAgentId: z.string().optional(),
101
101
  enabled: z.boolean().optional(),
102
102
  timezone: z.string().optional(),
@@ -24,7 +24,9 @@ import {
24
24
  listScriptApisForScript,
25
25
  listScripts,
26
26
  listScriptVersions,
27
+ restoreScratchScriptLastUsedIfUnchanged,
27
28
  rotateScriptApiSecret,
29
+ touchScratchScriptLastUsed,
28
30
  updateScriptApi,
29
31
  upsertScriptByName,
30
32
  } from "../be/scripts/db";
@@ -51,7 +53,7 @@ import {
51
53
  ScriptVersionRecordSchema,
52
54
  } from "../types";
53
55
  import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
54
- import { route } from "./route-def";
56
+ import { route, runtimeInstanceHeader } from "./route-def";
55
57
  import { json, jsonError } from "./utils";
56
58
 
57
59
  const scriptNameSchema = z.string().min(1).max(200);
@@ -242,6 +244,7 @@ const runRoute = route({
242
244
  description:
243
245
  "Inline source skips typecheck and is auto-saved as a scratch script only on success.",
244
246
  tags: ["Scripts"],
247
+ headers: runtimeInstanceHeader("acquire work through the script SDK"),
245
248
  body: runBodySchema,
246
249
  responses: {
247
250
  200: { description: "Script run completed", schema: scriptRunResponseSchema },
@@ -693,14 +696,23 @@ export async function handleScripts(
693
696
  const agent = requireAgent(res, agentId);
694
697
  if (!agent) return true;
695
698
 
699
+ // Per-boot identity of the invoking worker process. Carried into the
700
+ // script runtime as system context so SDK work-acquisition calls present
701
+ // the same runtime the worker itself would — never script input.
702
+ const runtimeInstanceId = ((h) => (Array.isArray(h) ? h[0] : h))(
703
+ req.headers["x-runtime-instance-id"],
704
+ );
705
+
696
706
  let source = parsed.body.source;
697
707
  let fsMode = parsed.body.fsMode;
708
+ let namedScript: ScriptRecord | null = null;
698
709
  if (parsed.body.name) {
699
710
  const script = resolveScript(parsed.body.name, agent.id, parsed.body.scope);
700
711
  if (!script) {
701
712
  jsonError(res, "Script not found", 404);
702
713
  return true;
703
714
  }
715
+ namedScript = script;
704
716
  source = script.source;
705
717
  fsMode = script.fsMode;
706
718
  }
@@ -711,21 +723,37 @@ export async function handleScripts(
711
723
  }
712
724
 
713
725
  const startedAt = new Date().toISOString();
726
+ // Touch before executing so an already-stale scratch script isn't reaped
727
+ // by the retention sweep while this run is still in flight.
728
+ const runStartTouch = namedScript?.isScratch
729
+ ? touchScratchScriptLastUsed(namedScript.id)
730
+ : null;
714
731
  const credentials = await buildScriptCredentialBindingsWithFailures({ agentId: agent.id });
715
732
  const output = await runScript({
716
733
  source: source as string,
717
734
  args: parsed.body.args,
718
735
  fsMode,
719
736
  agentId: agent.id,
737
+ runtimeInstanceId,
720
738
  egressSecrets: credentials.egressSecrets,
721
739
  failedBindings: credentials.failedBindings,
722
740
  apiConnections: getScriptApiConnectionDescriptors({ agentId: agent.id }),
723
741
  mcpConnections: getScriptMcpConnectionDescriptors({ agentId: agent.id }),
724
742
  });
743
+ const ok = output.exitCode === 0 && !output.error && !output.runtimeError;
744
+
745
+ if (namedScript?.isScratch && ok) {
746
+ touchScratchScriptLastUsed(namedScript.id);
747
+ } else if (namedScript?.isScratch && runStartTouch) {
748
+ // Failed run — restore the pre-run timestamp so it doesn't buy the
749
+ // script another retention window, unless a concurrent run already
750
+ // touched it since.
751
+ restoreScratchScriptLastUsedIfUnchanged(namedScript.id, namedScript.updatedAt, runStartTouch);
752
+ }
725
753
 
726
754
  // Persist output to KV when idempotencyKey is provided and run succeeded
727
755
  let kvSaved: { namespace: string; key: string } | undefined;
728
- if (parsed.body.idempotencyKey && !output.error && output.exitCode === 0) {
756
+ if (parsed.body.idempotencyKey && ok) {
729
757
  const kvNamespace = `script:executions`;
730
758
  const kvKey = parsed.body.idempotencyKey;
731
759
  const kvValue = {
@@ -745,7 +773,7 @@ export async function handleScripts(
745
773
  }
746
774
 
747
775
  let autoSaved: { slug: string; reason: string } | undefined;
748
- if (parsed.body.source && !output.error && output.exitCode === 0) {
776
+ if (parsed.body.source && ok) {
749
777
  const slug = scratchSlug(parsed.body.intent, parsed.body.source);
750
778
  await upsertScriptByName({
751
779
  name: slug,
@@ -767,7 +795,6 @@ export async function handleScripts(
767
795
  // Persist the inline run (no journal) so one-off executions show up alongside
768
796
  // durable workflow runs in the Script Runs dashboard. Best-effort: recording
769
797
  // must never fail the actual execution.
770
- const ok = output.exitCode === 0 && !output.error && !output.runtimeError;
771
798
  const runError = ok
772
799
  ? undefined
773
800
  : scrubSecrets(
@@ -5,6 +5,7 @@ import {
5
5
  createSessionLogs,
6
6
  getActivePricingRow,
7
7
  getAllSessionCosts,
8
+ getAttributionByPerson,
8
9
  getDashboardCostSummary,
9
10
  getSessionCostSummary,
10
11
  getSessionCostsByAgentId,
@@ -34,6 +35,9 @@ const SessionCostSummaryTotalsSchema = z.object({
34
35
  totalSessions: z.number().int(),
35
36
  avgCostPerSession: z.number(),
36
37
  attributedCostUsd: z.number(),
38
+ attributableCostUsd: z.number(),
39
+ excludedCostUsd: z.number(),
40
+ excludedTaskCount: z.number().int(),
37
41
  });
38
42
 
39
43
  /** Mirrors `SessionCostDailyRow` in src/be/db.ts. */
@@ -79,6 +83,17 @@ const DashboardCostSummarySchema = z.object({
79
83
  costMtd: z.number(),
80
84
  });
81
85
 
86
+ /** Mirrors `AttributionByPersonRow` in src/be/db.ts. */
87
+ const AttributionByPersonRowSchema = z.object({
88
+ userId: z.string(),
89
+ problemsInitiated: z.number().int(),
90
+ problemsShipped: z.number().int(),
91
+ agentsReached: z.number().int(),
92
+ reposReached: z.number().int(),
93
+ surfacesReached: z.number().int(),
94
+ firstPassYield: z.null(),
95
+ });
96
+
82
97
  // ─── Route Definitions ───────────────────────────────────────────────────────
83
98
 
84
99
  const createSessionLogsRoute = route({
@@ -203,6 +218,24 @@ const getSessionCostSummaryRoute = route({
203
218
  },
204
219
  });
205
220
 
221
+ const getAttributionByPersonRoute = route({
222
+ method: "get",
223
+ path: "/api/attribution/by-person",
224
+ pattern: ["api", "attribution", "by-person"],
225
+ summary: "Four-metric per-person attribution (problems initiated/shipped, reach)",
226
+ tags: ["Session Data"],
227
+ query: z.object({
228
+ startDate: z.string().optional(),
229
+ endDate: z.string().optional(),
230
+ }),
231
+ responses: {
232
+ 200: {
233
+ description: "Per-person attribution rows",
234
+ schema: z.object({ rows: z.array(AttributionByPersonRowSchema) }),
235
+ },
236
+ },
237
+ });
238
+
206
239
  const getDashboardCosts = route({
207
240
  method: "get",
208
241
  path: "/api/session-costs/dashboard",
@@ -403,6 +436,17 @@ export async function handleSessionData(
403
436
  return true;
404
437
  }
405
438
 
439
+ if (getAttributionByPersonRoute.match(req.method, pathSegments)) {
440
+ const parsed = await getAttributionByPersonRoute.parse(req, res, pathSegments, queryParams);
441
+ if (!parsed) return true;
442
+ const rows = getAttributionByPerson({
443
+ startDate: parsed.query.startDate || undefined,
444
+ endDate: parsed.query.endDate || undefined,
445
+ });
446
+ getAttributionByPersonRoute.respond(res, 200, { rows });
447
+ return true;
448
+ }
449
+
406
450
  if (listSessionCosts.match(req.method, pathSegments)) {
407
451
  const parsed = await listSessionCosts.parse(req, res, pathSegments, queryParams);
408
452
  if (!parsed) return true;
package/src/http/tasks.ts CHANGED
@@ -220,7 +220,7 @@ const createTask = route({
220
220
  agentId: z.string().optional(),
221
221
  taskType: z.string().optional(),
222
222
  tags: z.array(z.string()).optional(),
223
- priority: z.number().int().optional(),
223
+ priority: z.number().int().min(0).max(100).optional(),
224
224
  dependsOn: z.array(z.string()).optional(),
225
225
  offeredTo: z.string().optional(),
226
226
  dir: z.string().optional(),
@@ -760,7 +760,7 @@ export async function handleTasks(
760
760
  creatorAgentId: myAgentId || undefined,
761
761
  taskType: parsed.body.taskType || undefined,
762
762
  tags: parsed.body.tags || undefined,
763
- priority: parsed.body.priority || 50,
763
+ priority: parsed.body.priority,
764
764
  dependsOn: parsed.body.dependsOn || undefined,
765
765
  offeredTo: parsed.body.offeredTo || undefined,
766
766
  dir: parsed.body.dir || undefined,
package/src/http/users.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  deleteKv,
33
33
  deleteUser,
34
34
  getAllUsers,
35
+ getDb,
35
36
  getUserById,
36
37
  listKv,
37
38
  updateUser,
@@ -48,7 +49,7 @@ import {
48
49
  revokeToken,
49
50
  unlinkIdentity,
50
51
  } from "../be/users";
51
- import { UserSchema } from "../types";
52
+ import { UserCommsPrefsSchema, UserSchema } from "../types";
52
53
  import { getRequestAuth } from "../utils/request-auth-context";
53
54
  import { getOperatorActor } from "./operator-actor";
54
55
  import { route } from "./route-def";
@@ -296,6 +297,11 @@ const updateUserRoute = route({
296
297
  preferredChannel: z.string().optional(),
297
298
  timezone: z.string().optional(),
298
299
  metadata: z.record(z.string(), z.unknown()).nullable().optional(),
300
+ comms: UserCommsPrefsSchema.nullable()
301
+ .optional()
302
+ .describe(
303
+ "Merges into metadata.comms without touching sibling metadata keys; null removes only the comms key. When metadata is also provided, it is applied first and replaces the whole blob.",
304
+ ),
299
305
  dailyBudgetUsd: z.number().nullable().optional(),
300
306
  status: z.enum(["invited", "active", "suspended"]).optional(),
301
307
  // Complete-list diff: passing this replaces the user's identity set,
@@ -776,60 +782,63 @@ export async function handleUsers(
776
782
  }
777
783
 
778
784
  try {
779
- // Move every identity from source → target.
780
- for (const ident of sourceBefore.identities) {
781
- unlinkIdentity(sourceId, ident.kind, ident.externalId, actor);
782
- linkIdentity(targetId, ident.kind, ident.externalId, actor);
783
- }
785
+ const mergedUser = getDb().transaction(() => {
786
+ // Move every identity from source → target.
787
+ for (const ident of sourceBefore.identities) {
788
+ unlinkIdentity(sourceId, ident.kind, ident.externalId, actor);
789
+ linkIdentity(targetId, ident.kind, ident.externalId, actor);
790
+ }
784
791
 
785
- // Merge email aliases — append source.email + source.emailAliases into
786
- // target.emailAliases (de-duped). Emit `email_added` per added alias.
787
- const targetAliases = new Set(targetBefore.emailAliases ?? []);
788
- const targetPrimary = (targetBefore.email ?? "").toLowerCase();
789
- const newAliases: string[] = [];
790
- const candidates = [
791
- ...(sourceBefore.email ? [sourceBefore.email] : []),
792
- ...(sourceBefore.emailAliases ?? []),
793
- ];
794
- for (const candidate of candidates) {
795
- const lower = candidate.toLowerCase();
796
- if (!lower || lower === targetPrimary) continue;
797
- if (
798
- ![...targetAliases].some((a) => a.toLowerCase() === lower) &&
799
- !newAliases.some((a) => a.toLowerCase() === lower)
800
- ) {
801
- newAliases.push(candidate);
792
+ // Merge email aliases — append source.email + source.emailAliases into
793
+ // target.emailAliases (de-duped). Emit `email_added` per added alias.
794
+ const targetAliases = new Set(targetBefore.emailAliases ?? []);
795
+ const targetPrimary = (targetBefore.email ?? "").toLowerCase();
796
+ const newAliases: string[] = [];
797
+ const candidates = [
798
+ ...(sourceBefore.email ? [sourceBefore.email] : []),
799
+ ...(sourceBefore.emailAliases ?? []),
800
+ ];
801
+ for (const candidate of candidates) {
802
+ const lower = candidate.toLowerCase();
803
+ if (!lower || lower === targetPrimary) continue;
804
+ if (
805
+ ![...targetAliases].some((a) => a.toLowerCase() === lower) &&
806
+ !newAliases.some((a) => a.toLowerCase() === lower)
807
+ ) {
808
+ newAliases.push(candidate);
809
+ }
802
810
  }
803
- }
804
- if (newAliases.length > 0) {
805
- const merged = [...(targetBefore.emailAliases ?? []), ...newAliases];
806
- updateUser(targetId, { emailAliases: merged });
807
- for (const alias of newAliases) {
808
- recordIdentityEvent(targetId, "email_added", actor, null, { email: alias });
811
+ if (newAliases.length > 0) {
812
+ const merged = [...(targetBefore.emailAliases ?? []), ...newAliases];
813
+ updateUser(targetId, { emailAliases: merged });
814
+ for (const alias of newAliases) {
815
+ recordIdentityEvent(targetId, "email_added", actor, null, { email: alias });
816
+ }
809
817
  }
810
- }
811
818
 
812
- // Delete source — CASCADE cleans up any leftover external_ids row that
813
- // we may have missed (and clears tasks.requestedByUserId pointers).
814
- deleteUser(sourceId);
815
-
816
- // Single manual_merge event on target capturing the before/after rows.
817
- // The source row is deleted above, so carry a minimal snapshot of the
818
- // source user ({id, name, email}) inside the `after` payload under
819
- // `source` — this lets the UI render "Merged manually from X → Y".
820
- const targetAfter = composeUser(targetId);
821
- recordIdentityEvent(targetId, "manual_merge", actor, targetBefore, {
822
- ...targetAfter,
823
- source: {
824
- id: sourceBefore.id,
825
- name: sourceBefore.name,
826
- email: sourceBefore.email,
827
- },
828
- });
819
+ // Delete source — CASCADE cleans up any leftover external_ids row that
820
+ // we may have missed. Retained user references move to the target.
821
+ deleteUser(sourceId, targetId);
822
+
823
+ // Single manual_merge event on target capturing the before/after rows.
824
+ // The source row is deleted above, so carry a minimal snapshot of the
825
+ // source user ({id, name, email}) inside the `after` payload under
826
+ // `source` — this lets the UI render "Merged manually from X → Y".
827
+ const targetAfter = composeUser(targetId);
828
+ recordIdentityEvent(targetId, "manual_merge", actor, targetBefore, {
829
+ ...targetAfter,
830
+ source: {
831
+ id: sourceBefore.id,
832
+ name: sourceBefore.name,
833
+ email: sourceBefore.email,
834
+ },
835
+ });
829
836
 
830
- // Re-compose AFTER the event so the response surfaces the merge event in
831
- // recentEvents (otherwise the timeline is missing the event we just wrote).
832
- mergeUsersRoute.respond(res, 200, { user: composeUser(targetId) });
837
+ // Re-compose AFTER the event so the response surfaces the merge event in
838
+ // recentEvents (otherwise the timeline is missing the event we just wrote).
839
+ return composeUser(targetId);
840
+ })();
841
+ mergeUsersRoute.respond(res, 200, { user: mergedUser });
833
842
  } catch (err) {
834
843
  jsonError(res, err instanceof Error ? err.message : "Failed to merge users", 500);
835
844
  }
@@ -863,10 +872,23 @@ export async function handleUsers(
863
872
  }
864
873
 
865
874
  try {
866
- const { identities, metadata, ...rest } = parsed.body;
875
+ const { identities, metadata, comms, ...rest } = parsed.body;
867
876
  // updateUser doesn't accept `metadata: null` directly — passthrough via cast.
868
877
  const update: Parameters<typeof updateUser>[1] = { ...rest };
869
- if (metadata !== undefined) {
878
+ if (comms !== undefined) {
879
+ // Merge-safe write: metadata (if provided) is applied first and
880
+ // replaces the whole blob, then comms is merged into it so sibling
881
+ // metadata keys survive.
882
+ const base: Record<string, unknown> = {
883
+ ...(metadata !== undefined ? (metadata ?? {}) : (before.metadata ?? {})),
884
+ };
885
+ if (comms === null) {
886
+ delete base.comms;
887
+ } else {
888
+ base.comms = comms;
889
+ }
890
+ update.metadata = Object.keys(base).length > 0 ? base : null;
891
+ } else if (metadata !== undefined) {
870
892
  update.metadata = metadata as Record<string, unknown> | null;
871
893
  }
872
894
  const updated = updateUser(parsed.params.id, update);
@@ -50,7 +50,7 @@ import {
50
50
  import type { KapsoConfig } from "../integrations/kapso/config";
51
51
  import { getKapsoConfig } from "../integrations/kapso/config";
52
52
  import type { KapsoWebhookPayload } from "../integrations/kapso/inbound";
53
- import { routeKapsoInbound } from "../integrations/kapso/inbound";
53
+ import { resolveKapsoRequestedByUserId, routeKapsoInbound } from "../integrations/kapso/inbound";
54
54
  import { getExecutorRegistry } from "../workflows";
55
55
  import { workflowEventBus } from "../workflows/event-bus";
56
56
  import { handleWebhookTrigger, verifyHmacSignature, WebhookError } from "../workflows/triggers";
@@ -600,7 +600,10 @@ export async function handleWebhooks(
600
600
  rawBody,
601
601
  req.headers,
602
602
  getExecutorRegistry(),
603
- { alreadyAuthenticated: true },
603
+ {
604
+ alreadyAuthenticated: true,
605
+ requestedByUserId: resolveKapsoRequestedByUserId(payload),
606
+ },
604
607
  );
605
608
  break;
606
609
  case "no_mapping":
@@ -31,7 +31,6 @@ import {
31
31
  WorkflowSchema,
32
32
  WorkflowVersionSchema,
33
33
  } from "../types";
34
- import { getRequestAuth } from "../utils/request-auth-context";
35
34
  import { getExecutorRegistry, startWorkflowExecution } from "../workflows";
36
35
  import {
37
36
  applyDefinitionPatch,
@@ -827,13 +826,11 @@ export async function handleWorkflows(
827
826
  return true;
828
827
  }
829
828
  const body = await parseBody<Record<string, unknown>>(req);
830
- const auth = getRequestAuth(req);
831
-
832
829
  let runId: string;
833
830
  try {
834
831
  runId = await startWorkflowExecution(workflow, body, getExecutorRegistry(), {
835
832
  triggerType: "api",
836
- requestedByUserId: auth?.kind === "user" ? auth.userId : workflow.createdBy,
833
+ requestedByUserId: resolveHttpAuditUserId(req, myAgentId) ?? undefined,
837
834
  });
838
835
  } catch (err) {
839
836
  if (err instanceof TriggerSchemaError) {
package/src/http/x.ts CHANGED
@@ -12,6 +12,8 @@ import {
12
12
  getScriptApiSecret,
13
13
  getScriptById,
14
14
  recordScriptApiUsage,
15
+ restoreScratchScriptLastUsedIfUnchanged,
16
+ touchScratchScriptLastUsed,
15
17
  } from "../be/scripts/db";
16
18
  import {
17
19
  MAX_SCRIPT_WALL_CLOCK_MS,
@@ -228,6 +230,10 @@ export async function handleX(
228
230
  const timeoutMs = resolveTimeoutMs(req);
229
231
  const startedAt = new Date().toISOString();
230
232
 
233
+ // Touch before executing so an already-stale scratch script isn't reaped by
234
+ // the retention sweep while this run is still in flight.
235
+ const runStartTouch = script.isScratch ? touchScratchScriptLastUsed(script.id) : null;
236
+
231
237
  const credentials = await buildScriptCredentialBindingsWithFailures({
232
238
  agentId: endpoint.agentId,
233
239
  });
@@ -250,6 +256,14 @@ export async function handleX(
250
256
 
251
257
  const ok = output.exitCode === 0 && !output.error && !output.runtimeError;
252
258
  const error = ok ? null : buildExecutionError(output);
259
+ if (ok) {
260
+ touchScratchScriptLastUsed(script.id);
261
+ } else if (script.isScratch && runStartTouch) {
262
+ // Failed run — restore the pre-run timestamp so it doesn't buy the
263
+ // script another retention window, unless a concurrent run already
264
+ // touched it since.
265
+ restoreScratchScriptLastUsedIfUnchanged(script.id, script.updatedAt, runStartTouch);
266
+ }
253
267
 
254
268
  // Usage + observability — best-effort, must never fail the response.
255
269
  try {
@@ -80,7 +80,7 @@ function resolveKapsoIdentity(externalId: string): IdentityResolution {
80
80
  return kapsoResolution;
81
81
  }
82
82
 
83
- function resolveKapsoRequestedByUserId(payload: KapsoWebhookPayload): string | undefined {
83
+ export function resolveKapsoRequestedByUserId(payload: KapsoWebhookPayload): string | undefined {
84
84
  const externalId = normalizeKapsoSender(payload);
85
85
  if (!externalId) return undefined;
86
86
 
@@ -1,4 +1,5 @@
1
1
  import { getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
2
+ import { scrubSecrets } from "../utils/secret-scrubber";
2
3
  import { workflowEventBus } from "../workflows/event-bus";
3
4
  import { jiraFetch } from "./client";
4
5
 
@@ -28,14 +29,44 @@ const OUTPUT_TRUNCATE_CHARS = 4000;
28
29
  * as a known v1 limitation; a per-issue debounce / bounded outbound queue
29
30
  * could be added in v2 if it becomes a real problem.
30
31
  */
32
+ /**
33
+ * `EventEmitter.emit()` calls listeners synchronously and discards whatever
34
+ * they return, so a rejected async handler is never seen by the emitting side
35
+ * — it escapes to the process-level `unhandledRejection` handler with no
36
+ * subsystem context. These wrappers observe the *complete* handler body,
37
+ * including work that runs before any internal `try` (e.g. `getTrackerSync`).
38
+ *
39
+ * Each wrapper is created once at module scope so `off()` is passed the exact
40
+ * reference `on()` registered; building them inside `init` would leave the
41
+ * listeners attached after teardown.
42
+ */
43
+ function observed(
44
+ eventName: string,
45
+ handler: (data: unknown) => Promise<void>,
46
+ ): (data: unknown) => void {
47
+ return (data: unknown): void => {
48
+ handler(data).catch((error) => {
49
+ console.error(
50
+ `[Jira Outbound] ${eventName} handler failed:`,
51
+ scrubSecrets(error instanceof Error ? error.message : String(error)),
52
+ );
53
+ });
54
+ };
55
+ }
56
+
57
+ const onTaskCreated = observed("task.created", handleTaskCreated);
58
+ const onTaskCompleted = observed("task.completed", handleTaskCompleted);
59
+ const onTaskFailed = observed("task.failed", handleTaskFailed);
60
+ const onTaskCancelled = observed("task.cancelled", handleTaskCancelled);
61
+
31
62
  export function initJiraOutboundSync(): void {
32
63
  if (subscribed) return;
33
64
  subscribed = true;
34
65
 
35
- workflowEventBus.on("task.created", handleTaskCreated);
36
- workflowEventBus.on("task.completed", handleTaskCompleted);
37
- workflowEventBus.on("task.failed", handleTaskFailed);
38
- workflowEventBus.on("task.cancelled", handleTaskCancelled);
66
+ workflowEventBus.on("task.created", onTaskCreated);
67
+ workflowEventBus.on("task.completed", onTaskCompleted);
68
+ workflowEventBus.on("task.failed", onTaskFailed);
69
+ workflowEventBus.on("task.cancelled", onTaskCancelled);
39
70
  console.log("[Jira] Outbound sync subscribed to event bus");
40
71
  }
41
72
 
@@ -43,10 +74,10 @@ export function teardownJiraOutboundSync(): void {
43
74
  if (!subscribed) return;
44
75
  subscribed = false;
45
76
 
46
- workflowEventBus.off("task.created", handleTaskCreated);
47
- workflowEventBus.off("task.completed", handleTaskCompleted);
48
- workflowEventBus.off("task.failed", handleTaskFailed);
49
- workflowEventBus.off("task.cancelled", handleTaskCancelled);
77
+ workflowEventBus.off("task.created", onTaskCreated);
78
+ workflowEventBus.off("task.completed", onTaskCompleted);
79
+ workflowEventBus.off("task.failed", onTaskFailed);
80
+ workflowEventBus.off("task.cancelled", onTaskCancelled);
50
81
  console.log("[Jira] Outbound sync unsubscribed from event bus");
51
82
  }
52
83
 
@@ -1,4 +1,5 @@
1
1
  import { getPublicMcpBaseUrl } from "../utils/constants";
2
+ import { scrubSecrets } from "../utils/secret-scrubber";
2
3
  import { jiraFetch } from "./client";
3
4
  import { getJiraMetadata, updateJiraMetadata } from "./metadata";
4
5
 
@@ -359,11 +360,21 @@ export function startJiraWebhookKeepalive(): void {
359
360
  // Immediate-on-boot check (after a short delay so server finishes startup
360
361
  // and OAuth tokens are loaded). Mirrors src/oauth/keepalive.ts pattern.
361
362
  setTimeout(() => {
362
- runKeepalive();
363
+ runKeepalive().catch((err) =>
364
+ console.error(
365
+ "[Jira webhook keepalive] run failed:",
366
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
367
+ ),
368
+ );
363
369
  }, 10_000);
364
370
 
365
371
  keepaliveInterval = setInterval(() => {
366
- runKeepalive();
372
+ runKeepalive().catch((err) =>
373
+ console.error(
374
+ "[Jira webhook keepalive] run failed:",
375
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
376
+ ),
377
+ );
367
378
  }, TWELVE_HOURS_MS);
368
379
  }
369
380
 
@@ -1,5 +1,6 @@
1
1
  import { getTrackerSync, updateTrackerSync } from "../be/db-queries/tracker";
2
2
  import { ensureToken } from "../oauth/ensure-token";
3
+ import { scrubSecrets } from "../utils/secret-scrubber";
3
4
  import { workflowEventBus } from "../workflows/event-bus";
4
5
  import { getLinearClient, resetLinearClient } from "./client";
5
6
  import { endAgentSession, postAgentSessionAction, taskSessionMap } from "./sync";
@@ -8,15 +9,47 @@ let subscribed = false;
8
9
 
9
10
  const LOOP_PREVENTION_WINDOW_MS = 5_000;
10
11
 
12
+ /**
13
+ * `EventEmitter.emit()` calls listeners synchronously and discards whatever
14
+ * they return, so a rejected async handler is never seen by the emitting side
15
+ * — it escapes to the process-level `unhandledRejection` handler with no
16
+ * subsystem context. These wrappers observe the *complete* handler body,
17
+ * including work that runs before any internal `try` (e.g. `getTrackerSync`)
18
+ * and the `updateTrackerSync` write that runs after it.
19
+ *
20
+ * Each wrapper is created once at module scope so `off()` is passed the exact
21
+ * reference `on()` registered; building them inside `init` would leave the
22
+ * listeners attached after teardown.
23
+ */
24
+ function observed(
25
+ eventName: string,
26
+ handler: (data: unknown) => Promise<void>,
27
+ ): (data: unknown) => void {
28
+ return (data: unknown): void => {
29
+ handler(data).catch((error) => {
30
+ console.error(
31
+ `[Linear Outbound] ${eventName} handler failed:`,
32
+ scrubSecrets(error instanceof Error ? error.message : String(error)),
33
+ );
34
+ });
35
+ };
36
+ }
37
+
38
+ const onTaskCreated = observed("task.created", handleTaskCreated);
39
+ const onTaskCompleted = observed("task.completed", handleTaskCompleted);
40
+ const onTaskFailed = observed("task.failed", handleTaskFailed);
41
+ const onTaskCancelled = observed("task.cancelled", handleTaskCancelled);
42
+ const onTaskProgress = observed("task.progress", handleTaskProgress);
43
+
11
44
  export function initLinearOutboundSync(): void {
12
45
  if (subscribed) return;
13
46
  subscribed = true;
14
47
 
15
- workflowEventBus.on("task.created", handleTaskCreated);
16
- workflowEventBus.on("task.completed", handleTaskCompleted);
17
- workflowEventBus.on("task.failed", handleTaskFailed);
18
- workflowEventBus.on("task.cancelled", handleTaskCancelled);
19
- workflowEventBus.on("task.progress", handleTaskProgress);
48
+ workflowEventBus.on("task.created", onTaskCreated);
49
+ workflowEventBus.on("task.completed", onTaskCompleted);
50
+ workflowEventBus.on("task.failed", onTaskFailed);
51
+ workflowEventBus.on("task.cancelled", onTaskCancelled);
52
+ workflowEventBus.on("task.progress", onTaskProgress);
20
53
  console.log("[Linear] Outbound sync subscribed to event bus");
21
54
  }
22
55
 
@@ -24,11 +57,11 @@ export function teardownLinearOutboundSync(): void {
24
57
  if (!subscribed) return;
25
58
  subscribed = false;
26
59
 
27
- workflowEventBus.off("task.created", handleTaskCreated);
28
- workflowEventBus.off("task.completed", handleTaskCompleted);
29
- workflowEventBus.off("task.failed", handleTaskFailed);
30
- workflowEventBus.off("task.cancelled", handleTaskCancelled);
31
- workflowEventBus.off("task.progress", handleTaskProgress);
60
+ workflowEventBus.off("task.created", onTaskCreated);
61
+ workflowEventBus.off("task.completed", onTaskCompleted);
62
+ workflowEventBus.off("task.failed", onTaskFailed);
63
+ workflowEventBus.off("task.cancelled", onTaskCancelled);
64
+ workflowEventBus.off("task.progress", onTaskProgress);
32
65
  console.log("[Linear] Outbound sync unsubscribed from event bus");
33
66
  }
34
67