@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,135 @@
1
+ import { z } from "zod";
2
+ import { getChildWorkflowRunId, getWorkflowRunLineage } from "../../be/db/workflow-runs";
3
+ import type { ExecutorMeta, WorkflowRun } from "../../types";
4
+ import { startWorkflowExecution } from "../engine";
5
+ import type { ExecutorDependencies, ExecutorResult } from "./base";
6
+ import { BaseExecutor } from "./base";
7
+ import type { ExecutorRegistry } from "./registry";
8
+
9
+ // ─── Config / Output Schemas ────────────────────────────────
10
+
11
+ const SubWorkflowConfigSchema = z.object({
12
+ workflowId: z.string().min(1).describe("Workflow to run as a child"),
13
+ inputs: z
14
+ .record(z.string(), z.unknown())
15
+ .optional()
16
+ .describe("Trigger data for the child run, validated against its triggerSchema"),
17
+ });
18
+
19
+ const SubWorkflowOutputSchema = z.object({
20
+ runId: z.string(),
21
+ /** The child's per-node outputs, keyed by node id. */
22
+ outputs: z.record(z.string(), z.unknown()),
23
+ /** Set when the child completed with a partial failure. */
24
+ error: z.string().optional(),
25
+ });
26
+
27
+ type SubWorkflowOutput = z.infer<typeof SubWorkflowOutputSchema>;
28
+
29
+ /** Deepest child run a root run may start, counting the root as depth 0. */
30
+ export const MAX_SUB_WORKFLOW_DEPTH = 16;
31
+
32
+ /** Run-context keys that are not node outputs. `input` can hold resolved secrets. */
33
+ const NON_NODE_CONTEXT_KEYS = new Set(["trigger", "input", "swarm", "workflow"]);
34
+
35
+ /**
36
+ * The parent step's outcome for a child run: its result once the child
37
+ * completed, an error once it failed, was cancelled or was skipped, and null
38
+ * while it is still running or waiting.
39
+ */
40
+ export function childRunOutcome(
41
+ child: WorkflowRun,
42
+ ): { output: SubWorkflowOutput } | { error: string } | null {
43
+ if (child.status === "running" || child.status === "waiting") return null;
44
+ if (child.status !== "completed") {
45
+ const reason = child.error ? `: ${child.error}` : "";
46
+ return { error: `Child workflow run ${child.id} ${child.status}${reason}` };
47
+ }
48
+ const outputs: Record<string, unknown> = {};
49
+ for (const [key, value] of Object.entries(child.context ?? {})) {
50
+ if (!NON_NODE_CONTEXT_KEYS.has(key)) outputs[key] = value;
51
+ }
52
+ return { output: { runId: child.id, outputs, error: child.error } };
53
+ }
54
+
55
+ // ─── Executor ───────────────────────────────────────────────
56
+
57
+ /**
58
+ * Runs another workflow as a child run and waits for it. The child run records
59
+ * this step as its parent, so a re-executed step reconnects to it instead of
60
+ * starting a second child. The resume path is `resumeFromChildRun`.
61
+ */
62
+ export class SubWorkflowExecutor extends BaseExecutor<
63
+ typeof SubWorkflowConfigSchema,
64
+ typeof SubWorkflowOutputSchema
65
+ > {
66
+ readonly type = "sub-workflow";
67
+ readonly mode = "async" as const;
68
+ readonly configSchema = SubWorkflowConfigSchema;
69
+ readonly outputSchema = SubWorkflowOutputSchema;
70
+
71
+ constructor(
72
+ deps: ExecutorDependencies,
73
+ private readonly registry: ExecutorRegistry,
74
+ ) {
75
+ super(deps);
76
+ }
77
+
78
+ protected async execute(
79
+ config: z.infer<typeof SubWorkflowConfigSchema>,
80
+ _context: Readonly<Record<string, unknown>>,
81
+ meta: ExecutorMeta,
82
+ ): Promise<ExecutorResult<SubWorkflowOutput>> {
83
+ const { db } = this.deps;
84
+
85
+ const existingId = await getChildWorkflowRunId(meta.stepId);
86
+ const existing = existingId ? await db.getWorkflowRun(existingId) : null;
87
+ if (existing) return this.settle(existing);
88
+
89
+ // Capped at the max depth, so a long chain fails closed instead of
90
+ // hiding a cycle past the end of the scan.
91
+ const lineage = await getWorkflowRunLineage(meta.runId, MAX_SUB_WORKFLOW_DEPTH);
92
+ if (lineage.length > MAX_SUB_WORKFLOW_DEPTH) {
93
+ return {
94
+ status: "failed",
95
+ error: `Sub-workflow nesting exceeds ${MAX_SUB_WORKFLOW_DEPTH} levels`,
96
+ };
97
+ }
98
+ if (config.workflowId === meta.workflowId || lineage.includes(config.workflowId)) {
99
+ return {
100
+ status: "failed",
101
+ error: `Sub-workflow recursion: workflow ${config.workflowId} is already running in this run's ancestry`,
102
+ };
103
+ }
104
+
105
+ const workflow = await db.getWorkflow(config.workflowId);
106
+ if (!workflow) return { status: "failed", error: `Workflow ${config.workflowId} not found` };
107
+ if (!workflow.enabled) {
108
+ return { status: "failed", error: `Workflow ${config.workflowId} is disabled` };
109
+ }
110
+
111
+ // Returns once the child run exists; the child walks on its own. A child
112
+ // that already ended (cooldown, readiness, input failure) settles here.
113
+ const childRunId = await startWorkflowExecution(workflow, config.inputs ?? {}, this.registry, {
114
+ requestedByUserId: meta.requestedByUserId,
115
+ parentStepId: meta.stepId,
116
+ });
117
+ const child = await db.getWorkflowRun(childRunId);
118
+ if (!child) return { status: "failed", error: `Child workflow run ${childRunId} not found` };
119
+ return this.settle(child);
120
+ }
121
+
122
+ private settle(child: WorkflowRun): ExecutorResult<SubWorkflowOutput> {
123
+ const outcome = childRunOutcome(child);
124
+ if (!outcome) {
125
+ return {
126
+ status: "success",
127
+ async: true,
128
+ waitFor: "workflow.child.finished",
129
+ correlationId: child.id,
130
+ } as ExecutorResult<SubWorkflowOutput>;
131
+ }
132
+ if ("error" in outcome) return { status: "failed", error: outcome.error };
133
+ return { status: "success", output: outcome.output, nextPort: "success" };
134
+ }
135
+ }
@@ -93,7 +93,7 @@ export class WaitExecutor extends BaseExecutor<typeof WaitConfigSchema, typeof W
93
93
  async: true,
