@wardby/cli 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (370) hide show
  1. package/.env.example +33 -0
  2. package/README.md +25 -14
  3. package/dist/claude-coding-worker/context.d.ts +22 -0
  4. package/dist/claude-coding-worker/context.js +92 -0
  5. package/dist/claude-coding-worker/driver.d.ts +7 -1
  6. package/dist/claude-coding-worker/driver.js +38 -5
  7. package/dist/claude-coding-worker/sdk.js +13 -4
  8. package/dist/cli-help.d.ts +1 -1
  9. package/dist/cli-help.js +5 -2
  10. package/dist/cli-native-mode.d.ts +9 -0
  11. package/dist/cli-native-mode.js +20 -0
  12. package/dist/cli.d.ts +3 -1
  13. package/dist/cli.js +141 -5
  14. package/dist/coding/claude-context.d.ts +45 -0
  15. package/dist/coding/claude-context.js +434 -0
  16. package/dist/coding/profile.d.ts +12 -0
  17. package/dist/coding/profile.js +6 -0
  18. package/dist/coding/protocol.d.ts +104 -0
  19. package/dist/coding/protocol.js +87 -3
  20. package/dist/coding-worker/artifact.d.ts +8 -0
  21. package/dist/coding-worker/artifact.js +2 -2
  22. package/dist/coding-worker/driver.js +2 -0
  23. package/dist/coding-worker/sdk.d.ts +6 -0
  24. package/dist/coding-worker/sdk.js +1 -0
  25. package/dist/coding-worker/skills.d.ts +23 -0
  26. package/dist/coding-worker/skills.js +117 -0
  27. package/dist/coding-worker/types.d.ts +2 -0
  28. package/dist/config/github-allowed-accounts.d.ts +15 -0
  29. package/dist/config/github-allowed-accounts.js +36 -0
  30. package/dist/config/low-power.d.ts +12 -0
  31. package/dist/config/low-power.js +18 -0
  32. package/dist/config/mobile.d.ts +12 -0
  33. package/dist/config/mobile.js +18 -0
  34. package/dist/config/providers.d.ts +53 -0
  35. package/dist/config/providers.js +92 -6
  36. package/dist/core/db.d.ts +16 -6
  37. package/dist/core/db.js +12 -10
  38. package/dist/core/dispatch.d.ts +11 -0
  39. package/dist/core/dispatch.js +36 -1
  40. package/dist/core/engine-native.js +9 -5
  41. package/dist/core/host-events.d.ts +49 -3
  42. package/dist/core/host-events.js +289 -15
  43. package/dist/core/issue-bridge.js +42 -2
  44. package/dist/core/issue-events.js +12 -1
  45. package/dist/core/issue-status.js +14 -3
  46. package/dist/core/merge-order-check.d.ts +129 -0
  47. package/dist/core/merge-order-check.js +336 -0
  48. package/dist/core/notification-dispatcher.d.ts +70 -0
  49. package/dist/core/notification-dispatcher.js +368 -0
  50. package/dist/core/notification-templates.d.ts +25 -0
  51. package/dist/core/notification-templates.js +105 -0
  52. package/dist/core/notifications.d.ts +10 -0
  53. package/dist/core/notifications.js +38 -0
  54. package/dist/core/pg-auth.d.ts +22 -0
  55. package/dist/core/pg-auth.js +152 -0
  56. package/dist/core/power-composition.d.ts +16 -0
  57. package/dist/core/power-composition.js +37 -0
  58. package/dist/core/power-idle.d.ts +14 -0
  59. package/dist/core/power-idle.js +30 -0
  60. package/dist/core/power.d.ts +71 -0
  61. package/dist/core/power.js +202 -0
  62. package/dist/core/reconciler.d.ts +4 -2
  63. package/dist/core/reconciler.js +29 -5
  64. package/dist/core/related-pull-requests.d.ts +48 -4
  65. package/dist/core/related-pull-requests.js +113 -11
  66. package/dist/core/repo-access.d.ts +15 -0
  67. package/dist/core/repo-access.js +66 -0
  68. package/dist/core/review-fix.d.ts +37 -0
  69. package/dist/core/review-fix.js +207 -6
  70. package/dist/core/review-host-tools.d.ts +20 -0
  71. package/dist/core/review-host-tools.js +58 -2
  72. package/dist/core/runner.d.ts +336 -3
  73. package/dist/core/runner.js +894 -317
  74. package/dist/core/secret-broker-config.d.ts +103 -0
  75. package/dist/core/secret-broker-config.js +112 -0
  76. package/dist/core/secrets.d.ts +39 -4
  77. package/dist/core/secrets.js +148 -40
  78. package/dist/core/workflow-event-recorder.d.ts +14 -0
  79. package/dist/core/workflow-event-recorder.js +120 -0
  80. package/dist/core/workflow-events.d.ts +82 -0
  81. package/dist/core/workflow-events.js +44 -0
  82. package/dist/core/workflow-run-events.d.ts +16 -0
  83. package/dist/core/workflow-run-events.js +40 -0
  84. package/dist/generated/prisma/browser.d.ts +58 -0
  85. package/dist/generated/prisma/client.d.ts +58 -0
  86. package/dist/generated/prisma/commonInputTypes.d.ts +150 -0
  87. package/dist/generated/prisma/enums.d.ts +27 -0
  88. package/dist/generated/prisma/enums.js +23 -0
  89. package/dist/generated/prisma/internal/class.d.ts +88 -0
  90. package/dist/generated/prisma/internal/class.js +4 -4
  91. package/dist/generated/prisma/internal/prismaNamespace.d.ts +772 -1
  92. package/dist/generated/prisma/internal/prismaNamespace.js +135 -4
  93. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +139 -0
  94. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +135 -4
  95. package/dist/generated/prisma/models/Agent.d.ts +360 -1
  96. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +73 -1
  97. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  98. package/dist/generated/prisma/models/DeferredReview.d.ts +78 -1
  99. package/dist/generated/prisma/models/NativeGatewayCall.d.ts +1675 -0
  100. package/dist/generated/prisma/models/NativeGatewayCall.js +1 -0
  101. package/dist/generated/prisma/models/NativeGatewaySession.d.ts +1517 -0
  102. package/dist/generated/prisma/models/NativeGatewaySession.js +1 -0
  103. package/dist/generated/prisma/models/NativeWarmWorker.d.ts +1296 -0
  104. package/dist/generated/prisma/models/NativeWarmWorker.js +1 -0
  105. package/dist/generated/prisma/models/NotificationChannel.d.ts +1543 -0
  106. package/dist/generated/prisma/models/NotificationChannel.js +1 -0
  107. package/dist/generated/prisma/models/NotificationDelivery.d.ts +1502 -0
  108. package/dist/generated/prisma/models/NotificationDelivery.js +1 -0
  109. package/dist/generated/prisma/models/NotificationThread.d.ts +1090 -0
  110. package/dist/generated/prisma/models/NotificationThread.js +1 -0
  111. package/dist/generated/prisma/models/Run.d.ts +826 -1
  112. package/dist/generated/prisma/models/RunHostCheck.d.ts +36 -1
  113. package/dist/generated/prisma/models/Secret.d.ts +37 -1
  114. package/dist/generated/prisma/models/SecretBrokerChange.d.ts +1101 -0
  115. package/dist/generated/prisma/models/SecretBrokerChange.js +1 -0
  116. package/dist/generated/prisma/models/WorkflowEvent.d.ts +1422 -0
  117. package/dist/generated/prisma/models/WorkflowEvent.js +1 -0
  118. package/dist/generated/prisma/models.d.ts +8 -0
  119. package/dist/help/build.js +4 -2
  120. package/dist/help/catalog.d.ts +1 -1
  121. package/dist/help/catalog.js +31 -6
  122. package/dist/help-index.json +1363 -176
  123. package/dist/import/create.js +17 -0
  124. package/dist/import/neutral-schema.d.ts +6 -2
  125. package/dist/import/neutral-schema.js +2 -0
  126. package/dist/mcp/auth/access.d.ts +1 -0
  127. package/dist/mcp/auth/repo-authorization.js +14 -0
  128. package/dist/mcp/auth/resource-server.d.ts +2 -1
  129. package/dist/mcp/auth/resource-server.js +7 -2
  130. package/dist/mcp/auth/self-hosted/browser.d.ts +1 -1
  131. package/dist/mcp/auth/self-hosted/browser.js +19 -6
  132. package/dist/mcp/context.d.ts +6 -0
  133. package/dist/mcp/host-events/github-ingress.d.ts +23 -1
  134. package/dist/mcp/host-events/github-ingress.js +68 -0
  135. package/dist/mcp/index.d.ts +13 -0
  136. package/dist/mcp/index.js +59 -10
  137. package/dist/mcp/tools/agents.js +82 -11
  138. package/dist/mcp/tools/notification-channels.d.ts +2 -0
  139. package/dist/mcp/tools/notification-channels.js +293 -0
  140. package/dist/mcp/tools/power.d.ts +2 -0
  141. package/dist/mcp/tools/power.js +77 -0
  142. package/dist/mcp/tools/runs.d.ts +0 -6
  143. package/dist/mcp/tools/runs.js +7 -2
  144. package/dist/mcp/tools/secret-broker-compat.d.ts +12 -0
  145. package/dist/mcp/tools/secret-broker-compat.js +23 -0
  146. package/dist/mcp/tools/secret-elicitation-form.d.ts +22 -2
  147. package/dist/mcp/tools/secret-elicitation-form.js +200 -10
  148. package/dist/mcp/tools/secret-elicitation-server.js +8 -1
  149. package/dist/mcp/tools/secret-elicitation.d.ts +38 -3
  150. package/dist/mcp/tools/secret-elicitation.js +110 -12
  151. package/dist/mcp/tools/secrets.js +184 -7
  152. package/dist/mcp/tools/tools.d.ts +1 -1
  153. package/dist/mcp/tools/tools.js +22 -2
  154. package/dist/native-worker/composition.d.ts +23 -0
  155. package/dist/native-worker/composition.js +77 -0
  156. package/dist/native-worker/docker-isolation.d.ts +65 -0
  157. package/dist/native-worker/docker-isolation.js +183 -0
  158. package/dist/native-worker/docker-launcher.d.ts +74 -0
  159. package/dist/native-worker/docker-launcher.js +237 -0
  160. package/dist/native-worker/gateway-server.d.ts +39 -0
  161. package/dist/native-worker/gateway-server.js +315 -0
  162. package/dist/native-worker/gateway.d.ts +44 -0
  163. package/dist/native-worker/gateway.js +197 -0
  164. package/dist/native-worker/http-server.d.ts +22 -0
  165. package/dist/native-worker/http-server.js +109 -0
  166. package/dist/native-worker/http-transport.d.ts +17 -0
  167. package/dist/native-worker/http-transport.js +88 -0
  168. package/dist/native-worker/kubernetes-isolation.d.ts +90 -0
  169. package/dist/native-worker/kubernetes-isolation.js +218 -0
  170. package/dist/native-worker/kubernetes-launcher.d.ts +102 -0
  171. package/dist/native-worker/kubernetes-launcher.js +335 -0
  172. package/dist/native-worker/launch.d.ts +45 -0
  173. package/dist/native-worker/launch.js +44 -0
  174. package/dist/native-worker/ledger.d.ts +109 -0
  175. package/dist/native-worker/ledger.js +192 -0
  176. package/dist/native-worker/loopback.d.ts +12 -0
  177. package/dist/native-worker/loopback.js +49 -0
  178. package/dist/native-worker/main.d.ts +7 -0
  179. package/dist/native-worker/main.js +74 -0
  180. package/dist/native-worker/protocol.d.ts +744 -0
  181. package/dist/native-worker/protocol.js +200 -0
  182. package/dist/native-worker/sandbox-executor.d.ts +68 -0
  183. package/dist/native-worker/sandbox-executor.js +210 -0
  184. package/dist/native-worker/stdio.d.ts +29 -0
  185. package/dist/native-worker/stdio.js +188 -0
  186. package/dist/native-worker/warm-delivery.d.ts +16 -0
  187. package/dist/native-worker/warm-delivery.js +33 -0
  188. package/dist/native-worker/warm-pool-ledger.d.ts +39 -0
  189. package/dist/native-worker/warm-pool-ledger.js +73 -0
  190. package/dist/native-worker/warm-pool.d.ts +107 -0
  191. package/dist/native-worker/warm-pool.js +283 -0
  192. package/dist/native-worker/worker.d.ts +17 -0
  193. package/dist/native-worker/worker.js +115 -0
  194. package/dist/providers/auth/authorization-server.d.ts +3 -0
  195. package/dist/providers/auth/index.js +2 -0
  196. package/dist/providers/auth/self-hosted.d.ts +13 -1
  197. package/dist/providers/auth/self-hosted.js +54 -4
  198. package/dist/providers/chat/fake.d.ts +46 -0
  199. package/dist/providers/chat/fake.js +39 -0
  200. package/dist/providers/chat/index.d.ts +4 -0
  201. package/dist/providers/chat/index.js +8 -0
  202. package/dist/providers/chat/slack.d.ts +37 -0
  203. package/dist/providers/chat/slack.js +105 -0
  204. package/dist/providers/chat/types.d.ts +42 -0
  205. package/dist/providers/chat/types.js +19 -0
  206. package/dist/providers/engine/types.d.ts +19 -0
  207. package/dist/providers/engine/types.js +22 -0
  208. package/dist/providers/executor/composition.d.ts +24 -1
  209. package/dist/providers/executor/composition.js +39 -6
  210. package/dist/providers/executor/container.d.ts +24 -1
  211. package/dist/providers/executor/container.js +101 -11
  212. package/dist/providers/executor/dbos.d.ts +42 -0
  213. package/dist/providers/executor/dbos.js +118 -12
  214. package/dist/providers/executor/deferred.d.ts +56 -0
  215. package/dist/providers/executor/deferred.js +119 -0
  216. package/dist/providers/executor/routing.d.ts +37 -10
  217. package/dist/providers/executor/routing.js +73 -32
  218. package/dist/providers/executor/types.d.ts +12 -0
  219. package/dist/providers/jobs/docker.js +3 -3
  220. package/dist/providers/jobs/fake-kubernetes-api.d.ts +21 -2
  221. package/dist/providers/jobs/fake-kubernetes-api.js +42 -1
  222. package/dist/providers/jobs/kubernetes-api.d.ts +17 -1
  223. package/dist/providers/jobs/kubernetes-api.js +7 -0
  224. package/dist/providers/jobs/kubernetes-client.d.ts +10 -1
  225. package/dist/providers/jobs/kubernetes-client.js +52 -3
  226. package/dist/providers/jobs/kubernetes-dry-run-fixture.d.ts +6 -1
  227. package/dist/providers/jobs/kubernetes-dry-run-fixture.js +6 -3
  228. package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
  229. package/dist/providers/jobs/kubernetes-isolation.js +6 -0
  230. package/dist/providers/jobs/kubernetes-platform.d.ts +4 -1
  231. package/dist/providers/jobs/kubernetes-platform.js +38 -1
  232. package/dist/providers/jobs/kubernetes-preflight.d.ts +2 -1
  233. package/dist/providers/jobs/kubernetes-preflight.js +4 -2
  234. package/dist/providers/jobs/kubernetes.d.ts +36 -1
  235. package/dist/providers/jobs/kubernetes.js +74 -4
  236. package/dist/providers/jobs/metadata-probes.d.ts +11 -0
  237. package/dist/providers/jobs/metadata-probes.js +13 -0
  238. package/dist/providers/jobs/types.d.ts +9 -0
  239. package/dist/providers/review-host/github.d.ts +11 -1
  240. package/dist/providers/review-host/github.js +50 -0
  241. package/dist/providers/review-host/types.d.ts +23 -0
  242. package/dist/providers/vcs/github.d.ts +32 -6
  243. package/dist/providers/vcs/github.js +74 -16
  244. package/dist/quickstart/coding-seed.d.ts +10 -3
  245. package/dist/quickstart/coding-seed.js +74 -3
  246. package/dist/quickstart/images.d.ts +2 -0
  247. package/dist/quickstart/images.js +7 -1
  248. package/dist/quickstart/reviewer-prompt.d.ts +1 -1
  249. package/dist/quickstart/reviewer-prompt.js +1 -1
  250. package/dist/quickstart-images.json +1 -1
  251. package/dist/sandbox/brokered-fetch.d.ts +23 -0
  252. package/dist/sandbox/brokered-fetch.js +37 -0
  253. package/dist/sandbox/host-functions.d.ts +34 -0
  254. package/dist/sandbox/host-functions.js +144 -80
  255. package/dist/sandbox/prelude.js +7 -1
  256. package/dist/sandbox/run-in-sandbox.d.ts +19 -0
  257. package/dist/sandbox/run-in-sandbox.js +28 -10
  258. package/dist/sandbox/safe-fetch.d.ts +2 -0
  259. package/dist/sandbox/safe-fetch.js +3 -1
  260. package/dist/sandbox/secret-broker-sigv4.d.ts +2 -0
  261. package/dist/sandbox/secret-broker-sigv4.js +49 -0
  262. package/dist/sandbox/secret-broker.d.ts +34 -0
  263. package/dist/sandbox/secret-broker.js +162 -0
  264. package/dist/sandbox/user-tool.d.ts +14 -0
  265. package/dist/sandbox/user-tool.js +32 -0
  266. package/dist/serve.js +18 -0
  267. package/dist/viewer/api-schema.d.ts +210 -14
  268. package/dist/viewer/api-schema.js +22 -0
  269. package/dist/viewer/build-schemas.js +4 -1
  270. package/dist/viewer/event-bus.d.ts +6 -0
  271. package/dist/viewer/event-bus.js +16 -7
  272. package/dist/viewer/graph.d.ts +5 -2
  273. package/dist/viewer/graph.js +7 -2
  274. package/dist/viewer/http.d.ts +0 -1
  275. package/dist/viewer/http.js +2 -2
  276. package/dist/viewer/infra.js +25 -2
  277. package/dist/viewer/limits.d.ts +4 -0
  278. package/dist/viewer/limits.js +4 -0
  279. package/dist/viewer/openapi.d.ts +15 -0
  280. package/dist/viewer/openapi.js +172 -0
  281. package/docs/README.md +7 -0
  282. package/docs/agent-recipes.md +57 -8
  283. package/docs/architecture-runtime.md +16 -1
  284. package/docs/code-review-agents.md +139 -5
  285. package/docs/coding-agent-setup.md +113 -5
  286. package/docs/coding-packages.md +4 -2
  287. package/docs/coding-services.md +3 -1
  288. package/docs/coding-worker-byo-images.md +198 -6
  289. package/docs/coding-worker-isolation.md +68 -14
  290. package/docs/getting-started-gke.md +225 -9
  291. package/docs/getting-started-identity-provider.md +21 -10
  292. package/docs/getting-started.md +5 -4
  293. package/docs/jira-agents.md +5 -1
  294. package/docs/native-sandbox.md +635 -0
  295. package/docs/release-verification.md +9 -0
  296. package/docs/security-deployment.md +113 -12
  297. package/docs/slack-notifications.md +252 -0
  298. package/docs/tool-secrets.md +252 -0
  299. package/docs/viewer-api.md +46 -5
  300. package/docs/viewer-api.openapi.yml +1404 -0
  301. package/help/admin-viewer.md +6 -2
  302. package/help/agent-recipes.md +27 -0
  303. package/help/architecture-agent.md +1 -1
  304. package/help/brokered-secrets.md +79 -0
  305. package/help/build-claude-code-image.md +131 -0
  306. package/help/build-worker-image.md +3 -6
  307. package/help/code-review-agents.md +68 -6
  308. package/help/deploy-gke.md +8 -1
  309. package/help/deployment-targets.md +25 -14
  310. package/help/errors/coding-provider-not-configured.md +2 -2
  311. package/help/errors/database-auth-invalid.md +28 -0
  312. package/help/errors/github-account-not-allowed.md +25 -0
  313. package/help/errors/low-power-disabled.md +29 -0
  314. package/help/errors/low-power-scale-forbidden.md +35 -0
  315. package/help/errors/low-power-wake-timeout.md +30 -0
  316. package/help/errors/mobile-disabled.md +31 -0
  317. package/help/errors/native-sandbox-capacity.md +22 -0
  318. package/help/errors/native-sandbox-deadline-exceeded.md +19 -0
  319. package/help/errors/native-sandbox-docker-failed.md +20 -0
  320. package/help/errors/native-sandbox-gateway-unavailable.md +21 -0
  321. package/help/errors/native-sandbox-image-not-pinned.md +19 -0
  322. package/help/errors/native-sandbox-isolation-mismatch.md +20 -0
  323. package/help/errors/native-sandbox-network-unenforced.md +22 -0
  324. package/help/errors/native-sandbox-requires-catalog.md +31 -0
  325. package/help/errors/native-sandbox-unavailable.md +19 -0
  326. package/help/errors/native-sandbox-warm-delivery-failed.md +21 -0
  327. package/help/errors/native-sandbox-worker-exited.md +20 -0
  328. package/help/errors/native-sandbox-worker-lost.md +20 -0
  329. package/help/errors/native-sandbox-worker-unready.md +22 -0
  330. package/help/errors/secret-broker-body-invalid.md +26 -0
  331. package/help/errors/secret-broker-changed.md +20 -0
  332. package/help/errors/secret-broker-config-invalid.md +30 -0
  333. package/help/errors/secret-broker-conflict.md +27 -0
  334. package/help/errors/secret-broker-destination-denied.md +28 -0
  335. package/help/errors/secret-broker-value-invalid.md +28 -0
  336. package/help/errors/secret-brokered.md +24 -0
  337. package/help/errors/secret-not-brokered.md +25 -0
  338. package/help/errors/slack-auth-failed.md +48 -0
  339. package/help/errors/slack-channel-unreachable.md +39 -0
  340. package/help/errors/slack-not-configured.md +33 -0
  341. package/help/getting-started.md +4 -5
  342. package/help/github.md +18 -1
  343. package/help/identity-and-access.md +5 -1
  344. package/help/local-repositories.md +2 -3
  345. package/help/low-power-mode.md +125 -0
  346. package/help/merge-order-check.md +117 -0
  347. package/help/mobile-app.md +54 -0
  348. package/help/native-sandbox.md +136 -0
  349. package/help/observability.md +8 -0
  350. package/help/related-pull-requests.md +70 -9
  351. package/help/repo-instructions-and-skills.md +91 -0
  352. package/help/review-fix-rounds.md +10 -0
  353. package/help/slack-notifications.md +62 -0
  354. package/help/troubleshooting/coding-workers.md +21 -0
  355. package/package.json +11 -4
  356. package/prisma/migrations/20261009000003_native_execution_mode/migration.sql +12 -0
  357. package/prisma/migrations/20261009000004_native_gateway_ledger/migration.sql +65 -0
  358. package/prisma/migrations/20261009000005_native_gateway_call_child_agent/migration.sql +5 -0
  359. package/prisma/migrations/20261009000006_run_deferred_start_stop/migration.sql +14 -0
  360. package/prisma/migrations/20261009000007_native_gateway_network_ready/migration.sql +5 -0
  361. package/prisma/migrations/20261010000001_workflow_notifications/migration.sql +99 -0
  362. package/prisma/migrations/20261011000001_native_gateway_session_status_index/migration.sql +2 -0
  363. package/prisma/migrations/20261012000001_native_warm_worker/migration.sql +32 -0
  364. package/prisma/migrations/20261013000001_coding_run_merge_order/migration.sql +2 -0
  365. package/prisma/migrations/20261013000001_secret_broker/migration.sql +17 -0
  366. package/prisma/migrations/20261013000002_deferred_review_request/migration.sql +4 -0
  367. package/prisma/migrations/20261014000000_repo_skills/migration.sql +15 -0
  368. package/prisma/migrations/20261015000001_coding_run_merge_order_index/migration.sql +2 -0
  369. package/prisma/migrations/20261015000002_no_change_rereview/migration.sql +2 -0
  370. package/prisma/schema.prisma +362 -91
