@desplega.ai/agent-swarm 2.5.3 → 2.5.4

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 (197) hide show
  1. package/dist/{acp-adapter-gr2fcyyj.js → acp-adapter-ek38cwsv.js} +4 -4
  2. package/dist/{actions-wv766ghf.js → actions-92mepq03.js} +17 -17
  3. package/dist/{amp-adapter-speb906t.js → amp-adapter-9gaxg015.js} +2 -2
  4. package/dist/{app-ag29n14f.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-3x8bz3eg.js → assistant-zyr5kz47.js} +19 -19
  6. package/dist/{boot-reembed-jsmfrpzf.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-5b3d2eea.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-s73gerxy.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-5zmax2wr.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-hswdnm3t.js → claude-adapter-f0afqnne.js} +4 -4
  11. package/dist/{claude-managed-adapter-cvbzj1h6.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3k8m3hhc.js → claude-managed-setup-bqzwjd9x.js} +1 -1
  13. package/dist/{claude-sdk-session-fkbyk66x.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-vwn81jbs.js → cli-19202kew.js} +3 -3
  16. package/dist/{cli-svrj71pg.js → cli-1r5ad7j5.js} +7 -7
  17. package/dist/{cli-hhxjnjzs.js → cli-25n3mv5d.js} +8 -6
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-hcpj2xqz.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-35gtk1p6.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-40jsnrjm.js → cli-5bnsc0e1.js} +7 -5
  22. package/dist/{cli-fzcn3d87.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-s49hpn37.js → cli-64mh9mf3.js} +19 -5
  24. package/dist/{cli-e8jyq7d8.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-p9rafdrb.js → cli-7p4jxgtt.js} +4 -4
  26. package/dist/{cli-w1zwctj2.js → cli-9j129brq.js} +9 -3
  27. package/dist/{cli-ce0ak9ms.js → cli-abs8ndtr.js} +11 -11
  28. package/dist/{cli-aecdcr77.js → cli-asq5ydm4.js} +123 -50
  29. package/dist/{cli-n45mr7zh.js → cli-bvpgr9ny.js} +2 -2
  30. package/dist/{cli-210xd1f4.js → cli-c61mbdds.js} +3 -3
  31. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  32. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  33. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  34. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  35. package/dist/{cli-s53vp709.js → cli-eftzyh9y.js} +1 -1
  36. package/dist/{cli-3ngdk5te.js → cli-ethgy7ha.js} +1 -1
  37. package/dist/{cli-99xgw26r.js → cli-ff1tm9n9.js} +1 -1
  38. package/dist/{cli-ewm3pwbq.js → cli-g9vd9ece.js} +37 -12
  39. package/dist/{cli-jjznk3f7.js → cli-gbfxp48k.js} +62 -28
  40. package/dist/{cli-z21whrrg.js → cli-gn5af0x5.js} +14 -14
  41. package/dist/{cli-1fc85mvt.js → cli-gwhjmh8g.js} +4 -4
  42. package/dist/{cli-avdvgcay.js → cli-hh5we352.js} +24 -5
  43. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  44. package/dist/{cli-kxawjrxg.js → cli-jb30dzvk.js} +332 -36
  45. package/dist/{cli-25j01dpj.js → cli-jbnn4vxb.js} +3 -3
  46. package/dist/{cli-qzme5949.js → cli-k4tjsdx9.js} +44 -39
  47. package/dist/{cli-8s5wx3nz.js → cli-m4ecdxvz.js} +40 -13
  48. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  49. package/dist/{cli-p6zs9h4y.js → cli-rr7zx19j.js} +767 -249
  50. package/dist/{cli-502eqv93.js → cli-s682zgqz.js} +1 -1
  51. package/dist/{cli-fsdprwrg.js → cli-sbwsxvtd.js} +1 -1
  52. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  53. package/dist/{cli-egjggehm.js → cli-tsma7p8v.js} +1 -1
  54. package/dist/{cli-qgf8emrn.js → cli-wghjkr0e.js} +5 -5
  55. package/dist/{cli-p0rzjkya.js → cli-wx2nj19m.js} +29 -10
  56. package/dist/{cli-e2ar4bp1.js → cli-x0f2v69c.js} +111 -42
  57. package/dist/{cli-y561h5a9.js → cli-xtrz6bzs.js} +5 -5
  58. package/dist/{cli-gpsz7g88.js → cli-xzfq4at3.js} +7 -7
  59. package/dist/cli.js +18 -18
  60. package/dist/{codex-adapter-n8ka66vf.js → codex-adapter-wch12q99.js} +2 -2
  61. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  62. package/dist/{codex-session-runner-fsebts37.js → codex-session-runner-41pfcr95.js} +2 -2
  63. package/dist/{commands-t04rt2pc.js → commands-9849dkj6.js} +2 -2
  64. package/dist/{controller-rgna04r2.js → controller-ja0sd95p.js} +27 -1
  65. package/dist/{controllers-connect-bqm1rtft.js → controllers-connect-xp05dj6x.js} +16 -16
  66. package/dist/{cursor-adapter-swr2k95y.js → cursor-adapter-623qxqew.js} +1 -2
  67. package/dist/{db-a4m7haw0.js → db-47vj40dy.js} +3 -3
  68. package/dist/{db-24ck09xy.js → db-w759qmx0.js} +2 -2
  69. package/dist/{dispatcher-nwf9ss0q.js → dispatcher-7b8e4z2j.js} +15 -15
  70. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  71. package/dist/{dsh-adapter-kpdnhjst.js → dsh-adapter-2ezw3xea.js} +1 -2
  72. package/dist/{external-channel-j1e2p34p.js → external-channel-e2s028da.js} +2 -2
  73. package/dist/{grok-adapter-qtjz0whk.js → grok-adapter-ys7gaqee.js} +5 -5
  74. package/dist/{handlers-19xr6dnz.js → handlers-wy1dr6ja.js} +18 -18
  75. package/dist/{heartbeat-vtesda5d.js → heartbeat-w3mntghj.js} +17 -17
  76. package/dist/{hook-8wqaszda.js → hook-3jjywnpb.js} +9 -3
  77. package/dist/{hook-b2hhj6rd.js → hook-g7kvv6x9.js} +3 -2
  78. package/dist/{http-mxcqhhcs.js → http-tkp7d4kb.js} +267 -75
  79. package/dist/{index-srg522m6.js → index-1ef1myq2.js} +15 -15
  80. package/dist/{index-1r8wfpzs.js → index-3n8m9ea2.js} +1 -1
  81. package/dist/{index-kw2mmat7.js → index-nmm79qgr.js} +17 -17
  82. package/dist/{index-n1vbvta9.js → index-q7m26kvs.js} +24 -22
  83. package/dist/{index-spwxjgay.js → index-xdaahthb.js} +15 -15
  84. package/dist/{keepalive-dsd8rtgx.js → keepalive-v4ta8wgg.js} +3 -3
  85. package/dist/{lead-zyerk1x2.js → lead-sawnyqxk.js} +33 -33
  86. package/dist/{maintenance-jqmqwnet.js → maintenance-84n1ex21.js} +1 -1
  87. package/dist/{oauth-refresh-sweep-q2c3bnvm.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  88. package/dist/{onboard-w4yd7s8t.js → onboard-sfmpe4np.js} +2 -2
  89. package/dist/{opencode-adapter-9tppvxar.js → opencode-adapter-jmpst4q4.js} +3 -3
  90. package/dist/{otel-impl-f36byzbd.js → otel-impl-v6cc0zve.js} +1 -1
  91. package/dist/{pi-mono-adapter-tqsge1ay.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  92. package/dist/{pricing-refresh-xvak5p4v.js → pricing-refresh-fsk1e1zc.js} +3 -3
  93. package/dist/{rbac-roles-gfn30fb8.js → rbac-roles-prmkdnkw.js} +2 -2
  94. package/dist/{rbac-roles-682k2mt5.js → rbac-roles-zxmxqa39.js} +1 -1
  95. package/dist/{render-v2-hgytz8nn.js → render-v2-pjwxvnkw.js} +3 -3
  96. package/dist/{resume-2dvq348g.js → resume-frdtgfa0.js} +8 -6
  97. package/dist/{session-access-7f21ekq9.js → session-access-fpez4y5q.js} +4 -4
  98. package/dist/{setup-epffxtbk.js → setup-mq6m3m8q.js} +1 -1
  99. package/dist/{work-objects-xz1a5rqk.js → work-objects-kfahhjsq.js} +18 -18
  100. package/dist/{worker-42t8emjt.js → worker-v5e5rxj3.js} +33 -33
  101. package/openapi.json +68 -3
  102. package/package.json +5 -5
  103. package/plugin/commands/create-pr.md +2 -2
  104. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  105. package/src/be/db/approvals.ts +48 -0
  106. package/src/be/db/tasks/write.ts +15 -5
  107. package/src/be/db/workflow-idempotency.ts +183 -0
  108. package/src/be/db/workflow-runs.ts +318 -7
  109. package/src/be/db.ts +67 -17
  110. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  111. package/src/be/sealed-json.ts +1 -0
  112. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  113. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  114. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  115. package/src/be/sql-connections.ts +7 -1
  116. package/src/be/swarm-config-guard.ts +2 -0
  117. package/src/be/users.ts +9 -0
  118. package/src/commands/controller.ts +33 -0
  119. package/src/commands/runner.ts +6 -2
  120. package/src/extensions/apply-task-create.ts +57 -11
  121. package/src/extensions/contract-types.generated.ts +1 -1
  122. package/src/extensions/contract.ts +25 -0
  123. package/src/extensions/dispatcher.ts +4 -0
  124. package/src/extensions/post-bridge.ts +8 -0
  125. package/src/hooks/context-bound-note.ts +196 -0
  126. package/src/hooks/hook.ts +12 -0
  127. package/src/http/config.ts +48 -16
  128. package/src/http/idempotency-key.ts +117 -0
  129. package/src/http/workflows.ts +129 -24
  130. package/src/prompts/context-bound-note.ts +48 -0
  131. package/src/prompts/session-templates.ts +25 -1
  132. package/src/providers/amp-adapter.ts +30 -8
  133. package/src/providers/claude-adapter.ts +29 -4
  134. package/src/providers/claude-managed-adapter.ts +26 -0
  135. package/src/providers/claude-sdk-session.ts +10 -0
  136. package/src/providers/claude-session-events.ts +13 -0
  137. package/src/providers/codex-adapter.ts +12 -0
  138. package/src/providers/opencode-adapter.ts +18 -0
  139. package/src/providers/pi-mono-adapter.ts +35 -0
  140. package/src/runtime-providers/docker.ts +6 -2
  141. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  142. package/src/tests/amp-adapter.test.ts +97 -0
  143. package/src/tests/approval-requests.test.ts +10 -7
  144. package/src/tests/approval-sweeps.test.ts +12 -6
  145. package/src/tests/base-prompt.test.ts +1 -1
  146. package/src/tests/claude-hook-output.test.ts +79 -1
  147. package/src/tests/claude-managed-adapter.test.ts +275 -0
  148. package/src/tests/codex-adapter.test.ts +163 -0
  149. package/src/tests/context-bound-note.test.ts +269 -0
  150. package/src/tests/extensions-pre-task.test.ts +273 -0
  151. package/src/tests/fetch-resolved-env.test.ts +17 -0
  152. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  153. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  154. package/src/tests/fixtures/fake-amp.ts +22 -8
  155. package/src/tests/fixtures/held-template-render.ts +42 -0
  156. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  157. package/src/tests/openapi-route-headers.test.ts +2 -0
  158. package/src/tests/opencode-adapter.test.ts +134 -0
  159. package/src/tests/pi-mono-adapter.test.ts +105 -0
  160. package/src/tests/prompt-template-session.test.ts +4 -3
  161. package/src/tests/runner-push-delivery.test.ts +25 -0
  162. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  163. package/src/tests/runtime-providers-docker.test.ts +2 -0
  164. package/src/tests/script-connections.test.ts +3 -0
  165. package/src/tests/script-run-config-scope.test.ts +91 -3
  166. package/src/tests/session-logs.test.ts +70 -0
  167. package/src/tests/sessions-tree.test.ts +37 -0
  168. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  169. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  170. package/src/tests/workflow-idempotency.test.ts +915 -0
  171. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  172. package/src/utils/context-window.ts +8 -6
  173. package/src/workflows/approval-cancel.ts +15 -13
  174. package/src/workflows/approval-notifications.ts +5 -1
  175. package/src/workflows/checkpoint.ts +6 -3
  176. package/src/workflows/engine.ts +30 -8
  177. package/src/workflows/executors/foreach.ts +2 -2
  178. package/src/workflows/foreach-driver.ts +10 -3
  179. package/src/workflows/foreach-join.ts +29 -6
  180. package/src/workflows/recovery.ts +6 -1
  181. package/src/workflows/resume.ts +168 -56
  182. package/src/workflows/retry-poller.ts +49 -3
  183. package/src/workflows/task-step-routing.ts +29 -7
  184. package/templates/ai-toolbox.manifest.json +8 -8
  185. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  186. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  187. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  188. package/templates/skills/agent-browser/SKILL.md +4 -4
  189. package/templates/skills/agent-browser/content.md +4 -4
  190. package/templates/skills/code-quality/config.json +1 -1
  191. package/templates/skills/code-quality/content.md +2 -2
  192. package/templates/skills/delegate-work/content.md +29 -25
  193. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  194. package/templates/skills/qa/content.md +2 -2
  195. package/templates/skills/researching/content.md +2 -0
  196. package/templates/skills/work-on-task/SKILL.md +1 -1
  197. package/templates/skills/work-on-task/content.md +1 -1
@@ -162,3 +162,51 @@ export async function getPendingApprovalsForTasks(taskIds: string[]): Promise<
162
162
  taskIds,
163
163
  );
164
164
  }
