@desplega.ai/agent-swarm 1.167.0 → 1.169.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 (184) hide show
  1. package/dist/{acp-adapter-zpvy6321.js → acp-adapter-m47zfx4a.js} +2 -2
  2. package/dist/{actions-fq2zrwtf.js → actions-ws7k3tvq.js} +25 -19
  3. package/dist/{amp-adapter-5gbk7501.js → amp-adapter-nvv8k8j5.js} +1 -1
  4. package/dist/{app-nctetn1e.js → app-80w6qspx.js} +5 -5
  5. package/dist/{assistant-0aq8gjvj.js → assistant-jtq439kw.js} +29 -20
  6. package/dist/{boot-reembed-1ze5853n.js → boot-reembed-3nc0aaed.js} +4 -4
  7. package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-feda8wn8.js} +4 -4
  8. package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-grsa2er0.js} +5 -5
  9. package/dist/{boot-seeds-22tx8a53.js → boot-seeds-gcfgq44f.js} +7 -7
  10. package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-pjv2q7t8.js} +7 -7
  11. package/dist/{claude-managed-adapter-tzwxy803.js → claude-managed-adapter-0wa8se82.js} +2 -2
  12. package/dist/{claude-managed-setup-nrhfx5b0.js → claude-managed-setup-zxa9p9tx.js} +1 -1
  13. package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-c9j6k9kx.js} +8772 -8663
  14. package/dist/{cli-c2tpw9ck.js → cli-3ry5gx4f.js} +1 -1
  15. package/dist/{cli-wydz3feg.js → cli-61tbnjpf.js} +214 -2472
  16. package/dist/{cli-cexctgn5.js → cli-7vz49g7h.js} +101 -34
  17. package/dist/{cli-6qxmjpem.js → cli-8xjst8q2.js} +34 -22
  18. package/dist/{cli-tx2zvn23.js → cli-91r17hb1.js} +3 -3
  19. package/dist/{cli-577bxkf6.js → cli-a8vps458.js} +182 -2208
  20. package/dist/{cli-tc5zec7v.js → cli-b7y8113e.js} +1 -1
  21. package/dist/{cli-69egj654.js → cli-bqrs5ycj.js} +1 -1
  22. package/dist/{cli-cf2vgn9d.js → cli-dv76vq5d.js} +30 -28
  23. package/dist/{cli-g5erf0zt.js → cli-e2prcx5m.js} +31 -0
  24. package/dist/{cli-c8r99akr.js → cli-e5qk3m2a.js} +209 -31
  25. package/dist/{cli-z1dqhc9y.js → cli-eqfbsxsn.js} +3 -3
  26. package/dist/{cli-kw0jcfje.js → cli-fyeft04q.js} +1 -1
  27. package/dist/{cli-5qkcdv8c.js → cli-gmdajfw5.js} +3 -3
  28. package/dist/{cli-ypgba1a8.js → cli-jkfxpg1b.js} +1 -1
  29. package/dist/{cli-mxvnnv5q.js → cli-jv36y8wz.js} +1 -1
  30. package/dist/{cli-m7vmeepg.js → cli-km8559dm.js} +6 -6
  31. package/dist/{cli-ykt8f7va.js → cli-kp3vfknv.js} +1 -1
  32. package/dist/{cli-ymmc1zxg.js → cli-m23r88re.js} +8 -5
  33. package/dist/{cli-v1pwxreq.js → cli-n4z21pt8.js} +1 -1
  34. package/dist/{cli-mxry91sa.js → cli-qg2zhjfc.js} +73 -4
  35. package/dist/{cli-1yrcfa5s.js → cli-rbr91whd.js} +4 -4
  36. package/dist/{cli-mkdsy1nq.js → cli-rg1c8ccw.js} +93 -7
  37. package/dist/cli-rh44xx46.js +2659 -0
  38. package/dist/cli-s0k2j7q9.js +2084 -0
  39. package/dist/{cli-ty4bzkqr.js → cli-sajr098b.js} +1 -1
  40. package/dist/{cli-6vfz44sr.js → cli-swrz3mj8.js} +1 -1
  41. package/dist/{cli-p8rt8spp.js → cli-vejds37t.js} +2 -2
  42. package/dist/{cli-jbzbr36n.js → cli-w6xa5nf5.js} +1 -1
  43. package/dist/{cli-9q3wt23f.js → cli-x6z90nq9.js} +1 -1
  44. package/dist/{cli-qzmzwegq.js → cli-ysnkvdx6.js} +102 -9
  45. package/dist/{cli-y0kjpfm9.js → cli-yvxfaem2.js} +2 -2
  46. package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
  47. package/dist/{cli-6bpvv8h4.js → cli-zzev32jw.js} +1 -1
  48. package/dist/cli.js +20 -17
  49. package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-0kpg09ca.js} +3 -3
  50. package/dist/{codex-hook-15dgc4cb.js → codex-hook-295jpbfa.js} +2 -2
  51. package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-8n672hxh.js} +3 -3
  52. package/dist/{commands-h6zaaxym.js → commands-mhmg5n0x.js} +4 -4
  53. package/dist/{cursor-adapter-v8mm6bp5.js → cursor-adapter-wn8n5zzk.js} +2 -2
  54. package/dist/{db-qg3myn53.js → db-wa6s1mwv.js} +4 -4
  55. package/dist/{dispatcher-7vnwj9qw.js → dispatcher-nhs2pkr8.js} +16 -14
  56. package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
  57. package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-28pkdt80.js} +3 -3
  58. package/dist/{handlers-2qtkpncr.js → handlers-5rtf9nng.js} +19 -17
  59. package/dist/{hook-51yfcgsv.js → hook-krjfkpzy.js} +4 -4
  60. package/dist/{hook-qnbdgwvd.js → hook-qkm73zk6.js} +5 -5
  61. package/dist/{http-jsmtag08.js → http-a397xcn4.js} +4845 -3484
  62. package/dist/{index-2t9kesn1.js → index-ac0v8v9d.js} +4 -4
  63. package/dist/{index-ekywyv2v.js → index-kqre45fr.js} +16 -14
  64. package/dist/{index-bt8wkv1w.js → index-rtxg15ah.js} +17 -15
  65. package/dist/{index-t4g06w93.js → index-vvsq7v45.js} +26 -23
  66. package/dist/{index-cx1sb9bq.js → index-ytdhtdry.js} +50 -11
  67. package/dist/{keepalive-4aezx4bj.js → keepalive-psfh8ysx.js} +6 -6
  68. package/dist/{lead-r44bkpm5.js → lead-m6rppjn7.js} +29 -27
  69. package/dist/{maintenance-grz5jrzh.js → maintenance-41f837ke.js} +4 -4
  70. package/dist/{node-qm9eb6z0.js → node-fkajaq96.js} +1 -1
  71. package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-6hr55hm8.js} +5 -5
  72. package/dist/{onboard-v60zgqf0.js → onboard-ktrtd7cz.js} +2 -2
  73. package/dist/{otel-impl-ghh4dyf8.js → otel-impl-dq4f2k3m.js} +1 -1
  74. package/dist/{pi-mono-adapter-0eg8kw2c.js → pi-mono-adapter-e5v9w0hv.js} +3 -2
  75. package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-kmk7j1ra.js} +6 -6
  76. package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-9w1dkkxd.js} +4 -4
  77. package/dist/{rbac-roles-axv4d74x.js → rbac-roles-9ykx9tc5.js} +5 -5
  78. package/dist/{render-v2-k50j33qd.js → render-v2-ycqm53np.js} +5 -5
  79. package/dist/resume-cq2pkp8q.js +70 -0
  80. package/dist/{setup-4syh0pd2.js → setup-0dcmdvbn.js} +2 -2
  81. package/dist/{virtual-modules-ftr9kwe8.js → virtual-modules-1x9k4jcs.js} +1 -1
  82. package/dist/{work-objects-jcdbg8rr.js → work-objects-x01x2fh8.js} +19 -17
  83. package/dist/{worker-x8j8c9sx.js → worker-py1ssb1h.js} +29 -27
  84. package/openapi.json +15622 -15042
  85. package/package.json +8 -5
  86. package/src/apps/store.ts +1 -0
  87. package/src/artifact-sdk/feedback-overlay.ts +528 -0
  88. package/src/be/db/workflow-runs.ts +95 -0
  89. package/src/be/db-queries/slack-inbound.ts +489 -0
  90. package/src/be/db.ts +9 -2
  91. package/src/be/extensions/assets.ts +1 -1
  92. package/src/be/migrations/203_workflow_run_parent_step.sql +10 -0
  93. package/src/be/migrations/204_slack_inbound_receipts.sql +51 -0
  94. package/src/be/script-connections.ts +41 -7
  95. package/src/be/scripts/run-saved.ts +40 -20
  96. package/src/be/swarm-config-guard.ts +4 -0
  97. package/src/extensions/catalog.generated.json +17 -0
  98. package/src/extensions/catalog.ts +1 -1
  99. package/src/extensions/dispatcher.ts +3 -3
  100. package/src/heartbeat/heartbeat.ts +152 -2
  101. package/src/heartbeat/templates.ts +16 -0
  102. package/src/http/all-routes.ts +1 -0
  103. package/src/http/index.ts +2 -0
  104. package/src/http/integrations.ts +3 -1
  105. package/src/http/mcp-bridge.ts +1 -0
  106. package/src/http/pages-public.ts +7 -2
  107. package/src/http/pages.ts +178 -0
  108. package/src/http/script-connections.ts +35 -0
  109. package/src/http/slack-inbound.ts +107 -0
  110. package/src/http/tasks.ts +45 -27
  111. package/src/http/users.ts +1 -1
  112. package/src/http/webhooks.ts +20 -20
  113. package/src/prompts/session-templates.ts +32 -0
  114. package/src/providers/acp-targets.ts +1 -1
  115. package/src/providers/amp-adapter.ts +2 -2
  116. package/src/providers/claude-adapter.ts +6 -0
  117. package/src/providers/claude-background-keepalive.ts +100 -0
  118. package/src/providers/claude-managed-adapter.ts +2 -11
  119. package/src/providers/claude-sdk-session.ts +7 -1
  120. package/src/providers/dsh-adapter.ts +1 -1
  121. package/src/providers/opencode-adapter.ts +1 -2
  122. package/src/providers/pi-mono-adapter.ts +3 -3
  123. package/src/realtime/browser.generated.txt +8 -8
  124. package/src/realtime/browser.ts +3 -3
  125. package/src/scheduler/scheduler.ts +82 -13
  126. package/src/scripts-runtime/executors/quickjs-pool-config.ts +26 -0
  127. package/src/scripts-runtime/executors/quickjs.ts +9 -2
  128. package/src/scripts-runtime/executors/registry.ts +1 -1
  129. package/src/server.ts +1 -0
  130. package/src/slack/actions.ts +6 -2
  131. package/src/slack/app.ts +5 -0
  132. package/src/slack/assistant.ts +15 -5
  133. package/src/slack/egress-scrub.ts +3 -2
  134. package/src/slack/handlers.ts +36 -18
  135. package/src/slack/inbound-dispatch.ts +549 -0
  136. package/src/slack/inbound-files.ts +2 -0
  137. package/src/telemetry-context.ts +1 -0
  138. package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
  139. package/src/tests/claude-background-keepalive.fixture.ts +162 -0
  140. package/src/tests/claude-background-keepalive.test.ts +139 -0
  141. package/src/tests/codex-oauth-device.test.ts +1 -1
  142. package/src/tests/connection-embedded-auth.test.ts +51 -0
  143. package/src/tests/docs-index-footprint.test.js +3 -3
  144. package/src/tests/dsh-adapter.test.ts +1 -1
  145. package/src/tests/extensions-catalog.test.ts +1 -0
  146. package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
  147. package/src/tests/heartbeat-checklist.test.ts +243 -0
  148. package/src/tests/pages-feedback.test.ts +218 -0
  149. package/src/tests/schedule-target-type.test.ts +136 -0
  150. package/src/tests/script-connections-http.test.ts +45 -0
  151. package/src/tests/script-connections-mcp.test.ts +41 -0
  152. package/src/tests/script-connections.test.ts +2 -0
  153. package/src/tests/script-executor-quickjs-pool.test.ts +106 -0
  154. package/src/tests/script-executor-registry.test.ts +56 -0
  155. package/src/tests/send-task-self-send.test.ts +107 -0
  156. package/src/tests/slack-inbound-diagnostics-auth.test.ts +93 -0
  157. package/src/tests/slack-inbound-receipts.test.ts +684 -0
  158. package/src/tests/slack-session-status.test.ts +4 -2
  159. package/src/tests/ui-e2e-ingest-payload.test.ts +5 -1
  160. package/src/tests/user-token-routes.test.ts +1 -1
  161. package/src/tests/workflow-executors.test.ts +3 -2
  162. package/src/tests/workflow-sub-workflow.test.ts +363 -0
  163. package/src/tools/script-connections/tool.ts +35 -3
  164. package/src/tools/send-task.ts +9 -1
  165. package/src/utils/internal-ai/complete-structured.ts +6 -3
  166. package/src/utils/runtime-model-catalog.ts +1 -1
  167. package/src/workflows/engine.ts +17 -1
  168. package/src/workflows/executors/agent-task.ts +2 -2
  169. package/src/workflows/executors/human-in-the-loop.ts +3 -3
  170. package/src/workflows/executors/registry.ts +2 -0
  171. package/src/workflows/executors/script.ts +1 -1
  172. package/src/workflows/executors/sub-workflow.ts +135 -0
  173. package/src/workflows/executors/wait.ts +3 -3
  174. package/src/workflows/recovery.ts +11 -1
  175. package/src/workflows/resume.ts +77 -0
  176. package/src/workflows/wait-filter.ts +5 -7
  177. package/templates/extensions/README.md +1 -0
  178. package/templates/extensions/slack-reply-gate/README.md +98 -0
  179. package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
  180. package/templates/extensions/slack-reply-gate/manifest.json +8 -0
  181. package/templates/skills/heartbeat-runbook/content.md +3 -1
  182. package/templates/skills/pages/content.md +25 -0
  183. package/templates/skills/swarm-scripts/SKILL.md +10 -0
  184. package/templates/skills/swarm-scripts/content.md +10 -0