@@ -1,9 +1,18 @@
1
+ /**
2
+ * Host-neutral routing of code-review host events (GitHub App webhooks today)
3
+ * to the native agents linked to that repository. Adapters normalise their
4
+ * payloads into HostEvent (providers/review-host/github-events.ts); this
5
+ * module decides which agents run. See
6
+ * docs/private/2026-09-25-code-review-host-design.md §6.3.
7
+ */
8
+ import { Prisma } from "#prisma";
1
9
  import { linkedPullRequestAttribution, openingRunAttribution, RESPONSE_PATH_SNAPSHOT_BUDGET } from "./attribution.js";
2
10
  import { checkContinuation, dispatchRun } from "./dispatch.js";
3
11
  import { mentionStatusRow, postMentionStatus } from "./host-status.js";
4
12
  import { handlePullRequestClosed } from "./issue-bridge.js";
5
13
  import { logger } from "./logger.js";
6
- import { MAX_SIBLING_HINTS, openSiblings, SIBLING_GUIDANCE } from "./related-pull-requests.js";
14
+ import { syncMergeOrderChecksForPullRequest } from "./merge-order-check.js";
15
+ import { MAX_SIBLING_HINTS, openSiblings, runTreeRoot, SIBLING_GUIDANCE, } from "./related-pull-requests.js";
7
16
  import { requiredLevel } from "./repo-access.js";