94
94
  waitFor: "wait.fired",
95
95
  correlationId: existing.id,
96
- } as unknown as ExecutorResult<WaitOutput>;
96
+ } as ExecutorResult<WaitOutput>;
97
97
  }
98
98
 
99
99
  // 2. Mode-specific creation.
@@ -113,7 +113,7 @@ export class WaitExecutor extends BaseExecutor<typeof WaitConfigSchema, typeof W
113
113
  async: true,
114
114
  waitFor: "wait.fired",
115
115
  correlationId: waitId,
116
- } as unknown as ExecutorResult<WaitOutput>;
116
+ } as ExecutorResult<WaitOutput>;
117
117
  }
118
118
 
119
119
  // Event mode: validate the filter at executor-init time (so a bad workflow
@@ -149,7 +149,7 @@ export class WaitExecutor extends BaseExecutor<typeof WaitConfigSchema, typeof W
149
149
  async: true,
150
150
  waitFor: "wait.fired",
151
151
  correlationId: waitId,
152
- } as unknown as ExecutorResult<WaitOutput>;
152
+ } as ExecutorResult<WaitOutput>;
153
153
  }
154
154
  }
155
155
 
@@ -12,6 +12,7 @@ import {
12
12
  resolveApprovalRequest,
13
13
  updateWorkflowRun,
14
14
  } from "../be/db";