@@ -0,0 +1,95 @@
1
+ import type { WorkflowRunStatus } from "../../types";
2
+ import { scrubSecrets } from "../../utils/secret-scrubber";
3
+ import { getDbClient } from "./runtime";
4
+
5
+ // Parent/child links between workflow runs (migration 203): a `sub-workflow`
6
+ // step starts a child run that records the step in `parentStepId`.
7
+
8
+ const TERMINAL_STATUSES = new Set<WorkflowRunStatus>([
9
+ "completed",
10
+ "failed",
11
+ "cancelled",
12
+ "skipped",
13
+ ]);
14
+
15
+ /** The id of the child run a `sub-workflow` step started, if any. */
16
+ export async function getChildWorkflowRunId(parentStepId: string): Promise<string | null> {
17
+ const row = await getDbClient().get<{ id: string }>(
18
+ "SELECT id FROM workflow_runs WHERE parentStepId = ?",
19
+ [parentStepId],
20
+ );
21
+ return row?.id ?? null;
22
+ }
23
+
24
+ /**
25
+ * Workflow ids of a run and the ancestor runs that started it, nearest first.
26
+ * Stops `maxDepth` ancestors up, so it returns at most `maxDepth + 1` ids.
27
+ */
28
+ export async function getWorkflowRunLineage(runId: string, maxDepth: number): Promise<string[]> {
29
+ const rows = await getDbClient().query<{ workflowId: string }>(
30
+ `WITH RECURSIVE lineage(id, workflowId, parentStepId, depth) AS (
31
+ SELECT id, workflowId, parentStepId, 0 FROM workflow_runs WHERE id = ?
32
+ UNION ALL
33
+ SELECT r.id, r.workflowId, r.parentStepId, l.depth + 1
34
+ FROM lineage l
35
+ JOIN workflow_run_steps s ON s.id = l.parentStepId
36
+ JOIN workflow_runs r ON r.id = s.runId
37
+ WHERE l.depth < ?
38
+ )
39
+ SELECT workflowId FROM lineage ORDER BY depth`,
40
+ [runId, maxDepth],
41
+ );
42
+ return rows.map((row) => row.workflowId);
43
+ }
44
+
45
+ /** Waiting `sub-workflow` steps whose child run is terminal or deleted. */
46
+ export async function getSettledChildRunParentSteps(): Promise<string[]> {
47
+ const rows = await getDbClient().query<{ id: string }>(
48
+ `SELECT s.id
49
+ FROM workflow_run_steps s
50
+ LEFT JOIN workflow_runs c ON c.parentStepId = s.id
51
+ WHERE s.status = 'waiting' AND s.nodeType = 'sub-workflow'
52
+ AND (c.id IS NULL OR c.status IN ('completed', 'failed', 'cancelled', 'skipped'))`,
53
+ );
54
+ return rows.map((row) => row.id);
55
+ }
56
+
57
+ /**
58
+ * Before a workflow's runs are deleted, queue a wake-up for every step that
59
+ * started one of them as a child, so the step fails instead of waiting forever.
60
+ */
61
+ export async function wakeParentsOfDeletedRuns(workflowId: string): Promise<void> {
62
+ const rows = await getDbClient().query<{ id: string; parentStepId: string }>(
63
+ "SELECT id, parentStepId FROM workflow_runs WHERE workflowId = ? AND parentStepId IS NOT NULL",
64
+ [workflowId],
65
+ );
66
+ for (const row of rows) emitAfterCommit(row.id, row.parentStepId);
67
+ }
68
+
69
+ /**
70
+ * Wake the waiting `sub-workflow` step once its child run is terminal. Emitted
71
+ * after commit; a missed event is caught by the recovery sweep
72
+ * (`getSettledChildRunParentSteps`).
73
+ */
74
+ export function emitChildRunFinished(
75
+ childRunId: string,
76
+ parentStepId: string,
77
+ status: WorkflowRunStatus,
78
+ ): void {
79
+ if (TERMINAL_STATUSES.has(status)) emitAfterCommit(childRunId, parentStepId);
80
+ }
81
+
82
+ function emitAfterCommit(childRunId: string, parentStepId: string): void {
83
+ getDbClient().afterCommit(() => {
84
+ import("../../workflows/event-bus")
85
+ .then(({ workflowEventBus }) => {
86
+ workflowEventBus.emit("workflow.child.finished", { childRunId, parentStepId });
87
+ })
88
+ .catch((err) =>
89
+ console.error(
90
+ "[db] workflow.child.finished event not emitted:",
91
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
92
+ ),
93
+ );
94
+ });
95
+ }
@@ -0,0 +1,489 @@
1
+ import { getDbClient } from "../db";
2
+
3
+ /**
4
+ * Durable receipt store for inbound Slack deliveries.
5
+ *
6
+ * Admission is one atomic INSERT against a unique dedup key inside a
7
+ * `BEGIN IMMEDIATE` transaction, never a SELECT-then-INSERT or an in-memory map,
8
+ * so concurrent identical deliveries produce exactly one row and one drain job.
9
+ * The caller encrypts the payload; this module never sees plaintext.
10
+ *
11
+ * State machine:
12
+ * pending ──claim──▶ processing ──▶ processed | ignored | failed | uncertain
13
+ * ▲ │
14
+ * └── release (failure established to precede any side effect)
15
+ * processing ──(boot recovery after a crash)──▶ uncertain
16
+ *
17
+ * Retention never touches `pending`, `processing` or `uncertain` receipts.
18
+ */
19
+
20
+ export type SlackInboundTransport = "socket" | "http";
21
+ export type SlackInboundKind = "event" | "interaction" | "command";
22
+ export type SlackInboundState =
23
+ | "pending"
24
+ | "processing"
25
+ | "processed"
26
+ | "ignored"
27
+ | "failed"
28
+ | "uncertain";
29
+
30
+ export const SLACK_INBOUND_STATES: readonly SlackInboundState[] = [
31
+ "pending",
32
+ "processing",
33
+ "processed",
34
+ "ignored",
35
+ "failed",
36
+ "uncertain",
37
+ ];
38
+
39
+ /**
40
+ * Initial operational bounds. Sized from measured production traffic
41
+ * (2026-08-25 → 2026-09-24, Slack-sourced tasks): peak 4/min, 19/hour, 866 in
42
+ * 30 days, largest task body ~34 KB. Inbound events outnumber tasks (channel
43
+ * chatter that is filtered), so the backlog cap leaves ~50x headroom over the
44
+ * peak hour of task-producing traffic.
45
+ */
46
+ export const SLACK_INBOUND_LIMITS = {
47
+ /** Pending + processing receipts before admission answers "overloaded". */
48
+ maxBacklogReceipts: 1_000,
49
+ /** Plaintext bytes held by pending + processing receipts. */
50
+ maxBacklogBytes: 32 * 1024 * 1024,
51
+ /** A single delivery larger than this is refused outright. */
52
+ maxPayloadBytes: 1024 * 1024,
53
+ /** How long a completed dedup key is kept (covers Slack's retry schedule). */
54
+ completedRetentionMs: 48 * 60 * 60 * 1000,
55
+ /** Claims before a failure that preceded side effects becomes terminal. */
56
+ maxAttempts: 3,
57
+ /** Delay before a released receipt can be claimed again, per attempt. */
58
+ retryBackoffMs: 30_000,
59
+ } as const;
60
+
61
+ export type SlackInboundLimits = typeof SLACK_INBOUND_LIMITS;
62
+
63
+ export interface SlackInboundReceipt {
64
+ id: string;
65
+ dedupKey: string;
66
+ transport: SlackInboundTransport;
67
+ kind: SlackInboundKind;
68
+ payloadType: string;
69
+ apiAppId: string | null;
70
+ eventId: string | null;
71
+ payloadCiphertext: string | null;
72
+ payloadBytes: number;
73
+ retryNum: number | null;
74
+ retryReason: string | null;
75
+ state: SlackInboundState;
76
+ attempts: number;
77
+ duplicateCount: number;
78
+ outcomeCode: string | null;
79
+ errorCode: string | null;
80
+ receivedAt: string;
81
+ availableAt: string;
82
+ claimedAt: string | null;
83
+ completedAt: string | null;
84
+ lastDuplicateAt: string | null;
85
+ payloadErasedAt: string | null;
86
+ }
87
+
88
+ interface SlackInboundReceiptRow {
89
+ id: string;
90
+ dedup_key: string;
91
+ transport: SlackInboundTransport;
92
+ kind: SlackInboundKind;
93
+ payload_type: string;
94
+ api_app_id: string | null;
95
+ event_id: string | null;
96
+ payload_ciphertext: string | null;
97
+ payload_bytes: number;
98
+ retry_num: number | null;
99
+ retry_reason: string | null;
100
+ state: SlackInboundState;
101
+ attempts: number;
102
+ duplicate_count: number;
103
+ outcome_code: string | null;
104
+ error_code: string | null;
105
+ received_at: string;
106
+ available_at: string;
107
+ claimed_at: string | null;
108
+ completed_at: string | null;
109
+ last_duplicate_at: string | null;
110
+ payload_erased_at: string | null;
111
+ }
112
+
113
+ function toReceipt(row: SlackInboundReceiptRow): SlackInboundReceipt {
114
+ return {
115
+ id: row.id,
116
+ dedupKey: row.dedup_key,
117
+ transport: row.transport,
118
+ kind: row.kind,
119
+ payloadType: row.payload_type,
120
+ apiAppId: row.api_app_id,
121
+ eventId: row.event_id,
122
+ payloadCiphertext: row.payload_ciphertext,
123
+ payloadBytes: row.payload_bytes,
124
+ retryNum: row.retry_num,
125
+ retryReason: row.retry_reason,
126
+ state: row.state,
127
+ attempts: row.attempts,
128
+ duplicateCount: row.duplicate_count,
129
+ outcomeCode: row.outcome_code,
130
+ errorCode: row.error_code,
131
+ receivedAt: row.received_at,
132
+ availableAt: row.available_at,
133
+ claimedAt: row.claimed_at,
134
+ completedAt: row.completed_at,
135
+ lastDuplicateAt: row.last_duplicate_at,
136
+ payloadErasedAt: row.payload_erased_at,
137
+ };
138
+ }
139
+
140
+ export interface AdmitSlackInboundReceiptInput {
141
+ dedupKey: string;
142
+ transport: SlackInboundTransport;
143
+ kind: SlackInboundKind;
144
+ payloadType: string;
145
+ apiAppId?: string | null;
146
+ eventId?: string | null;
147
+ payloadCiphertext: string;
148
+ payloadBytes: number;
149
+ retryNum?: number | null;
150
+ retryReason?: string | null;
151
+ }
152
+
153
+ export type SlackInboundAdmission =
154
+ | { status: "admitted"; receipt: SlackInboundReceipt }
155
+ | { status: "duplicate"; receipt: SlackInboundReceipt }
156
+ | { status: "rejected"; reason: "payload_too_large" | "backlog_full" };
157
+
158
+ /**
159
+ * Admit a delivery. A duplicate of a stored key is always acknowledged, even
160
+ * under overload: it needs no new work. A new key is refused when the backlog
161
+ * is full so the transport can answer 503 and Slack retries later.
162
+ */
163
+ export async function admitSlackInboundReceipt(
164
+ input: AdmitSlackInboundReceiptInput,
165
+ now: Date = new Date(),
166
+ limits: Pick<
167
+ SlackInboundLimits,
168
+ "maxBacklogReceipts" | "maxBacklogBytes" | "maxPayloadBytes"
169
+ > = SLACK_INBOUND_LIMITS,
170
+ ): Promise<SlackInboundAdmission> {
171
+ const nowIso = now.toISOString();
172
+ return getDbClient().transaction(async (tx) => {
173
+ const duplicate = await tx.get<SlackInboundReceiptRow>(
174
+ `UPDATE slack_inbound_receipts
175
+ SET duplicate_count = duplicate_count + 1, last_duplicate_at = ?, updated_at = ?
176
+ WHERE dedup_key = ?
177
+ RETURNING *`,
178
+ [nowIso, nowIso, input.dedupKey],
179
+ );
180
+ if (duplicate) return { status: "duplicate", receipt: toReceipt(duplicate) };
181
+
182
+ if (input.payloadBytes > limits.maxPayloadBytes) {
183
+ return { status: "rejected", reason: "payload_too_large" };
184
+ }
185
+
186
+ const backlog = await tx.get<{ receipts: number; bytes: number | null }>(
187
+ `SELECT COUNT(*) AS receipts, SUM(payload_bytes) AS bytes
188
+ FROM slack_inbound_receipts
189
+ WHERE state IN ('pending', 'processing')`,
190
+ );
191
+ const receipts = backlog?.receipts ?? 0;
192
+ const bytes = backlog?.bytes ?? 0;
193
+ if (
194
+ receipts >= limits.maxBacklogReceipts ||
195
+ bytes + input.payloadBytes > limits.maxBacklogBytes
196
+ ) {
197
+ return { status: "rejected", reason: "backlog_full" };
198
+ }
199
+
200
+ const inserted = await tx.get<SlackInboundReceiptRow>(
201
+ `INSERT INTO slack_inbound_receipts (
202
+ id, dedup_key, transport, kind, payload_type, api_app_id, event_id,
203
+ payload_ciphertext, payload_bytes, retry_num, retry_reason,
204
+ received_at, available_at, created_at, updated_at
205
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
206
+ ON CONFLICT(dedup_key) DO NOTHING
207
+ RETURNING *`,
208
+ [
209
+ crypto.randomUUID(),
210
+ input.dedupKey,
211
+ input.transport,
212
+ input.kind,
213
+ input.payloadType,
214
+ input.apiAppId ?? null,
215
+ input.eventId ?? null,
216
+ input.payloadCiphertext,
217
+ input.payloadBytes,
218
+ input.retryNum ?? null,
219
+ input.retryReason ?? null,
220
+ nowIso,
221
+ nowIso,
222
+ nowIso,
223
+ nowIso,
224
+ ],
225
+ );
226
+ if (inserted) return { status: "admitted", receipt: toReceipt(inserted) };
227
+
228
+ // Unreachable under BEGIN IMMEDIATE, kept so a future deferred caller still
229
+ // reports the winner instead of a phantom admission.
230
+ const winner = await tx.get<SlackInboundReceiptRow>(
231
+ "SELECT * FROM slack_inbound_receipts WHERE dedup_key = ?",
232
+ [input.dedupKey],
233
+ );
234
+ if (!winner) throw new Error("Slack inbound receipt vanished during admission");
235
+ return { status: "duplicate", receipt: toReceipt(winner) };
236
+ });
237
+ }
238
+
239
+ /**
240
+ * Atomically move one claimable pending receipt to `processing`. Without an id
241
+ * the oldest available receipt is claimed. Returns null when nothing is
242
+ * claimable (or the named receipt was claimed by someone else).
243
+ */
244
+ export async function claimSlackInboundReceipt(
245
+ now: Date = new Date(),
246
+ id?: string,
247
+ ): Promise<SlackInboundReceipt | null> {
248
+ const nowIso = now.toISOString();
249
+ const row = id
250
+ ? await getDbClient().get<SlackInboundReceiptRow>(
251
+ `UPDATE slack_inbound_receipts
252
+ SET state = 'processing', attempts = attempts + 1, claimed_at = ?, updated_at = ?
253
+ WHERE id = ? AND state = 'pending' AND available_at <= ?
254
+ RETURNING *`,
255
+ [nowIso, nowIso, id, nowIso],
256
+ )
257
+ : await getDbClient().get<SlackInboundReceiptRow>(
258
+ `UPDATE slack_inbound_receipts
259
+ SET state = 'processing', attempts = attempts + 1, claimed_at = ?, updated_at = ?
260
+ WHERE id = (
261
+ SELECT id FROM slack_inbound_receipts
262
+ WHERE state = 'pending' AND available_at <= ?
263
+ ORDER BY received_at, id
264
+ LIMIT 1
265
+ )
266
+ AND state = 'pending'
267
+ RETURNING *`,
268
+ [nowIso, nowIso, nowIso],
269
+ );
270
+ return row ? toReceipt(row) : null;
271
+ }
272
+
273
+ export type SlackInboundTerminalState = "processed" | "ignored" | "failed" | "uncertain";
274
+
275
+ /**
276
+ * Record the outcome of a claimed receipt. Processed and ignored receipts
277
+ * erase their payload immediately; failed and uncertain ones keep it for
278
+ * operator review. Only a receipt still in `processing` is updated.
279
+ */
280
+ export async function completeSlackInboundReceipt(
281
+ id: string,
282
+ outcome: {
283
+ state: SlackInboundTerminalState;
284
+ outcomeCode?: string | null;
285
+ errorCode?: string | null;
286
+ },
287
+ now: Date = new Date(),
288
+ ): Promise<boolean> {
289
+ const nowIso = now.toISOString();
290
+ const erase = outcome.state === "processed" || outcome.state === "ignored";
291
+ const { changes } = await getDbClient().run(
292
+ `UPDATE slack_inbound_receipts
293
+ SET state = ?, outcome_code = ?, error_code = ?, completed_at = ?, updated_at = ?,
294
+ payload_ciphertext = CASE WHEN ? THEN NULL ELSE payload_ciphertext END,
295
+ payload_erased_at = CASE WHEN ? THEN ? ELSE payload_erased_at END
296
+ WHERE id = ? AND state = 'processing'`,
297
+ [
298
+ outcome.state,
299
+ outcome.outcomeCode ?? null,
300
+ outcome.errorCode ?? null,
301
+ outcome.state === "uncertain" ? null : nowIso,
302
+ nowIso,
303
+ erase ? 1 : 0,
304
+ erase ? 1 : 0,
305
+ nowIso,
306
+ id,
307
+ ],
308
+ );
309
+ return changes > 0;
310
+ }
311
+
312
+ /**
313
+ * Return a claimed receipt to `pending` after a failure established to precede
314
+ * any side effect. It becomes claimable again at `availableAt`.
315
+ */
316
+ export async function releaseSlackInboundReceipt(
317
+ id: string,
318
+ errorCode: string,
319
+ availableAt: Date,
320
+ now: Date = new Date(),
321
+ ): Promise<boolean> {
322
+ const nowIso = now.toISOString();
323
+ const { changes } = await getDbClient().run(
324
+ `UPDATE slack_inbound_receipts
325
+ SET state = 'pending', error_code = ?, available_at = ?, claimed_at = NULL, updated_at = ?
326
+ WHERE id = ? AND state = 'processing'`,
327
+ [errorCode, availableAt.toISOString(), nowIso, id],
328
+ );
329
+ return changes > 0;
330
+ }
331
+
332
+ /** Earliest `available_at` among pending receipts, or null when none wait. */
333
+ export async function getNextSlackInboundAvailableAt(): Promise<Date | null> {
334
+ const row = await getDbClient().get<{ next: string | null }>(
335
+ `SELECT MIN(available_at) AS next FROM slack_inbound_receipts WHERE state = 'pending'`,
336
+ );
337
+ return row?.next ? new Date(row.next) : null;
338
+ }
339
+
340
+ /**
341
+ * Boot recovery: a receipt still `processing` belonged to a process that died
342
+ * mid-handler. Its side effects are unknown, so it becomes `uncertain` and is
343
+ * never replayed automatically. Call before the drain starts.
344
+ */
345
+ export async function markInterruptedSlackInboundReceiptsUncertain(
346
+ now: Date = new Date(),
347
+ ): Promise<number> {
348
+ const nowIso = now.toISOString();
349
+ const { changes } = await getDbClient().run(
350
+ `UPDATE slack_inbound_receipts
351
+ SET state = 'uncertain', error_code = 'interrupted_during_processing', updated_at = ?
352
+ WHERE state = 'processing'`,
353
+ [nowIso],
354
+ );
355
+ return changes;
356
+ }
357
+
358
+ /**
359
+ * Retention: erase any payload a completed receipt still holds, and delete
360
+ * completed receipts (with their dedup key) once the retention window passes.
361
+ * Pending, processing and uncertain receipts are never touched.
362
+ */
363
+ export async function purgeSlackInboundReceipts(
364
+ now: Date = new Date(),
365
+ retentionMs: number = SLACK_INBOUND_LIMITS.completedRetentionMs,
366
+ ): Promise<{ erasedPayloads: number; deletedReceipts: number }> {
367
+ const nowIso = now.toISOString();
368
+ const cutoff = new Date(now.getTime() - retentionMs).toISOString();
369
+ return getDbClient().transaction(async (tx) => {
370
+ const deleted = await tx.run(
371
+ `DELETE FROM slack_inbound_receipts
372
+ WHERE state IN ('processed', 'ignored', 'failed') AND completed_at <= ?`,
373
+ [cutoff],
374
+ );
375
+ const erased = await tx.run(
376
+ `UPDATE slack_inbound_receipts
377
+ SET payload_ciphertext = NULL, payload_erased_at = ?, updated_at = ?
378
+ WHERE state IN ('processed', 'ignored') AND payload_ciphertext IS NOT NULL`,
379
+ [nowIso, nowIso],
380
+ );
381
+ return { erasedPayloads: erased.changes, deletedReceipts: deleted.changes };
382
+ });
383
+ }
384
+
385
+ export async function getSlackInboundReceiptById(id: string): Promise<SlackInboundReceipt | null> {
386
+ const row = await getDbClient().get<SlackInboundReceiptRow>(
387
+ "SELECT * FROM slack_inbound_receipts WHERE id = ?",
388
+ [id],
389
+ );
390
+ return row ? toReceipt(row) : null;
391
+ }
392
+
393
+ export async function getSlackInboundReceiptByKey(
394
+ dedupKey: string,
395
+ ): Promise<SlackInboundReceipt | null> {
396
+ const row = await getDbClient().get<SlackInboundReceiptRow>(
397
+ "SELECT * FROM slack_inbound_receipts WHERE dedup_key = ?",
398
+ [dedupKey],
399
+ );
400
+ return row ? toReceipt(row) : null;
401
+ }
402
+
403
+ export interface SlackInboundReceiptStats {
404
+ counts: Record<SlackInboundState, number>;
405
+ backlogBytes: number;
406
+ duplicateDeliveries: number;
407
+ oldestPendingReceivedAt: string | null;
408
+ oldestUncertainReceivedAt: string | null;
409
+ lastReceivedAt: string | null;
410
+ lastFailure: { errorCode: string | null; at: string } | null;
411
+ /** Uncertain receipts, newest first, for operator review. No payloads. */
412
+ uncertain: Array<{
413
+ id: string;
414
+ kind: SlackInboundKind;
415
+ payloadType: string;
416
+ eventId: string | null;
417
+ errorCode: string | null;
418
+ receivedAt: string;
419
+ attempts: number;
420
+ }>;
421
+ }
422
+
423
+ export async function getSlackInboundReceiptStats(
424
+ uncertainLimit = 20,
425
+ ): Promise<SlackInboundReceiptStats> {
426
+ const db = getDbClient();
427
+ return db.transaction(
428
+ async (tx) => {
429
+ const counts = Object.fromEntries(SLACK_INBOUND_STATES.map((s) => [s, 0])) as Record<
430
+ SlackInboundState,
431
+ number
432
+ >;
433
+ const byState = await tx.query<{ state: SlackInboundState; n: number; oldest: string }>(
434
+ `SELECT state, COUNT(*) AS n, MIN(received_at) AS oldest
435
+ FROM slack_inbound_receipts GROUP BY state`,
436
+ );
437
+ let oldestPending: string | null = null;
438
+ let oldestUncertain: string | null = null;
439
+ for (const row of byState) {
440
+ counts[row.state] = row.n;
441
+ if (row.state === "pending") oldestPending = row.oldest;
442
+ if (row.state === "uncertain") oldestUncertain = row.oldest;
443
+ }
444
+ const totals = await tx.get<{
445
+ backlogBytes: number | null;
446
+ duplicates: number | null;
447
+ lastReceivedAt: string | null;
448
+ }>(
449
+ `SELECT
450
+ SUM(CASE WHEN state IN ('pending', 'processing') THEN payload_bytes ELSE 0 END)
451
+ AS backlogBytes,
452
+ SUM(duplicate_count) AS duplicates,
453
+ MAX(received_at) AS lastReceivedAt
454
+ FROM slack_inbound_receipts`,
455
+ );
456
+ const lastFailure = await tx.get<{ error_code: string | null; updated_at: string }>(
457
+ `SELECT error_code, updated_at FROM slack_inbound_receipts
458
+ WHERE error_code IS NOT NULL
459
+ ORDER BY updated_at DESC LIMIT 1`,
460
+ );
461
+ const uncertain = await tx.query<SlackInboundReceiptRow>(
462
+ `SELECT * FROM slack_inbound_receipts WHERE state = 'uncertain'
463
+ ORDER BY received_at DESC LIMIT ?`,
464
+ [uncertainLimit],
465
+ );
466
+ return {
467
+ counts,
468
+ backlogBytes: totals?.backlogBytes ?? 0,
469
+ duplicateDeliveries: totals?.duplicates ?? 0,
470
+ oldestPendingReceivedAt: oldestPending,
471
+ oldestUncertainReceivedAt: oldestUncertain,
472
+ lastReceivedAt: totals?.lastReceivedAt ?? null,
473
+ lastFailure: lastFailure
474
+ ? { errorCode: lastFailure.error_code, at: lastFailure.updated_at }
475
+ : null,
476
+ uncertain: uncertain.map((row) => ({
477
+ id: row.id,
478
+ kind: row.kind,
479
+ payloadType: row.payload_type,
480
+ eventId: row.event_id,
481
+ errorCode: row.error_code,
482
+ receivedAt: row.received_at,
483
+ attempts: row.attempts,
484
+ })),
485
+ };
486
+ },
487
+ { readOnly: true },
488
+ );
489
+ }
package/src/be/db.ts CHANGED
@@ -198,6 +198,7 @@ import {
198
198
  rowToAgentTaskSummary,
199
199
  } from "./db/tasks/read";