8
17
  import { composeTaskOverride } from "./untrusted-content.js";
9
18
  import { DEFAULT_KNOWLEDGE_BUNDLE_PATH, parseConcept, RESERVED_BUNDLE_FILES, } from "../knowledge/concept.js";
@@ -338,6 +347,11 @@ export function unknownPriorRunBody(priorRunId) {
338
347
  "deployment has no record of that run opening this pull request (another wardby deployment may have opened it). " +
339
348
  "Ask the deployment that opened it, or make the change on this branch by hand.");
340
349
  }
350
+ /** A review run's task: the head to review, plus any extra context its trigger adds. */
351
+ export function reviewTaskText(repository, prNumber, headSha, context) {
352
+ const task = `Review pull request #${prNumber} in ${repository} (head ${headSha}).`;
353
+ return context ? composeTaskOverride(`${task}\n\n${context.task}`, context.untrustedContext) : task;
354
+ }
341
355
  /** Run states that already cover a commit: a review of it is under way or done. */
342
356
  const COVERING_REVIEW_STATUSES = ["pending", "running", "succeeded"];
343
357
  /**
@@ -360,9 +374,16 @@ async function alreadyReviewed(deps, repository, prNumber, headSha, agentId) {
360
374
  });
361
375
  return prior !== null;
362
376
  }
363
- async function startReviews(deps, host, repository, prNumber, headSha, targets,
377
+ /** Starts one review run per target on a pull request head; returns the started run ids. */
378
+ export async function startReviews(deps, host, repository, prNumber, headSha, targets,
364
379
  /** True for automatic triggers; an explicit re-run or `@wardby review` always runs. */
365
- skipReviewedCommits = false) {
380
+ skipReviewedCommits = false,
381
+ /**
382
+ * Extra context for this review: `task` (written by wardby, trusted) is appended to the
383
+ * review task; `untrustedContext` (anything a model or a person wrote) travels separately,
384
+ * delivered as data.
385
+ */
386
+ context) {
366
387
  const runIds = [];
367
388
  // One lookup per PR, not per reviewer, and none at all when every reviewer
368
389
  // skips this commit: taken at the first actual dispatch.
@@ -404,7 +425,7 @@ skipReviewedCommits = false) {
404
425
  selfDefects: { db: deps.db, issueTrackers: deps.issueTrackers },
405
426
  agentId: target.agentId,
406
427
  trigger: "host_event",
407
- taskOverride: `Review pull request #${prNumber} in ${repository} (head ${headSha}).`,
428
+ taskOverride: reviewTaskText(repository, prNumber, headSha, context),
408
429
  attribution: await linkedAttribution(),
409
430
  afterPersist: checkId
410
431
  ? async (tx, run) => {
@@ -590,6 +611,7 @@ async function deferUntilCi(deps, host, key, links) {
590
611
  prNumber: key.prNumber,
591
612
  headSha: key.headSha,
592
613
  agentId: { in: links.map((l) => l.agentId) },
614
+ reason: "ci",
593
615
  },
594
616
  select: { id: true, agentId: true, checkName: true },
595
617
  });