15
+ import { getSettledChildRunParentSteps } from "../be/db/workflow-runs";
15
16
  import type { WorkflowRunStep } from "../types";
16
17
  import { shapeApprovalResolution } from "./approval-resolution";
17
18
  import { loadCompletedStepRouting } from "./completed-step-routing";
@@ -24,7 +25,7 @@ import {
24
25
  } from "./engine";
25
26
  import type { ExecutorRegistry } from "./executors/registry";
26
27
  import { getSecretInputKeys } from "./input";
27
- import { finalizeOrWait, resumeWaitState } from "./resume";
28
+ import { finalizeOrWait, resumeFromChildRun, resumeWaitState } from "./resume";
28
29
  import {
29
30
  checkpointPortStepAndResolveSuccessors,
30
31
  completeTaskStepAndResolveSuccessors,
@@ -56,6 +57,15 @@ export async function recoverIncompleteRuns(registry: ExecutorRegistry): Promise
56
57
  // --- Case 4: Waiting runs whose wait_states are overdue or already resolved ---
57
58
  recovered += await recoverWaitStates(registry);
58
59
 
60
+ // --- Case 5: Waiting sub-workflow steps whose child run already finished ---
61
+ for (const parentStepId of await getSettledChildRunParentSteps()) {
62
+ try {
63
+ if (await resumeFromChildRun(parentStepId, registry)) recovered++;
64
+ } catch (err) {
65
+ console.error(`[workflows] Failed to resume sub-workflow step ${parentStepId}:`, err);
66
+ }
67
+ }
68
+
59
69
  if (recovered > 0) {
60
70
  console.log(`[workflows] Recovered ${recovered} incomplete run(s)`);
61
71
  }
@@ -17,6 +17,7 @@ import {
17
17
  updateWorkflowRun,
18
18
  updateWorkflowRunStep,
19
19
  } from "../be/db";
20
+ import { getChildWorkflowRunId } from "../be/db/workflow-runs";
20
21
  import type { WaitStateRow } from "../types";
21
22
  import { scrubSecrets } from "../utils/secret-scrubber";
22
23
  import {
@@ -31,6 +32,7 @@ import { failRunOnUnreadableReplay, findReadyNodes, hasRunningStep, walkGraph }
31
32
  import type { WorkflowEventBus } from "./event-bus";
32
33
  import { workflowEventBus } from "./event-bus";
33
34
  import type { ExecutorRegistry } from "./executors/registry";
35
+ import { childRunOutcome } from "./executors/sub-workflow";
34
36
  import { computeNextPort } from "./executors/wait";
35
37
  import { resolveForeachParent } from "./foreach-join";
36
38
  import { getSecretInputKeys } from "./input";
@@ -120,11 +122,23 @@ export function setupWorkflowResumeListener(
120
122
  };
121
123
  eventBus.on("approval.resolved", onApprovalResolved);
122
124
 
125
+ const onChildRunFinished = async (data: unknown) => {
126
+ try {
127
+ const { parentStepId } = data as { parentStepId?: string };
128
+ if (!parentStepId) return;
129
+ await resumeFromChildRun(parentStepId, registry);
130
+ } catch (err) {
131
+ console.error("[workflows] Resume from child workflow run failed:", err);
132
+ }
133
+ };
134
+ eventBus.on("workflow.child.finished", onChildRunFinished);
135
+
123
136
  return () => {
124
137
  eventBus.off("task.completed", onTaskCompleted);
125
138
  eventBus.off("task.failed", onTaskFailed);
126
139
  eventBus.off("task.cancelled", onTaskCancelled);
127
140
  eventBus.off("approval.resolved", onApprovalResolved);
141
+ eventBus.off("workflow.child.finished", onChildRunFinished);
128
142
  };
129
143
  }
130
144
 
@@ -765,6 +779,69 @@ async function resumeClaimedWait(
765
779
  }
766
780
  }
767
781
 