165
+
166
+ /** The claim key of one Slack thread's cancellation post: `<channel>:<messageTs>`. */
167
+ export function approvalCancellationNotificationKey(target: string, messageTs: string): string {
168
+ return `${target}:${messageTs}`;
169
+ }
170
+
171
+ /**
172
+ * Ids of the cancelled requests of the given runs with a Slack cancellation
173
+ * post not yet delivered (never claimed, or a claim a failed post released).
174
+ * A delivered post never comes back, so a repeat cancel of a settled tree
175
+ * retries only what failed. One query per 500 runs.
176
+ */
177
+ export async function listUndeliveredCancelledApprovalRequestIds(
178
+ runIds: string[],
179
+ ): Promise<string[]> {
180
+ const ids: string[] = [];
181
+ for (let i = 0; i < runIds.length; i += 500) {
182
+ const chunk = runIds.slice(i, i + 500);
183
+ const rows = await getDbClient().query<{
184
+ id: string;
185
+ notificationChannels: string;
186
+ claims: string | null;
187
+ }>(
188
+ `SELECT id, notificationChannels, cancellationNotificationClaims AS claims
189
+ FROM approval_requests
190
+ WHERE workflowRunId IN (${chunk.map(() => "?").join(", ")})
191
+ AND status = 'cancelled' AND notificationChannels IS NOT NULL`,
192
+ chunk,
193
+ );
194
+ for (const row of rows) {
195
+ const claims = row.claims ? (JSON.parse(row.claims) as Record<string, unknown>) : {};
196
+ const channels = JSON.parse(row.notificationChannels) as Array<{
197
+ channel?: string;
198
+ target?: string;
199
+ messageTs?: string;
200
+ }>;
201
+ const undelivered = channels.some(
202
+ (n) =>
203
+ n.channel === "slack" &&
204
+ n.target &&
205
+ n.messageTs &&
206
+ claims[approvalCancellationNotificationKey(n.target, n.messageTs)] !== "delivered",
207
+ );
208
+ if (undelivered) ids.push(row.id);
209
+ }
210
+ }
211
+ return ids;
212
+ }
@@ -657,7 +657,15 @@ export async function overwriteTerminalTaskResultText(
657
657
  return row ? rowToAgentTask(row) : task;
658
658
  }