@@ -606,8 +628,8 @@ async function deferUntilCi(deps, host, key, links) {
606
628
  * Starts deferred reviews for one PR head. The head must still be that sha,
607
629
  * open, and not a fork; otherwise every row is dropped (a newer push records
608
630
  * its own). Each row is claimed by deleting it, so a concurrent ci_completed
609
- * delivery or sweep starts each review only once. On `ci`, only reviewers
610
- * still authorized are claimed (the rest wait for the sweep); on `sweep` (the
631
+ * delivery or sweep starts each review only once. On `ci` and `request`, only
632
+ * reviewers still authorized are claimed (the rest wait for the sweep); on `sweep` (the
611
633
  * last attempt) every row is claimed and unauthorized or unlinked reviewers
612
634
  * are dropped.
613
635
  */
@@ -623,7 +645,7 @@ async function startDeferred(deps, host, key, rows, reviewerLinks, mode, reason)
623
645
  const allowedIds = new Set(allowed.map((l) => l.agentId));
624
646
  const targets = [];
625
647
  for (const row of rows) {
626
- if (mode === "ci" && !allowedIds.has(row.agentId))
648
+ if (mode !== "sweep" && !allowedIds.has(row.agentId))
627
649
  continue;
628
650
  const claimed = await deps.db.deferredReview.deleteMany({ where: { id: row.id } });
629
651
  if (claimed.count !== 1)
@@ -647,14 +669,16 @@ async function startDeferred(deps, host, key, rows, reviewerLinks, mode, reason)
647
669
  }
648
670
  /**
649
671
  * ci_completed: starts the reviews deferred on this head (see deferUntilCi),
650
- * unless CI there is still pending (a later completion decides). Never
672
+ * unless CI there is still pending (a later completion decides). Rows still
673
+ * waiting for their delegating run (reason "request") are not CI's to start:
674
+ * startDeferredForRequest hands them over once that run is terminal. Never
651
675
  * throws; a failure is logged and the rows stay for the reconciler sweep.
652
676
  */
653
677
  async function startDeferredAfterCi(deps, host, event, prNumber, reviewerLinks) {
654
678
  try {
655
679
  const key = { provider: event.provider, repository: event.repository, prNumber, headSha: event.headSha };
656
680
  const rows = await deps.db.deferredReview.findMany({
657
- where: key,
681
+ where: { ...key, reason: "ci" },
658
682
  select: { id: true, agentId: true, checkName: true },
659
683
  });
660
684
  if (rows.length === 0)
@@ -676,9 +700,21 @@ async function startDeferredAfterCi(deps, host, event, prNumber, reviewerLinks)
676
700
  * DEFERRED_REVIEW_MAX_AGE_MS, and starts (regardless of CI) those waiting
677
701
  * longer than DEFERRED_REVIEW_MAX_WAIT_MS, at most DEFERRED_REVIEW_BATCH per
678
702
  * pass, oldest first. Covers CI that never reported, ran very long, or whose
679
- * completion webhook was missed. Never throws; returns the runs started.
703
+ * completion webhook was missed. Rows waiting for a delegating run (reason
704
+ * "request") are released the same way once they are that old, but only when
705
+ * that run is terminal (or gone): a lead the reconciler marked lost never
706
+ * reaches its finalizer. Never throws; returns the runs started.
680
707
  */
681
708
  export async function startDeferredReviews(deps, now = new Date()) {
709
+ const providers = Object.keys(deps.hosts);
710
+ const runIds = await sweepCiRows(deps, providers, now);
711
+ // Outside the CI half's try: a failure there never keeps request rows from being released.
712
+ if (providers.length > 0)
713
+ runIds.push(...(await sweepRequestRows(deps, providers, now)));
714
+ return runIds;
715
+ }
716
+ /** The sweep's half for expiry and CI rows: drops rows past the max age, then starts CI rows past the max wait. */
717
+ async function sweepCiRows(deps, providers, now) {
682
718
  const runIds = [];
683
719
  try {
684
720
  const expired = await deps.db.deferredReview.deleteMany({
@@ -686,12 +722,12 @@ export async function startDeferredReviews(deps, now = new Date()) {
686
722
  });
687
723
  if (expired.count > 0)
688
724
  log.info({ count: expired.count }, "dropped deferred reviews older than 24 h");
689
- const providers = Object.keys(deps.hosts);
690
725
  if (providers.length === 0)
691
726
  return runIds;
692
727
  const due = await deps.db.deferredReview.findMany({
693
728
  where: {
694
729
  provider: { in: providers },
730
+ reason: "ci",
695
731
  createdAt: { lte: new Date(now.getTime() - DEFERRED_REVIEW_MAX_WAIT_MS) },
696
732
  },
697
733
  select: {
@@ -732,6 +768,214 @@ export async function startDeferredReviews(deps, now = new Date()) {
732
768
  }
733
769
  return runIds;
734
770
  }
771
+ /** The sweep's half for request rows: releases those older than the max wait whose delegating run is terminal or gone. */
772
+ async function sweepRequestRows(deps, providers, now) {
773
+ const runIds = [];
774
+ try {
775
+ // Joined in the query, so leads still running never fill the batch ahead of finished ones.
776
+ const leads = await deps.db.$queryRaw `
777
+ SELECT DISTINCT d."leadRunId"
778
+ FROM "DeferredReview" d
779
+ LEFT JOIN "Run" r ON r."id" = d."leadRunId"
780
+ WHERE d."reason" = 'request'
781
+ AND d."leadRunId" IS NOT NULL
782
+ AND d."provider" IN (${Prisma.join(providers)})
783
+ AND d."createdAt" <= ${new Date(now.getTime() - DEFERRED_REVIEW_MAX_WAIT_MS)}
784
+ AND (r."id" IS NULL OR r."status"::text NOT IN ('pending', 'running'))
785
+ LIMIT ${DEFERRED_REVIEW_BATCH}`;
786
+ for (const { leadRunId } of leads)
787
+ runIds.push(...(await releaseRequestRows(deps, leadRunId, "sweep")));
788
+ }
789
+ catch (err) {
790
+ log.warn({ err }, "deferred review sweep failed for reviews waiting on a delegating run");
791
+ }
792
+ return runIds;
793
+ }
794
+ /**
795
+ * The run whose finish a review of this pull request waits for: the root of
796
+ * the run tree when the PR's opening coding run was delegated (it has a
797
+ * parent run) and that root is not terminal yet; otherwise null (a
798
+ * non-delegated coding PR, a finished request, or an unknown marker). A lead's
799
+ * delegate_to_* calls block until each child is terminal, so a terminal root
800
+ * means every sibling coding run of the request has finished and opened its
801
+ * pull request. A continuation's marker resolves to its root coding run, then
802
+ * up that run's parents. `repository`, when given, must be the coding run's.
803
+ */
804
+ export async function requestLeadRunId(db, prMarkerRunId, repository) {
805
+ const root = await runTreeRoot(db, prMarkerRunId, repository);
806
+ if (!root || root.isOpener || !RUN_NOT_FINISHED.has(root.status))
807
+ return null;
808
+ return root.id;
809
+ }
810
+ /** Run statuses that mean the delegating run has not finished. */
811
+ const RUN_NOT_FINISHED = new Set(["pending", "running"]);
812
+ /**
813
+ * pr_updated for a pull request a delegated coding run opened, while the
814
+ * request's lead run is still running: records one DeferredReview row per
815
+ * reviewer (reason "request", with or without waitForCi) and starts nothing;
816
+ * the lead's finalizer (startDeferredForRequest) or the sweep starts them.
817
+ * Returns null, to review as without it, for any other pull request, or when
818
+ * the lead cannot be resolved or the deferral cannot be recorded.
819
+ */
820
+ async function deferUntilRequest(deps, host, key, links,
821
+ /** The pull request's origin, read at most once per event (shared with the merge order check). */
822
+ readOrigin) {
823
+ if (links.length === 0 || !readOrigin)
824
+ return null;
825
+ let leadRunId;
826
+ try {
827
+ const origin = await readOrigin();
828
+ if (!origin.markerRunId)
829
+ return null;
830
+ leadRunId = await requestLeadRunId(deps.db, origin.markerRunId, key.repository);
831
+ }
832
+ catch (err) {
833
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not read the pull request's delegating run; reviewing as usual");
834
+ return null;
835
+ }
836
+ if (!leadRunId)
837
+ return null;
838
+ const head = { provider: key.provider, repository: key.repository, prNumber: key.prNumber, headSha: key.headSha };
839
+ try {
840
+ // Idempotent per head and reviewer, like deferUntilCi.
841
+ await deps.db.deferredReview.createMany({
842
+ data: links.map((l) => ({ ...head, agentId: l.agentId, checkName: l.checkName, reason: "request", leadRunId })),
843
+ skipDuplicates: true,
844
+ });
845
+ // A row this head already had for CI waits for the request too: released only when both are done.
846
+ await deps.db.deferredReview.updateMany({
847
+ where: { ...head, agentId: { in: links.map((l) => l.agentId) }, reason: "ci" },
848
+ data: { reason: "request", leadRunId },
849
+ });
850
+ }
851
+ catch (err) {
852
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not record the deferred review; reviewing as usual");
853
+ return null;
854
+ }
855
+ log.info({ ...head, leadRunId, agentIds: links.map((l) => l.agentId) }, "review deferred until the delegating run finishes");
856
+ // The lead may have finished between the lookup and the insert, its
857
+ // finalizer finding no row yet: check once more (rows are claimed by
858
+ // delete, so a racing finalizer starts each review once). A failure leaves
859
+ // the rows for the sweep.
860
+ const runIds = [];
861
+ try {
862
+ const lead = await deps.db.run.findUnique({ where: { id: leadRunId }, select: { status: true } });
863
+ if (!lead || !RUN_NOT_FINISHED.has(lead.status)) {
864
+ runIds.push(...(await releaseRequestRows(deps, leadRunId, "request")));
865
+ }
866
+ }
867
+ catch (err) {
868
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber, leadRunId }, "could not re-check the delegating run after deferring; the sweep starts the review");
869
+ }
870
+ return { runIds };
871
+ }
872
+ /**
873
+ * The delegating run `leadRunId` is terminal (it succeeded, failed, or was
874
+ * cancelled): releases every review that waited for it. A reviewer linked
875
+ * with waitForCi whose head's CI is still pending is handed to CI (the row
876
+ * becomes reason "ci", its max wait counted from now); every other row is
877
+ * started (claimed by delete, so concurrent releases start each review once).
878
+ * Called from the lead's finalizer, after the related pull requests sections
879
+ * were rewritten. Never throws.
880
+ */
881
+ export async function startDeferredForRequest(deps, leadRunId) {
882
+ try {
883
+ await releaseRequestRows(deps, leadRunId, "request");
884
+ }
885
+ catch (err) {
886
+ log.warn({ err, leadRunId }, "could not start the reviews waiting for the delegating run");
887
+ }
888
+ }
889
+ async function releaseRequestRows(deps, leadRunId, mode) {
890
+ const rows = await deps.db.deferredReview.findMany({
891
+ where: { leadRunId, reason: "request" },
892
+ select: {
893
+ id: true,
894
+ provider: true,
895
+ repository: true,
896
+ prNumber: true,
897
+ headSha: true,
898
+ agentId: true,
899
+ checkName: true,
900
+ },
901
+ orderBy: { createdAt: "asc" },
902
+ });
903
+ const groups = new Map();
904
+ for (const row of rows) {
905
+ const id = JSON.stringify([row.provider, row.repository, row.prNumber, row.headSha]);
906
+ groups.set(id, [...(groups.get(id) ?? []), row]);
907
+ }
908
+ const runIds = [];
909
+ for (const group of groups.values()) {
910
+ const first = group[0];
911
+ const provider = first.provider;
912
+ const key = { provider, repository: first.repository, prNumber: first.prNumber, headSha: first.headSha };
913
+ const host = deps.hosts[provider];
914
+ if (!host)
915
+ continue;
916
+ try {
917
+ const reviewerLinks = (await loadLinks(deps, provider, key.repository)).filter(isReviewerLink);
918
+ runIds.push(...(await releaseRequestHead(deps, host, key, group, reviewerLinks, mode)));
919
+ }
920
+ catch (err) {
921
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber, leadRunId }, "could not start a review waiting for the delegating run");
922
+ }
923
+ }
924
+ return runIds;
925
+ }
926
+ /** One head's request rows: hands waitForCi rows with CI still pending to CI, starts the rest. */
927
+ async function releaseRequestHead(deps, host, key, rows, reviewerLinks, mode) {
928
+ const waitsForCi = new Set(reviewerLinks.filter((l) => l.waitForCi).map((l) => l.agentId));
929
+ let ciRows = rows.filter((r) => waitsForCi.has(r.agentId));
930
+ if (ciRows.length > 0 && host.readCi) {
931
+ let state;
932
+ try {
933
+ state = (await host.readCi(key.repository, key.headSha)).state;
934
+ }
935
+ catch (err) {
936
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not read CI; reviewing now");
937
+ }
938
+ if (state === undefined || !CI_NOT_FINISHED_ON_PUSH.has(state))
939
+ ciRows = [];
940
+ }
941
+ else
942
+ ciRows = [];
943
+ const startNow = rows.filter((r) => !ciRows.includes(r));
944
+ const runIds = startNow.length > 0
945
+ ? await startDeferred(deps, host, key, startNow, reviewerLinks, mode, "the delegating run finished")
946
+ : [];
947
+ if (ciRows.length === 0 || !host.readCi)
948
+ return runIds;
949
+ const ids = ciRows.map((r) => r.id);
950
+ // Only a row still waiting for the request flips: a concurrent release that claimed it already started it.
951
+ await deps.db.deferredReview.updateMany({
952
+ where: { id: { in: ids }, reason: "request" },
953
+ data: { reason: "ci", createdAt: new Date() },
954
+ });
955
+ log.info({
956
+ repository: key.repository,
957
+ prNumber: key.prNumber,
958
+ headSha: key.headSha,
959
+ agentIds: ciRows.map((r) => r.agentId),
960
+ }, "the delegating run finished; review deferred until CI finishes");
961
+ // As in deferUntilCi: CI may have finished before the flip, its ci_completed skipping the request row.
962
+ try {
963
+ const again = (await host.readCi(key.repository, key.headSha)).state;
964
+ if (!CI_NOT_FINISHED_ON_PUSH.has(again)) {
965
+ const handed = await deps.db.deferredReview.findMany({
966
+ where: { id: { in: ids }, reason: "ci" },
967
+ select: { id: true, agentId: true, checkName: true },
968
+ });
969
+ if (handed.length > 0) {
970
+ runIds.push(...(await startDeferred(deps, host, key, handed, reviewerLinks, "ci", "CI finished")));
971
+ }
972
+ }
973
+ }
974
+ catch (err) {
975
+ log.warn({ err, repository: key.repository, prNumber: key.prNumber }, "could not re-read CI after the delegating run finished; the sweep starts the review");
976
+ }
977
+ return runIds;
978
+ }
735
979
  export async function routeHostEvent(event, deps) {
736
980
  const none = { runIds: [], followUps: [] };
737
981
  if (event.kind === "pr_closed") {
@@ -742,7 +986,18 @@ export async function routeHostEvent(event, deps) {
742
986
  number: event.prNumber,
743
987
  merged: event.merged,
744
988
  });
745
- return none;
989
+ // The merge order checks of the sets this PR is a step of, after the response. Never throws.
990
+ if (!deps.hosts[event.provider]?.upsertNamedCheck)
991
+ return none;
992
+ const pr = { repository: event.repository, number: event.prNumber };
993
+ return {
994
+ runIds: [],
995
+ followUps: [
996
+ () => syncMergeOrderChecksForPullRequest(deps, pr, {
997
+ closed: { ...pr, merged: event.merged },
998
+ }),
999
+ ],
1000
+ };
746
1001
  }
747
1002
  const host = deps.hosts[event.provider];
748
1003
  if (!host)
@@ -816,11 +1071,30 @@ export async function routeHostEvent(event, deps) {
816
1071
  return { runIds, followUps: [] };
817
1072
  }
818
1073
  case "pr_updated": {
819
- if (event.isFork || reviewerLinks.length === 0)
1074
+ if (event.isFork)
820
1075
  return none;
1076
+ // Read at most once per event, by the request deferral and the merge order check alike.
1077
+ let origin;
1078
+ const readOrigin = host.pullRequestOrigin
1079
+ ? () => (origin ??= host.pullRequestOrigin(event.repository, event.prNumber))
1080
+ : undefined;
1081
+ // A new head needs the merge order check re-posted (check runs are per commit), on every
1082
+ // path below, a review held for the delegating run included. After the response; never throws.
1083
+ const followUps = host.upsertNamedCheck && readOrigin
1084
+ ? [
1085
+ () => syncMergeOrderChecksForPullRequest(deps, { repository: event.repository, number: event.prNumber }, { origin: { repository: event.repository, number: event.prNumber, read: readOrigin } }),
1086
+ ]
1087
+ : [];
1088
+ if (reviewerLinks.length === 0)
1089
+ return { runIds: [], followUps };
821
1090
  const allowed = await authorizedLinks(deps, event, reviewerLinks);
822
1091
  if (allowed.length === 0)
823
- return none;
1092
+ return { runIds: [], followUps };
1093
+ // A PR a delegated coding run opened is reviewed once the whole request
1094
+ // finished, so its sibling PRs exist and the related section is current (#259).
1095
+ const request = await deferUntilRequest(deps, host, event, allowed, readOrigin);
1096
+ if (request)
1097
+ return { runIds: request.runIds, followUps };
824
1098
  // Reviewers linked with waitForCi review this head only once its CI finished.
825
1099
  const deferral = await deferUntilCi(deps, host, event, allowed.filter((l) => l.waitForCi));
826
1100
  const startNow = new Set(deferral.startNow);
@@ -829,7 +1103,7 @@ export async function routeHostEvent(event, deps) {
829
1103
  if (reviewers.length > 0) {
830
1104
  runIds.push(...(await startReviews(deps, host, event.repository, event.prNumber, event.headSha, reviewers, true)));
831
1105
  }
832
- return { runIds, followUps: [] };
1106
+ return { runIds, followUps };
833
1107
  }
834
1108
  case "ci_completed": {
835
1109
  if (reviewerLinks.length === 0)
@@ -11,6 +11,7 @@
11
11
  import { Prisma } from "#prisma";
12
12
  import { projectOf, } from "../providers/issue-tracker/types.js";
13
13
  import { logger } from "./logger.js";
14
+ import { dedupeKeys, emitWorkflowEvent } from "./workflow-events.js";
14
15
  const log = logger.child({ module: "issue-bridge" });
15
16
  export const pullRequestGlobalId = (pr) => `wardby:pr:${pr.codeProvider}:${pr.repository}#${pr.number}`;
16
17
  /**
@@ -77,10 +78,13 @@ export async function recordPullRequests(db, tracker, input) {
77
78
  const notes = [];
78
79
  try {
79
80
  let newlyOpened = false;
81
+ const opened = [];
80
82
  for (const pr of input.pullRequests) {
81
83
  const state = await ensurePair(db, input, pr);
82
- if (state === "created" && pr.outcome === "pull_request_opened")
84
+ if (state === "created" && pr.outcome === "pull_request_opened") {
83
85
  newlyOpened = true;
86
+ opened.push(pr);
87
+ }
84
88
  if (state !== "created" && state !== "open")
85
89
  continue;
86
90
  try {
@@ -95,15 +99,28 @@ export async function recordPullRequests(db, tracker, input) {
95
99
  }
96
100
  }
97
101
  const target = input.onPullRequestOpened;
102
+ let movedTo = null;
98
103
  if (target && newlyOpened) {
99
104
  try {
100
- await tracker.transitionTo(input.issueKey, target);
105
+ const moved = await tracker.transitionTo(input.issueKey, target);
106
+ movedTo = moved.toStatus || target;
101
107
  }
102
108
  catch (err) {
103
109
  log.warn({ err, issueKey: input.issueKey, target }, "could not move the issue after a pull request opened");
104
110
  notes.push(`Could not move ${input.issueKey} to "${target}"; it may need a manual move.`);
105
111
  }
106
112
  }
113
+ // Only pairs this call newly recorded, so a retried completion does not notify twice. Never throws.
114
+ for (const pr of opened) {
115
+ await emitWorkflowEvent({
116
+ dedupeKey: dedupeKeys.prOpened(pr.codeProvider, pr.repository, pr.number),
117
+ runId: pr.openedByRunId,
118
+ agentId: input.agentId,
119
+ workItem: { provider: input.issueProvider, key: input.issueKey },
120
+ pullRequest: { codeProvider: pr.codeProvider, repository: pr.repository, number: pr.number },
121
+ payload: { kind: "pr_opened", prLabel: `${pr.repository}#${pr.number}`, prUrl: pr.url, movedTo },
122
+ });
123
+ }
107
124
  }
108
125
  catch (err) {
109
126
  log.warn({ err, issueKey: input.issueKey }, "could not bridge the pull requests to the issue");
@@ -128,6 +145,23 @@ export async function handlePullRequestClosed(db, trackers, pr) {
128
145
  // Not caught: nothing is claimed yet, and a throw rolls back the delivery so it is redelivered.
129
146
  const rows = await db.issuePullRequest.findMany({ where: { ...where, state: "open" } });
130
147
  for (const row of rows) {
148
+ // Exactly once per claimed row, from the finally below, even when a later step throws: the row is no
149
+ // longer open, so nothing would retry it. Never throws.
150
+ const emitClosed = (movedTo) => emitWorkflowEvent({
151
+ dedupeKey: dedupeKeys.prClosed(pr.codeProvider, pr.repository, pr.number),
152
+ agentId: row.agentId,
153
+ workItem: { provider: row.issueProvider, key: row.issueKey },
154
+ pullRequest: { codeProvider: pr.codeProvider, repository: pr.repository, number: pr.number },
155
+ payload: {
156
+ kind: "pr_closed",
157
+ prLabel: `${row.repository}#${row.number}`,
158
+ prUrl: row.url,
159
+ merged: pr.merged,
160
+ movedTo,
161
+ },
162
+ });
163
+ let claimedRow = false;
164
+ let movedTo = null;
131
165
  try {
132
166
  const claimed = await db.issuePullRequest.updateMany({
133
167
  where: { id: row.id, state: "open" },
@@ -135,6 +169,7 @@ export async function handlePullRequestClosed(db, trackers, pr) {
135
169
  });
136
170
  if (claimed.count === 0)
137
171
  continue;
172
+ claimedRow = true;
138
173
  const tracker = trackers[row.issueProvider];
139
174
  if (!tracker)
140
175
  continue;
@@ -166,6 +201,7 @@ export async function handlePullRequestClosed(db, trackers, pr) {
166
201
  if (target) {
167
202
  try {
168
203
  const moved = await tracker.transitionTo(row.issueKey, target);
204
+ movedTo = moved.toStatus;
169
205
  markdown += ` Moved to ${moved.toStatus}.`;
170
206
  }
171
207
  catch (err) {
@@ -185,5 +221,9 @@ export async function handlePullRequestClosed(db, trackers, pr) {
185
221
  catch (err) {
186
222
  log.warn({ err, issueKey: row.issueKey, ...where }, "could not bridge the closed pull request to the issue");
187
223
  }
224
+ finally {
225
+ if (claimedRow)
226
+ await emitClosed(movedTo);
227
+ }
188
228
  }
189
229
  }
@@ -4,6 +4,7 @@ import { issueStatusRow, postIssueWorkingStatus } from "./issue-status.js";
4
4
  import { logger } from "./logger.js";
5
5
  import { openSiblingsForIssue, SIBLING_GUIDANCE } from "./related-pull-requests.js";
6
6
  import { composeTaskOverride } from "./untrusted-content.js";
7
+ import { dedupeKeys, emitWorkflowEvent } from "./workflow-events.js";
7
8
  const log = logger.child({ module: "issue-events" });
8
9
  const MAX_TASK_BODY = 8000;
9
10
  const RUN_ID_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
@@ -93,7 +94,7 @@ export async function routeIssueEvent(event, deps) {
93
94
  return result;
94
95
  const links = (await deps.db.agentIssueProject.findMany({
95
96
  where: { provider: event.provider, projectKey: event.projectKey },
96
- include: { agent: { select: { ownerId: true, kind: true } } },
97
+ include: { agent: { select: { ownerId: true, kind: true, name: true } } },
97
98
  }));
98
99
  const bot = await tracker.botAccountId();
99
100
  // One snapshot per event, taken lazily on the first dispatch so an event no link matches costs nothing.
@@ -139,6 +140,16 @@ export async function routeIssueEvent(event, deps) {
139
140
  const runId = dispatched.run.id;
140
141
  result.runIds.push(runId);
141
142
  result.followUps.push(() => postIssueWorkingStatus(deps.db, deps.trackers, runId));
143
+ // describeKind shows only admin-configured values, so the trigger is safe to show in chat.
144
+ const trigger = matched.map((k) => describeKind(event, k, link)).join("; ");
145
+ const agentName = link.agent.name;
146
+ result.followUps.push(() => emitWorkflowEvent({
147
+ dedupeKey: dedupeKeys.issuePickedUp(runId),
148
+ runId,
149
+ agentId: link.agentId,
150
+ workItem: { provider: event.provider, key: event.issueKey },
151
+ payload: { kind: "issue_picked_up", agentName, trigger },
152
+ }));
142
153
  log.info({ issueKey: event.issueKey, agentId: link.agentId, runId, kinds: matched }, "issue run dispatched");
143
154
  }
144
155
  catch (err) {
@@ -22,12 +22,23 @@ export function toJiraMarkdown(text) {
22
22
  export function issueStatusRow(event, runId, visibilityRole) {
23
23
  return { runId, provider: "jira", issueKey: event.issueKey, visibilityRole };
24
24
  }
25
+ /**
26
+ * Cents from $1 up ($12.34, $1234.50); below $1, two significant digits with at
27
+ * least two decimals so fractions of a cent stay readable ($0.50, $0.12, $0.0035).
28
+ * Never exponent form.
29
+ */
30
+ const usd = (amount) => {
31
+ const abs = Math.abs(amount);
32
+ const decimals = abs === 0 || abs >= 1 ? 2 : Math.max(2, 1 - Math.floor(Math.log10(abs)));
33
+ // Trailing zeros past the cents are noise ($0.010, $0.100 from rounding up).
34
+ return `$${amount.toFixed(Math.min(decimals, 20)).replace(/(\.\d\d\d*?)0+$/, "$1")}`;
35
+ };
25
36
  export function formatSpendLine(input) {
26
- const parts = [`Agent spend: $${input.treeUsd.toFixed(4)} this run`];
37
+ const parts = [`Agent spend: ${usd(input.treeUsd)} this run`];
27
38
  if (input.issueUsd !== null)
28
- parts.push(`$${input.issueUsd.toFixed(4)} on this issue so far`);
39
+ parts.push(`${usd(input.issueUsd)} on this issue so far`);
29
40
  if (input.models.length > 0)
30
- parts.push(input.models.map((m) => `${m.model} $${m.costUsd.toFixed(4)}`).join(", "));
41
+ parts.push(input.models.map((m) => `${m.model} ${usd(m.costUsd)}`).join(", "));
31
42
  return parts.join(" · ");
32
43
  }
33
44
  /** The whole run tree under `runId` (every depth), the issue's total across all its runs, and the tree's models. */