782
+ /**
783
+ * Resume a waiting `sub-workflow` step once its child run is terminal: a
784
+ * completed child completes the step, any other end (or a deleted child) fails
785
+ * the step and its run. Returns true when this call claimed the step. Safe to call repeatedly:
786
+ * the live event and the recovery sweep both route here.
787
+ */
788
+ export async function resumeFromChildRun(
789
+ parentStepId: string,
790
+ registry: ExecutorRegistry,
791
+ ): Promise<boolean> {
792
+ const step = await getWorkflowRunStep(parentStepId);
793
+ if (!step || step.status !== "waiting" || step.nodeType !== "sub-workflow") return false;
794
+ const childId = await getChildWorkflowRunId(parentStepId);
795
+ const child = childId ? await getWorkflowRun(childId) : null;
796
+ // The child row exists before the step parks, so a missing one was deleted.
797
+ const outcome = child
798
+ ? childRunOutcome(child)
799
+ : { error: `Child workflow run of step ${parentStepId} was deleted` };
800
+ if (!outcome) return false;
801
+
802
+ let claimed = false;
803
+ await failClosedOnUnreadableReplay(step.runId, step.id, async () => {
804
+ const run = await getWorkflowRun(step.runId);
805
+ if (!run || (run.status !== "waiting" && run.status !== "running")) return;
806
+ const workflow = await getWorkflow(run.workflowId);
807
+ if (!workflow) return;
808
+
809
+ if ("error" in outcome) {
810
+ claimed = await failStepAndRunIfWaiting(step.id, run.id, outcome.error);
811
+ return;
812
+ }
813
+
814
+ const ctx = (run.context ?? {}) as Record<string, unknown>;
815
+ const routing = await checkpointPortStepAndResolveSuccessors(
816
+ workflow.definition,
817
+ run.id,
818
+ step.id,
819
+ step.nodeId,
820
+ outcome.output,
821
+ "success",
822
+ ctx,
823
+ );
824
+ if (!routing.claimed) return;
825
+ claimed = true;
826
+
827
+ if (routing.successors.length > 0) {
828
+ const secretKeys = getSecretInputKeys(workflow.input);
829
+ await walkGraph(
830
+ workflow.definition,
831
+ run.id,
832
+ ctx,
833
+ routing.successors,
834
+ registry,
835
+ workflow.id,
836
+ secretKeys,
837
+ );
838
+ } else {
839
+ await finalizeOrWait(run.id);
840
+ }
841
+ });
842
+ return claimed;
843
+ }
844
+
768
845
  // ─── 64KB firedPayload cap ──────────────────────────────────────────────────
769
846
 
770
847
  const FIRED_PAYLOAD_BYTE_CAP = 64 * 1024; // 64KB