659
659
 
660
- export async function cancelTask(id: string, reason?: string): Promise<AgentTask | null> {
660
+ /**
661
+ * `cascadeDependents: false` skips the dependents sweep for a caller that
662
+ * already knows no task depends on this one (a bulk cancel looks once).
663
+ */
664
+ export async function cancelTask(
665
+ id: string,
666
+ reason?: string,
667
+ options: { cascadeDependents?: boolean } = {},
668
+ ): Promise<AgentTask | null> {
661
669
  const oldTask = await getTaskById(id);
662
670
  if (!oldTask) return null;
663
671
 
@@ -736,10 +744,12 @@ export async function cancelTask(id: string, reason?: string): Promise<AgentTask
736
744
  );
737
745
  }
738
746
 
739
- try {
740
- await dependencies.cascadeFailDependents(id, "cancelled");
741
- } catch (err) {
742
- console.error("[cancelTask] cascade-fail dependents error:", err);
747
+ if (options.cascadeDependents !== false) {
748
+ try {
749
+ await dependencies.cascadeFailDependents(id, "cancelled");
750
+ } catch (err) {
751
+ console.error("[cancelTask] cascade-fail dependents error:", err);
752
+ }
743
753
  }
744
754
  }
745
755
 
@@ -0,0 +1,183 @@
1
+ import { getCurrentRequestUserId } from "../../utils/request-auth-context";
2
+ import { openSealedJson, sealJson } from "../sealed-json";
3
+ import { getDbClient } from "./runtime";
4
+
5
+ /**
6
+ * Server-side `Idempotency-Key` records for workflow create and trigger.
7
+ *
8
+ * A record is written in the transaction that accepts the operation (the
9
+ * workflow insert, or the run insert with its initial state), so it exists if
10
+ * and only if the operation committed. Readers look it up before re-running
11
+ * any check that can change between attempts (enabled, cooldown, readiness,
12
+ * trigger schema) and replay the stored response instead.
13
+ */
14
+
15
+ export type WorkflowIdempotencyOperation = "workflow.create" | "workflow.trigger";
16
+
17
+ /** Who may replay a record: one caller, one operation, one target, one key. */
18
+ export interface WorkflowIdempotencyScope {
19
+ /** Stable caller identity, e.g. `user:<id>`; never a credential value. */
20
+ principal: string;
21
+ operation: WorkflowIdempotencyOperation;
22
+ /** The triggered workflow id; empty for create. */
23
+ targetId: string;
24
+ /** The client's `Idempotency-Key` value. Stored only as a hash. */
25
+ key: string;
26
+ }
27
+
28
+ /** A claim for one request: its scope plus the fingerprint of what it asked for. */
29
+ export interface WorkflowIdempotencyClaim extends WorkflowIdempotencyScope {
30
+ fingerprint: string;
31
+ }
32
+
33
+ export interface AcceptedWorkflowOperation {
34
+ fingerprint: string;
35
+ status: number;
36
+ /** The original response body; null once the workflow it names was deleted. */
37
+ body: unknown;
38
+ resourceDeletedAt: string | null;
39
+ }
40
+
41
+ /**
42
+ * The key was accepted for another request while this one was getting ready.
43
+ * Thrown inside the accepting transaction, so this request commits nothing;
44
+ * the caller looks the record up again and answers from it.
45
+ */
46
+ export class IdempotencyKeyTakenError extends Error {
47
+ constructor() {
48
+ super("Idempotency-Key was accepted by a concurrent request");
49
+ this.name = "IdempotencyKeyTakenError";
50
+ }
51
+ }
52
+
53
+ type AcceptedRow = {
54
+ requestFingerprint: string;
55
+ responseStatus: number;
56
+ responseBody: string | null;
57
+ resourceDeletedAt: string | null;
58
+ };
59
+
60
+ function sha256(text: string): string {
61
+ const hasher = new Bun.CryptoHasher("sha256");
62
+ hasher.update(text);
63
+ return hasher.digest("hex");
64
+ }
65
+
66
+ function sortKeys(value: unknown): unknown {
67
+ if (Array.isArray(value)) return value.map(sortKeys);
68
+ if (!value || typeof value !== "object") return value;
69
+ // Code-unit order, not localeCompare: the fingerprint must not depend on the
70
+ // server's locale.
71
+ return Object.fromEntries(
72
+ Object.entries(value as Record<string, unknown>)
73
+ .filter(([, nested]) => nested !== undefined)
74
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
75
+ .map(([key, nested]) => [key, sortKeys(nested)]),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Fingerprint of an accepted request body. Object key order does not matter,
81
+ * array order does, and every value counts as sent (nothing is redacted first,
82
+ * so two requests that differ only in a secret never match). Only the hash is
83
+ * stored.
84
+ */
85
+ export function workflowRequestFingerprint(
86
+ operation: WorkflowIdempotencyOperation,
87
+ body: unknown,
88
+ ): string {
89
+ return sha256(`${operation}\n${JSON.stringify(sortKeys(body ?? null))}`);
90
+ }
91
+
92
+ const SCOPE_WHERE = "principal = ? AND operation = ? AND targetId = ? AND keyHash = ?";
93
+
94
+ function scopeParams(scope: WorkflowIdempotencyScope): string[] {
95
+ return [scope.principal, scope.operation, scope.targetId, sha256(scope.key)];
96
+ }
97
+
98
+ export async function findAcceptedWorkflowOperation(
99
+ scope: WorkflowIdempotencyScope,
100
+ ): Promise<AcceptedWorkflowOperation | null> {
101
+ const row = await getDbClient().get<AcceptedRow>(
102
+ `SELECT requestFingerprint, responseStatus, responseBody, resourceDeletedAt
103
+ FROM workflow_idempotency_keys WHERE ${SCOPE_WHERE}`,
104
+ scopeParams(scope),
105
+ );
106
+ if (!row) return null;
107
+ return {
108
+ fingerprint: row.requestFingerprint,
109
+ status: row.responseStatus,
110
+ body: row.responseBody === null ? null : openSealedJson(row.responseBody),
111
+ resourceDeletedAt: row.resourceDeletedAt,
112
+ };
113
+ }
114
+
115
+ /**
116
+ * Throw `IdempotencyKeyTakenError` when the scope already has a record. Inside
117
+ * the accepting transaction, call it before writing a resource whose insert
118
+ * could collide with the concurrent winner's (a unique workflow name), so the
119
+ * loser replays instead of failing on that constraint.
120
+ */
121
+ export async function assertWorkflowIdempotencyKeyUnused(
122
+ scope: WorkflowIdempotencyScope,
123
+ ): Promise<void> {
124
+ const taken = await getDbClient().get(
125
+ `SELECT 1 FROM workflow_idempotency_keys WHERE ${SCOPE_WHERE}`,
126
+ scopeParams(scope),
127
+ );
128
+ if (taken) throw new IdempotencyKeyTakenError();
129
+ }
130
+
131
+ /**
132
+ * Record the accepted operation. Call it inside the transaction that creates
133
+ * the resource: a rollback drops both. Throws `IdempotencyKeyTakenError` when
134
+ * the scope already has a record (a concurrent request committed first); the
135
+ * unique index backs this across processes.
136
+ */
137
+ export async function recordAcceptedWorkflowOperation(
138
+ claim: WorkflowIdempotencyClaim,
139
+ accepted: { workflowId: string; runId?: string; status: number; body: unknown },
140
+ ): Promise<void> {
141
+ await assertWorkflowIdempotencyKeyUnused(claim);
142
+ const actor = getCurrentRequestUserId() ?? null;
143
+ try {
144
+ await getDbClient().run(
145
+ `INSERT INTO workflow_idempotency_keys
146
+ (id, principal, operation, targetId, keyHash, requestFingerprint, workflowId, runId,
147
+ responseStatus, responseBody, createdAt, created_by, updated_by)
148
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
149
+ [
150
+ crypto.randomUUID(),
151
+ ...scopeParams(claim),
152
+ claim.fingerprint,
153
+ accepted.workflowId,
154
+ accepted.runId ?? null,
155
+ accepted.status,
156
+ sealJson(accepted.body),
157
+ new Date().toISOString(),
158
+ actor,
159
+ actor,
160
+ ],
161
+ );
162
+ } catch (err) {
163
+ if (err instanceof Error && err.message.includes("UNIQUE constraint failed")) {
164
+ throw new IdempotencyKeyTakenError();
165
+ }
166
+ throw err;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Turn every record naming `workflowId` into a tombstone: the stored response
172
+ * (which may carry the deleted workflow's definition) is cleared and the key
173
+ * stays taken, so a retry is told the resource is gone instead of creating or
174
+ * triggering anything again. Runs in the delete's transaction.
175
+ */
176
+ export async function tombstoneWorkflowIdempotencyKeys(workflowId: string): Promise<void> {
177
+ await getDbClient().run(
178
+ `UPDATE workflow_idempotency_keys
179
+ SET responseBody = NULL, resourceDeletedAt = ?, updated_by = ?
180
+ WHERE workflowId = ? AND resourceDeletedAt IS NULL`,
181
+ [new Date().toISOString(), getCurrentRequestUserId() ?? null, workflowId],
182
+ );
183
+ }
@@ -1,4 +1,4 @@
1
- import type { WorkflowRunStatus } from "../../types";
1
+ import { TERMINAL_TASK_STATUSES, type WorkflowRunStatus } from "../../types";
2
2
  import { scrubSecrets } from "../../utils/secret-scrubber";
3
3
  import { scrubJsonValue } from "../scrub-json";
4
4
  import { getDbClient } from "./runtime";
@@ -14,6 +14,292 @@ const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
14
14
  "skipped",
15
15
  ]);
16
16
 
17
+ /**
18
+ * Deepest run tree the lineage guard and the cascade cancel walk. Twice the
19
+ * sub-workflow nesting cap (MAX_SUB_WORKFLOW_DEPTH, 16), so any tree the
20
+ * engine admits fits; a deeper chain is corrupt and fails closed.
21
+ */
22
+ export const WORKFLOW_RUN_TREE_MAX_DEPTH = 32;
23
+
24
+ /** Thrown when a run's stored parent links cannot be walked: a cycle, or a chain past the depth bound. */
25
+ export class WorkflowRunTreeError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "WorkflowRunTreeError";
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Thrown when work is created for a workflow step that may no longer run: the
34
+ * step is not `running`, its run is not live, or the run's lineage is blocked
35
+ * (see `getRunLineageBlock`).
36
+ */
37
+ export class WorkflowStepNotExecutableError extends Error {
38
+ constructor(stepId: string, reason: string) {
39
+ super(`Workflow step ${stepId} may not start new work: ${reason}`);
40
+ this.name = "WorkflowStepNotExecutableError";
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Why the run and its ancestors forbid new engine work, or null when they
46
+ * allow it. Blocked when the run is missing, the run or an ancestor is
47
+ * `cancelled`, a stored parent link points at a missing step or run, the
48
+ * links form a cycle, or the chain is deeper than WORKFLOW_RUN_TREE_MAX_DEPTH.
49
+ * A completed or failed ancestor does not block: a child of an earlier
50
+ * attempt may outlive its parent's run. The run's own live status is left to
51
+ * the caller, since the retry claim also revives a `failed` run.
52
+ *
53
+ * Call it inside the transaction that writes the new work, so a cascade
54
+ * cancel either commits first and is seen here or sees the new row itself.
55
+ */
56
+ export async function getRunLineageBlock(runId: string): Promise<string | null> {
57
+ const rows = await getDbClient().query<{
58
+ id: string;
59
+ status: WorkflowRunStatus;
60
+ parentStepId: string | null;
61
+ depth: number;
62
+ }>(
63
+ `WITH RECURSIVE lineage(id, status, parentStepId, depth) AS (
64
+ SELECT id, status, parentStepId, 0 FROM workflow_runs WHERE id = ?
65
+ UNION ALL
66
+ SELECT r.id, r.status, r.parentStepId, l.depth + 1
67
+ FROM lineage l
68
+ JOIN workflow_run_steps s ON s.id = l.parentStepId
69
+ JOIN workflow_runs r ON r.id = s.runId
70
+ WHERE l.depth < ?
71
+ )
72
+ SELECT id, status, parentStepId, depth FROM lineage ORDER BY depth`,
73
+ [runId, WORKFLOW_RUN_TREE_MAX_DEPTH],
74
+ );
75
+ if (rows.length === 0) return `Workflow run ${runId} is missing`;
76
+ const seen = new Set<string>();
77
+ for (const row of rows) {
78
+ if (seen.has(row.id)) return `Workflow run ${runId} has a cycle in its parent links`;
79
+ seen.add(row.id);
80
+ if (row.status === "cancelled") {
81
+ return row.id === runId
82
+ ? `Workflow run ${runId} is cancelled`
83
+ : `Ancestor workflow run ${row.id} is cancelled`;
84
+ }
85
+ }
86
+ const top = rows[rows.length - 1]!;
87
+ if (top.parentStepId == null) return null;
88
+ if (top.depth >= WORKFLOW_RUN_TREE_MAX_DEPTH) {
89
+ return `Workflow run ${runId} is nested deeper than ${WORKFLOW_RUN_TREE_MAX_DEPTH} levels`;
90
+ }
91
+ return `Parent step ${top.parentStepId} of workflow run ${top.id} is missing`;
92
+ }
93
+
94
+ /**
95
+ * The run and every run below it, root first: children of every attempt of
96
+ * every step, through terminal runs too, since a run that ended can still have
97
+ * a live child of an earlier attempt. The tree comes from stored links only
98
+ * (child.parentStepId → step → run). There is no size limit: the engine admits
99
+ * any number of children per run, and a tree it admitted must stay
100
+ * cancellable. Throws WorkflowRunTreeError on a cycle or past `maxDepth`, so
101
+ * the caller's transaction rolls back whole.
102
+ */
103
+ export async function getWorkflowRunTreeIds(
104
+ rootId: string,
105
+ bounds: { maxDepth?: number } = {},
106
+ ): Promise<string[]> {
107
+ const maxDepth = bounds.maxDepth ?? WORKFLOW_RUN_TREE_MAX_DEPTH;
108
+ const ids = [rootId];
109
+ const seen = new Set(ids);
110
+ // One level per pass, so a cycle stops at its first repeated run instead of
111
+ // unrolling down to the depth bound.
112
+ let level = [rootId];
113
+ for (let depth = 1; level.length > 0; depth++) {
114
+ const next: string[] = [];
115
+ for (let i = 0; i < level.length; i += TREE_QUERY_CHUNK) {
116
+ const chunk = level.slice(i, i + TREE_QUERY_CHUNK);
117
+ const rows = await getDbClient().query<{ id: string }>(
118
+ `SELECT c.id
119
+ FROM workflow_run_steps s
120
+ JOIN workflow_runs c ON c.parentStepId = s.id
121
+ WHERE s.runId IN (${chunk.map(() => "?").join(", ")})`,
122
+ chunk,
123
+ );
124
+ for (const { id } of rows) {
125
+ // Each run has one parent link, so reaching a run twice means a cycle.
126
+ if (seen.has(id)) {
127
+ throw new WorkflowRunTreeError(`Workflow run tree of ${rootId} has a cycle at run ${id}`);
128
+ }
129
+ seen.add(id);
130
+ next.push(id);
131
+ }
132
+ }
133
+ if (next.length > 0 && depth > maxDepth) {
134
+ throw new WorkflowRunTreeError(
135
+ `Workflow run tree of ${rootId} is nested deeper than ${maxDepth} levels`,
136
+ );
137
+ }
138
+ ids.push(...next);
139
+ level = next;
140
+ }
141
+ return ids;
142
+ }
143
+
144
+ /** Parent runs per children query, well under SQLite's bound-parameter limit. */
145
+ const TREE_QUERY_CHUNK = 500;
146
+
147
+ /** A run's status alone, without opening its sealed context. Null when the run is missing. */
148
+ export async function getWorkflowRunStatus(runId: string): Promise<WorkflowRunStatus | null> {
149
+ const row = await getDbClient().get<{ status: WorkflowRunStatus }>(
150
+ "SELECT status FROM workflow_runs WHERE id = ?",
151
+ [runId],
152
+ );
153
+ return row?.status ?? null;
154
+ }
155
+
156
+ /** Run `fn` over `ids` in chunks small enough for one statement's bound parameters. */
157
+ async function forEachChunk(
158
+ ids: string[],
159
+ fn: (chunk: string[], placeholders: string) => Promise<void>,
160
+ ): Promise<void> {
161
+ for (let i = 0; i < ids.length; i += TREE_QUERY_CHUNK) {
162
+ const chunk = ids.slice(i, i + TREE_QUERY_CHUNK);
163
+ await fn(chunk, chunk.map(() => "?").join(", "));
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Ids of the non-terminal tasks of the given runs, including tasks of earlier
169
+ * attempts: a retry detaches a task from its step, but the run link stays.
170
+ */
171
+ export async function getOpenWorkflowTreeTaskIds(runIds: string[]): Promise<string[]> {
172
+ const ids: string[] = [];
173
+ const terminal = TERMINAL_TASK_STATUSES.map(() => "?").join(", ");
174
+ await forEachChunk(runIds, async (chunk, placeholders) => {
175
+ const rows = await getDbClient().query<{ id: string }>(
176
+ `SELECT id FROM agent_tasks
177
+ WHERE workflowRunId IN (${placeholders}) AND status NOT IN (${terminal})`,
178
+ [...chunk, ...TERMINAL_TASK_STATUSES],
179
+ );
180
+ for (const row of rows) ids.push(row.id);
181
+ });
182
+ return ids;
183
+ }
184
+
185
+ /**
186
+ * Ids of tasks that a non-terminal task lists in `dependsOn`: the only
187
+ * dependents a cancel can fail. One scan over open tasks, so a cancel of many
188
+ * tasks looks for dependents once instead of once per task.
189
+ */
190
+ export async function getTaskIdsWithDependents(): Promise<Set<string>> {
191
+ const rows = await getDbClient().query<{ id: string }>(
192
+ `SELECT DISTINCT dep.value AS id
193
+ FROM agent_tasks t, json_each(t.dependsOn) AS dep
194
+ WHERE t.status NOT IN (${TERMINAL_TASK_STATUSES.map(() => "?").join(", ")})
195
+ AND t.dependsOn IS NOT NULL AND t.dependsOn != '[]'`,
196
+ [...TERMINAL_TASK_STATUSES],
197
+ );
198
+ return new Set(rows.map((row) => row.id));
199
+ }
200
+
201
+ /**
202
+ * True when any of the given runs still has work a cancel would close: a live
203
+ * run, a pending, running or waiting step, a queued retry, an open task or a
204
+ * pending approval. Reads only, with a LIMIT 1 probe per chunk.
205
+ */
206
+ export async function workflowRunTreeHasOpenWork(runIds: string[]): Promise<boolean> {
207
+ const terminal = TERMINAL_TASK_STATUSES.map(() => "?").join(", ");
208
+ const probes: Array<{ sql: (ids: string) => string; params: string[] }> = [
209
+ {
210
+ sql: (ids) =>
211
+ `SELECT 1 FROM workflow_runs WHERE id IN (${ids}) AND status IN ('running', 'waiting') LIMIT 1`,
212
+ params: [],
213
+ },
214
+ {
215
+ sql: (ids) => `SELECT 1 FROM workflow_run_steps
216
+ WHERE runId IN (${ids})
217
+ AND (status IN ('pending', 'running', 'waiting')
218
+ OR (status = 'failed' AND nextRetryAt IS NOT NULL))
219
+ LIMIT 1`,
220
+ params: [],
221
+ },
222
+ {
223
+ sql: (ids) =>
224
+ `SELECT 1 FROM agent_tasks WHERE workflowRunId IN (${ids}) AND status NOT IN (${terminal}) LIMIT 1`,
225
+ params: [...TERMINAL_TASK_STATUSES],
226
+ },
227
+ {
228
+ sql: (ids) =>
229
+ `SELECT 1 FROM approval_requests WHERE workflowRunId IN (${ids}) AND status = 'pending' LIMIT 1`,
230
+ params: [],
231
+ },
232
+ ];
233
+ let open = false;
234
+ await forEachChunk(runIds, async (chunk, placeholders) => {
235
+ for (const probe of probes) {
236
+ if (open) return;
237
+ if (await getDbClient().get(probe.sql(placeholders), [...chunk, ...probe.params])) {
238
+ open = true;
239
+ }
240
+ }
241
+ });
242
+ return open;
243
+ }
244
+
245
+ /**
246
+ * Cancel the pending, running and waiting steps of the given runs. The
247
+ * migration-140 trigger closes each cancelled step's pending approval.
248
+ */
249
+ export async function cancelOpenWorkflowTreeSteps(
250
+ runIds: string[],
251
+ reason: string,
252
+ finishedAt: string,
253
+ ): Promise<void> {
254
+ await forEachChunk(runIds, async (chunk, placeholders) => {
255
+ await getDbClient().run(
256
+ `UPDATE workflow_run_steps
257
+ SET status = 'cancelled', error = ?, finishedAt = ?
258
+ WHERE runId IN (${placeholders}) AND status IN ('pending', 'running', 'waiting')`,
259
+ [scrubSecrets(reason), finishedAt, ...chunk],
260
+ );
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Drop the pending retries of the failed steps of the given runs. The step
266
+ * keeps its status, error, output and retryCount; only the queued next
267
+ * attempt goes.
268
+ */
269
+ export async function disarmWorkflowTreeRetries(
270
+ runIds: string[],
271
+ finishedAt: string,
272
+ ): Promise<void> {
273
+ await forEachChunk(runIds, async (chunk, placeholders) => {
274
+ await getDbClient().run(
275
+ `UPDATE workflow_run_steps
276
+ SET nextRetryAt = NULL, finishedAt = COALESCE(finishedAt, ?)
277
+ WHERE runId IN (${placeholders}) AND status = 'failed' AND nextRetryAt IS NOT NULL`,
278
+ [finishedAt, ...chunk],
279
+ );
280
+ });
281
+ }
282
+
283
+ /**
284
+ * Mark the given runs that are still running or waiting `cancelled`, without
285
+ * the per-run `workflow.child.finished` wake-up: it is for runs whose parent
286
+ * step the same cancel closes. A terminal run keeps its status and reason.
287
+ */
288
+ export async function cancelLiveWorkflowTreeRuns(
289
+ runIds: string[],
290
+ reason: string,
291
+ finishedAt: string,
292
+ ): Promise<void> {
293
+ await forEachChunk(runIds, async (chunk, placeholders) => {
294
+ await getDbClient().run(
295
+ `UPDATE workflow_runs
296
+ SET status = 'cancelled', error = ?, finishedAt = ?, lastUpdatedAt = ?
297
+ WHERE id IN (${placeholders}) AND status IN ('running', 'waiting')`,
298
+ [scrubSecrets(reason), finishedAt, finishedAt, ...chunk],
299
+ );
300
+ });
301
+ }
302
+
17
303
  /** The id of the child run a `sub-workflow` step started on `attempt`, if any. */
18
304
  export async function getChildWorkflowRunId(
19
305
  parentStepId: string,
@@ -28,23 +314,48 @@ export async function getChildWorkflowRunId(
28
314
 
29
315
  /**
30
316
  * True while `attempt` of the step is executing in a live run: the step is
31
- * `running` with that `retryCount`. A child run is created only then, so a
32
- * cancelled run, a step another attempt moved on, or a step already parked
33
- * never gets a new child.
317
+ * `running` with that `retryCount` and no ancestor run is cancelled. A child
318
+ * run is created only then, so a cancelled run or tree, a step another attempt
319
+ * moved on, or a step already parked never gets a new child.
34
320
  */
35
321
  export async function isParentStepAttemptCurrent(
36
322
  parentStepId: string,
37
323
  attempt: number,
38
324
  ): Promise<boolean> {
39
- const row = await getDbClient().get<{ found: number }>(
40
- `SELECT 1 AS found
325
+ const row = await getDbClient().get<{ runId: string }>(
326
+ `SELECT r.id AS runId
41
327
  FROM workflow_run_steps s
42
328
  JOIN workflow_runs r ON r.id = s.runId
43
329
  WHERE s.id = ? AND s.status = 'running' AND s.retryCount = ?
44
330
  AND r.status IN ('running', 'waiting')`,
45
331
  [parentStepId, attempt],
46
332
  );
47
- return row != null;
333
+ return row != null && (await getRunLineageBlock(row.runId)) == null;
334
+ }
335
+
336
+ /**
337
+ * Why `stepId` may not start new work (a task, an approval, a child run), or
338
+ * null when it may: the step is `running` in a live run whose lineage is not
339
+ * blocked. Call it inside the transaction that inserts the work.
340
+ */
341
+ export async function getStepExecutionBlock(stepId: string): Promise<string | null> {
342
+ const row = await getDbClient().get<{
343
+ stepStatus: string;
344
+ runId: string;
345
+ runStatus: WorkflowRunStatus;
346
+ }>(
347
+ `SELECT s.status AS stepStatus, r.id AS runId, r.status AS runStatus
348
+ FROM workflow_run_steps s
349
+ JOIN workflow_runs r ON r.id = s.runId
350
+ WHERE s.id = ?`,
351
+ [stepId],
352
+ );
353
+ if (!row) return "the step or its run is missing";
354
+ if (row.runStatus !== "running" && row.runStatus !== "waiting") {
355
+ return `its run is ${row.runStatus}`;
356
+ }
357
+ if (row.stepStatus !== "running") return `the step is ${row.stepStatus}`;
358
+ return getRunLineageBlock(row.runId);
48
359
  }
49
360
 
50
361
  /**