200
200
  import { configureTaskWriteDependencies, failTask } from "./db/tasks/write";
201
+ import { emitChildRunFinished, wakeParentsOfDeletedRuns } from "./db/workflow-runs";
201
202
  import { scrubJsonValue } from "./scrub-json";
202
203
  import { openSealedJson, sealedJsonForDisplay, sealJson } from "./sealed-json";
203
204
  import { configSecretName, registerStoredSecret } from "./secret-registry";
@@ -7614,7 +7615,8 @@ async function deleteWorkflowRows(id: string, source?: "api" | "mcp"): Promise<b
7614
7615
  `UPDATE agent_tasks SET workflowRunId = NULL, workflowRunStepId = NULL WHERE workflowRunId IN (SELECT id FROM workflow_runs WHERE workflowId = ?)`,
7615
7616
  [id],
7616
7617
  );
7617
- // 2. Delete steps (they reference runs)
7618
+ // 2. Fail the parent steps of child runs about to go, then delete steps
7619
+ await wakeParentsOfDeletedRuns(id);
7618
7620
  await client.run(
7619
7621
  `DELETE FROM workflow_run_steps WHERE runId IN (SELECT id FROM workflow_runs WHERE workflowId = ?)`,
7620
7622
  [id],
@@ -7673,6 +7675,8 @@ type WorkflowRunRow = {
7673
7675
  startedAt: string;
7674
7676
  lastUpdatedAt: string;
7675
7677
  finishedAt: string | null;
7678
+ /** The `sub-workflow` step that started this run (migration 203). */
7679
+ parentStepId?: string | null;
7676
7680
  };
7677
7681
 
7678
7682
  /**
@@ -7705,10 +7709,11 @@ export async function createWorkflowRun(data: {
7705
7709
  triggerType?: "schedule" | "manual" | "event" | "api";
7706
7710
  triggerData?: unknown;
7707
7711
  createdBy?: string;
7712
+ parentStepId?: string;
7708
7713
  }): Promise<WorkflowRun> {
7709
7714
  const now = new Date().toISOString();
7710
7715
  const row = await getDbClient().get<WorkflowRunRow>(
7711
- `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by) VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
7716
+ `INSERT INTO workflow_runs (id, workflowId, triggerType, startedAt, triggerData, created_by, parentStepId) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *`,
7712
7717
  [
7713
7718
  data.id,
7714
7719
  data.workflowId,
@@ -7716,6 +7721,7 @@ export async function createWorkflowRun(data: {
7716
7721
  now,
7717
7722
  data.triggerData ? scrubJsonValue(data.triggerData) : null,
7718
7723
  data.createdBy ?? null,
7724
+ data.parentStepId ?? null,
7719
7725
  ],
7720
7726
  );
7721
7727
  if (!row) throw new Error("Failed to create workflow run");
@@ -7804,6 +7810,7 @@ export async function updateWorkflowRun(
7804
7810
  if (data.status === "completed" || data.status === "failed") {
7805
7811
  emitWorkflowTerminalTelemetry(run);
7806
7812
  }
7813
+ if (row.parentStepId) emitChildRunFinished(id, row.parentStepId, run.status);
7807
7814
  return run;
7808
7815
  }
7809
7816
 
@@ -694,7 +694,7 @@ const ADAPTERS: { [K in ExtensionAssetKind]: Adapter<Extract<Plan, { kind: K }>>
694
694
  };
695
695
 
696
696
  function adapterFor(kind: ExtensionAssetKind): Adapter<Plan> {
697
- return ADAPTERS[kind] as unknown as Adapter<Plan>;
697
+ return ADAPTERS[kind] as Adapter<Plan>;
698
698
  }
699
699
 
700
700
  /** Scripts first (schedules and swarm-script nodes call them), skills last. */
@@ -0,0 +1,10 @@
1
+ -- Link a child workflow run to the `sub-workflow` step that started it.
2
+ --
3
+ -- The parent step waits until this run is terminal. The unique index makes a
4
+ -- step own at most one child run, so a restart reconnects to the existing
5
+ -- child instead of starting a second one.
6
+
7
+ ALTER TABLE workflow_runs ADD COLUMN parentStepId TEXT;
8
+
9
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workflow_runs_parentStepId
10
+ ON workflow_runs(parentStepId) WHERE parentStepId IS NOT NULL;