@@ -151,15 +151,13 @@ async function runWithTimeout<T>(fn: () => T): Promise<T | null> {
151
151
  // the user fn doesn't crash the runtime) and resolve to a sentinel
152
152
  // that the caller treats as "no-match". We wrap in a marker object
153
153
  // so the outer `await` can't unwrap a Thenable for us.
154
- if (
155
- v !== null &&
156
- typeof v === "object" &&
157
- typeof (v as { then?: unknown }).then === "function"
158
- ) {
159
- (v as unknown as Promise<unknown>).catch(() => {});
154
+ const then =
155
+ v !== null && typeof v === "object" ? (v as { then?: unknown }).then : undefined;
156
+ if (typeof then === "function") {
157
+ then.call(v, undefined, () => {});
160
158
  settled = true;
161
159
  clearTimeout(timer);
162
- resolve({ __asyncRejected: true } as unknown as T);
160
+ resolve({ __asyncRejected: true } as T);
163
161
  return;
164
162
  }
165
163
  if (settled) return;
@@ -13,6 +13,7 @@ Each folder holds exactly one `manifest.yaml`, `manifest.yml`, or `manifest.json
13
13
  | `require-ticket-ref` | `pre.task.create` | Blocks REST, MCP, and Slack tasks that do not name a ticket |
14
14
  | `notify-on-complete` | `post.task.completed`, `post.task.failed` | Posts a summary to a Slack channel |
15
15
  | `deploy-awareness` | `pre.task.create` | Appends a one-line note to new tasks while a Dokploy deploy is in progress |
16
+ | `slack-reply-gate` | `pre.task.create` | Asks Jev (TypeSafe or OpenRouter) whether Lead should answer a Slack thread follow-up; in `enforce` drops the wake-up and marks the message :mute: |
16
17
  | `require-verification-note` | `pre.tool.call` | Refuses completion without a `Verified:` line in the output |
17
18
  | `task-digest` | `post.task.completed`, `post.task.failed` | Ships the `task-digest-collect` script, the `task-digest-daily` schedule (09:00 UTC), the `task-digest-report` workflow, and the `task-digest-guide` skill |
18
19
 
@@ -0,0 +1,98 @@
1
+ # slack-reply-gate
2
+
3
+ Lead is woken by every human message in a Slack thread the swarm takes part in, including "lol", an emoji, or two people chatting with each other. This extension asks Jev, [TypeSafe](https://typesafe.ai)'s decision model, whether Lead should answer a new thread message, and drops the wake-up when it should not.
4
+
5
+ Install config:
6
+
7
+ ```json
8
+ { "mode": "shadow" }
9
+ ```
10
+
11
+ Start in `shadow`, read the decisions in KV for a few days, then switch to `enforce`.
12
+
13
+ ## Modes
14
+
15
+ | `mode` | Effect |
16
+ |---|---|
17
+ | `off` | Never evaluates. |
18
+ | `shadow` (default) | Asks Jev and records the verdict. Never blocks. |
19
+ | `enforce` | Asks Jev, records the verdict, and blocks the Lead task when the answer is no. The message gets :mute: instead: no Lead task, no reply, no outcome card. |
20
+
21
+ ## What it gates
22
+
23
+ Hook: `pre.task.create`, for tasks with origin `slack` whose description carries the thread buffer's header `[Thread follow-up — N message(s) buffered]`. Those are the non-mention thread replies that `src/slack/thread-buffer.ts` batches into a Lead task. A direct @mention of the bot creates a task without that header and is never gated.
24
+
25
+ The header counts only at the start of the description or right after the closing `</thread_context>`. Thread context quotes old messages verbatim, and an older Lead reply can quote the header itself, so a header inside the context is ignored. Only the text after the real header is treated as new.
26
+
27
+ ## Decision
28
+
29
+ 1. A follow-up with downloaded files (status `draft`) passes without a Jev call.
30
+ 2. **@mention pass-through.** A new message that @mentions Lead passes without a Jev call. A mention is the bot mention the engine rewrites to `<@U…> (that's you)`, a resolved `<@U…|lead>`, or a literal `@lead` (any name in `leadAliases`). Only the new messages are scanned, never the thread context, so an old mention earlier in the thread does not pass later banter.
31
+ 3. Otherwise one Jev call (one `choice` question, see [Jev transport](#jev-transport)) with the new message(s), the sender kind, and the last `contextMessages` thread messages. Labels: `direct_ask`, `followup_to_lead`, `instruction_to_lead`, `reaction_only`, `banter_between_others`, `not_addressed_to_lead`, `other`.
32
+ 4. The answer is no only when the top label is `reaction_only`, `banter_between_others`, or `not_addressed_to_lead`, and the summed probability of those three labels is at least `minConfidence`. A split between two skip labels is still a skip. An unsure verdict replies.
33
+
34
+ Several buffered messages are judged together. One mention in any of them passes the batch.
35
+
36
+ ## Jev transport
37
+
38
+ Jev is reachable two ways. Both take the same request and return the same answer, so the decision is identical whichever one runs.
39
+
40
+ | `provider` | Endpoint | Key | Model |
41
+ |---|---|---|---|
42
+ | `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |
43
+ | `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |
44
+ | `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |
45
+
46
+ Under `auto`, `TYPESAFE_API_KEY` wins when both keys are set. With neither key, every message fails open and the KV row names both secrets. Each decision row records the `provider` that answered.
47
+
48
+ ## Fail open
49
+
50
+ Every error lets the task through: a missing key, a TypeSafe HTTP error, a network error, a timeout, a malformed answer, or an unknown label. The handler never throws, so an outage cannot auto-disable the extension. Each failure is recorded as `fail-open` in KV and logged as a warning.
51
+
52
+ ## :mute: on drop (enforce)
53
+
54
+ No swarm tool or extension `ctx` method adds a Slack reaction, so the hook calls the Slack Web API with the bot token. It needs the `reactions:write` scope, which the Slack app already uses for its acceptance reactions.
55
+
56
+ 1. `reactions.remove` for each acceptance reaction the engine may have put on the triggering message: `eyes`, `heavy_plus_sign`, `zap`, `speech_balloon`, or their `SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, and `SLACK_REACTION_STEERED` overrides. Slack only removes the bot's own reaction, so a human's :eyes: stays.
57
+ 2. `reactions.add` with `muteReaction` (default `mute`). `already_reacted` counts as success. Skipped when `muteReaction` is `""`.
58
+
59
+ The block returns first and the Slack calls run after it. A slow or failing Slack call never delays the drop or turns it back into a task. Set `muteReaction` to `""` for a true no-op: step 1 still clears the bot's acceptance reaction(s), and nothing is added.
60
+
61
+ ## Config
62
+
63
+ | Key | Default | Notes |
64
+ |---|---|---|
65
+ | `mode` | `shadow` | `off`, `shadow`, or `enforce` |
66
+ | `timeoutMs` | `3000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |
67
+ | `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |
68
+ | `model` | `jev-latest` | Jev model on TypeSafe |
69
+ | `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |
70
+ | `minConfidence` | `0.55` | Minimum summed skip probability to skip |
71
+ | `contextMessages` | `6` | Thread messages sent to Jev, 0 to 20 |
72
+ | `leadAliases` | `["lead"]` | A literal `@alias` in a new message counts as a mention |
73
+ | `botSlackUserIds` | `[]` | Slack user ids labelled `bot` as the sender for Jev |
74
+ | `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |
75
+ | `muteReaction` | `mute` | Reaction for a dropped message; `""` only removes the acceptance reaction(s) |
76
+ | `slackTimeoutMs` | `5000` | Timeout per Slack call |
77
+
78
+ ## Required secrets
79
+
80
+ All are read from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.
81
+
82
+ - `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every message fails open.
83
+ - `SLACK_BOT_TOKEN`: the Slack app's bot token. A swarm with Slack connected already has it. Without it, `enforce` still drops the message but cannot touch its reactions, and logs a `mute-error`.
84
+
85
+ No key is written to config, KV, logs, or a description.
86
+
87
+ ## KV namespace `ext:slack-reply-gate`
88
+
89
+ - `d:<epochMs>:<channel>_<threadTs>`: one row per decision, with `action` (`pass`, `shadow-skip`, `skipped`, `fail-open`), `provider`, `reason`, `confidence`, `skipP`, `latencyMs`, `sender`, and a 300-character `preview`.
90
+ - `m:<epochMs>:<channel>_<threadTs>`: one row per mute attempt, with `action` (`muted`, `unreacted` when `muteReaction` is `""`, `mute-error`), `removed`, `added`, and `errors`.
91
+ - `last`: the latest decision. Mute rows never overwrite it.
92
+ - Counters: `total`, `mention`, `files`, `reply`, `shadow-skip`, `skipped`, `error`, `muted`, `unreacted`, `mute-error`, and `reason:<label>`.
93
+
94
+ ## Known limits
95
+
96
+ - When Lead already has an active task in the thread, the buffer steers that task instead of creating one, so `pre.task.create` does not fire and the gate does not run.
97
+ - A blocked flush makes the thread buffer log a `TaskCreationBlockedError`. Nothing is lost: the next follow-up's `<thread_context>` carries the earlier messages.
98
+ - A flush of several messages mutes only the triggering (last) message. The others keep their acceptance reaction.