@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.
- package/dist/{acp-adapter-zpvy6321.js → acp-adapter-m47zfx4a.js} +2 -2
- package/dist/{actions-fq2zrwtf.js → actions-ws7k3tvq.js} +25 -19
- package/dist/{amp-adapter-5gbk7501.js → amp-adapter-nvv8k8j5.js} +1 -1
- package/dist/{app-nctetn1e.js → app-80w6qspx.js} +5 -5
- package/dist/{assistant-0aq8gjvj.js → assistant-jtq439kw.js} +29 -20
- package/dist/{boot-reembed-1ze5853n.js → boot-reembed-3nc0aaed.js} +4 -4
- package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-feda8wn8.js} +4 -4
- package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-grsa2er0.js} +5 -5
- package/dist/{boot-seeds-22tx8a53.js → boot-seeds-gcfgq44f.js} +7 -7
- package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-pjv2q7t8.js} +7 -7
- package/dist/{claude-managed-adapter-tzwxy803.js → claude-managed-adapter-0wa8se82.js} +2 -2
- package/dist/{claude-managed-setup-nrhfx5b0.js → claude-managed-setup-zxa9p9tx.js} +1 -1
- package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-c9j6k9kx.js} +8772 -8663
- package/dist/{cli-c2tpw9ck.js → cli-3ry5gx4f.js} +1 -1
- package/dist/{cli-wydz3feg.js → cli-61tbnjpf.js} +214 -2472
- package/dist/{cli-cexctgn5.js → cli-7vz49g7h.js} +101 -34
- package/dist/{cli-6qxmjpem.js → cli-8xjst8q2.js} +34 -22
- package/dist/{cli-tx2zvn23.js → cli-91r17hb1.js} +3 -3
- package/dist/{cli-577bxkf6.js → cli-a8vps458.js} +182 -2208
- package/dist/{cli-tc5zec7v.js → cli-b7y8113e.js} +1 -1
- package/dist/{cli-69egj654.js → cli-bqrs5ycj.js} +1 -1
- package/dist/{cli-cf2vgn9d.js → cli-dv76vq5d.js} +30 -28
- package/dist/{cli-g5erf0zt.js → cli-e2prcx5m.js} +31 -0
- package/dist/{cli-c8r99akr.js → cli-e5qk3m2a.js} +209 -31
- package/dist/{cli-z1dqhc9y.js → cli-eqfbsxsn.js} +3 -3
- package/dist/{cli-kw0jcfje.js → cli-fyeft04q.js} +1 -1
- package/dist/{cli-5qkcdv8c.js → cli-gmdajfw5.js} +3 -3
- package/dist/{cli-ypgba1a8.js → cli-jkfxpg1b.js} +1 -1
- package/dist/{cli-mxvnnv5q.js → cli-jv36y8wz.js} +1 -1
- package/dist/{cli-m7vmeepg.js → cli-km8559dm.js} +6 -6
- package/dist/{cli-ykt8f7va.js → cli-kp3vfknv.js} +1 -1
- package/dist/{cli-ymmc1zxg.js → cli-m23r88re.js} +8 -5
- package/dist/{cli-v1pwxreq.js → cli-n4z21pt8.js} +1 -1
- package/dist/{cli-mxry91sa.js → cli-qg2zhjfc.js} +73 -4
- package/dist/{cli-1yrcfa5s.js → cli-rbr91whd.js} +4 -4
- package/dist/{cli-mkdsy1nq.js → cli-rg1c8ccw.js} +93 -7
- package/dist/cli-rh44xx46.js +2659 -0
- package/dist/cli-s0k2j7q9.js +2084 -0
- package/dist/{cli-ty4bzkqr.js → cli-sajr098b.js} +1 -1
- package/dist/{cli-6vfz44sr.js → cli-swrz3mj8.js} +1 -1
- package/dist/{cli-p8rt8spp.js → cli-vejds37t.js} +2 -2
- package/dist/{cli-jbzbr36n.js → cli-w6xa5nf5.js} +1 -1
- package/dist/{cli-9q3wt23f.js → cli-x6z90nq9.js} +1 -1
- package/dist/{cli-qzmzwegq.js → cli-ysnkvdx6.js} +102 -9
- package/dist/{cli-y0kjpfm9.js → cli-yvxfaem2.js} +2 -2
- package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
- package/dist/{cli-6bpvv8h4.js → cli-zzev32jw.js} +1 -1
- package/dist/cli.js +20 -17
- package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-0kpg09ca.js} +3 -3
- package/dist/{codex-hook-15dgc4cb.js → codex-hook-295jpbfa.js} +2 -2
- package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-8n672hxh.js} +3 -3
- package/dist/{commands-h6zaaxym.js → commands-mhmg5n0x.js} +4 -4
- package/dist/{cursor-adapter-v8mm6bp5.js → cursor-adapter-wn8n5zzk.js} +2 -2
- package/dist/{db-qg3myn53.js → db-wa6s1mwv.js} +4 -4
- package/dist/{dispatcher-7vnwj9qw.js → dispatcher-nhs2pkr8.js} +16 -14
- package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
- package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-28pkdt80.js} +3 -3
- package/dist/{handlers-2qtkpncr.js → handlers-5rtf9nng.js} +19 -17
- package/dist/{hook-51yfcgsv.js → hook-krjfkpzy.js} +4 -4
- package/dist/{hook-qnbdgwvd.js → hook-qkm73zk6.js} +5 -5
- package/dist/{http-jsmtag08.js → http-a397xcn4.js} +4845 -3484
- package/dist/{index-2t9kesn1.js → index-ac0v8v9d.js} +4 -4
- package/dist/{index-ekywyv2v.js → index-kqre45fr.js} +16 -14
- package/dist/{index-bt8wkv1w.js → index-rtxg15ah.js} +17 -15
- package/dist/{index-t4g06w93.js → index-vvsq7v45.js} +26 -23
- package/dist/{index-cx1sb9bq.js → index-ytdhtdry.js} +50 -11
- package/dist/{keepalive-4aezx4bj.js → keepalive-psfh8ysx.js} +6 -6
- package/dist/{lead-r44bkpm5.js → lead-m6rppjn7.js} +29 -27
- package/dist/{maintenance-grz5jrzh.js → maintenance-41f837ke.js} +4 -4
- package/dist/{node-qm9eb6z0.js → node-fkajaq96.js} +1 -1
- package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-6hr55hm8.js} +5 -5
- package/dist/{onboard-v60zgqf0.js → onboard-ktrtd7cz.js} +2 -2
- package/dist/{otel-impl-ghh4dyf8.js → otel-impl-dq4f2k3m.js} +1 -1
- package/dist/{pi-mono-adapter-0eg8kw2c.js → pi-mono-adapter-e5v9w0hv.js} +3 -2
- package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-kmk7j1ra.js} +6 -6
- package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-9w1dkkxd.js} +4 -4
- package/dist/{rbac-roles-axv4d74x.js → rbac-roles-9ykx9tc5.js} +5 -5
- package/dist/{render-v2-k50j33qd.js → render-v2-ycqm53np.js} +5 -5
- package/dist/resume-cq2pkp8q.js +70 -0
- package/dist/{setup-4syh0pd2.js → setup-0dcmdvbn.js} +2 -2
- package/dist/{virtual-modules-ftr9kwe8.js → virtual-modules-1x9k4jcs.js} +1 -1
- package/dist/{work-objects-jcdbg8rr.js → work-objects-x01x2fh8.js} +19 -17
- package/dist/{worker-x8j8c9sx.js → worker-py1ssb1h.js} +29 -27
- package/openapi.json +15622 -15042
- package/package.json +8 -5
- package/src/apps/store.ts +1 -0
- package/src/artifact-sdk/feedback-overlay.ts +528 -0
- package/src/be/db/workflow-runs.ts +95 -0
- package/src/be/db-queries/slack-inbound.ts +489 -0
- package/src/be/db.ts +9 -2
- package/src/be/extensions/assets.ts +1 -1
- package/src/be/migrations/203_workflow_run_parent_step.sql +10 -0
- package/src/be/migrations/204_slack_inbound_receipts.sql +51 -0
- package/src/be/script-connections.ts +41 -7
- package/src/be/scripts/run-saved.ts +40 -20
- package/src/be/swarm-config-guard.ts +4 -0
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/catalog.ts +1 -1
- package/src/extensions/dispatcher.ts +3 -3
- package/src/heartbeat/heartbeat.ts +152 -2
- package/src/heartbeat/templates.ts +16 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/index.ts +2 -0
- package/src/http/integrations.ts +3 -1
- package/src/http/mcp-bridge.ts +1 -0
- package/src/http/pages-public.ts +7 -2
- package/src/http/pages.ts +178 -0
- package/src/http/script-connections.ts +35 -0
- package/src/http/slack-inbound.ts +107 -0
- package/src/http/tasks.ts +45 -27
- package/src/http/users.ts +1 -1
- package/src/http/webhooks.ts +20 -20
- package/src/prompts/session-templates.ts +32 -0
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +2 -2
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-background-keepalive.ts +100 -0
- package/src/providers/claude-managed-adapter.ts +2 -11
- package/src/providers/claude-sdk-session.ts +7 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +1 -2
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/realtime/browser.generated.txt +8 -8
- package/src/realtime/browser.ts +3 -3
- package/src/scheduler/scheduler.ts +82 -13
- package/src/scripts-runtime/executors/quickjs-pool-config.ts +26 -0
- package/src/scripts-runtime/executors/quickjs.ts +9 -2
- package/src/scripts-runtime/executors/registry.ts +1 -1
- package/src/server.ts +1 -0
- package/src/slack/actions.ts +6 -2
- package/src/slack/app.ts +5 -0
- package/src/slack/assistant.ts +15 -5
- package/src/slack/egress-scrub.ts +3 -2
- package/src/slack/handlers.ts +36 -18
- package/src/slack/inbound-dispatch.ts +549 -0
- package/src/slack/inbound-files.ts +2 -0
- package/src/telemetry-context.ts +1 -0
- package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
- package/src/tests/claude-background-keepalive.fixture.ts +162 -0
- package/src/tests/claude-background-keepalive.test.ts +139 -0
- package/src/tests/codex-oauth-device.test.ts +1 -1
- package/src/tests/connection-embedded-auth.test.ts +51 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/extensions-catalog.test.ts +1 -0
- package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
- package/src/tests/heartbeat-checklist.test.ts +243 -0
- package/src/tests/pages-feedback.test.ts +218 -0
- package/src/tests/schedule-target-type.test.ts +136 -0
- package/src/tests/script-connections-http.test.ts +45 -0
- package/src/tests/script-connections-mcp.test.ts +41 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/script-executor-quickjs-pool.test.ts +106 -0
- package/src/tests/script-executor-registry.test.ts +56 -0
- package/src/tests/send-task-self-send.test.ts +107 -0
- package/src/tests/slack-inbound-diagnostics-auth.test.ts +93 -0
- package/src/tests/slack-inbound-receipts.test.ts +684 -0
- package/src/tests/slack-session-status.test.ts +4 -2
- package/src/tests/ui-e2e-ingest-payload.test.ts +5 -1
- package/src/tests/user-token-routes.test.ts +1 -1
- package/src/tests/workflow-executors.test.ts +3 -2
- package/src/tests/workflow-sub-workflow.test.ts +363 -0
- package/src/tools/script-connections/tool.ts +35 -3
- package/src/tools/send-task.ts +9 -1
- package/src/utils/internal-ai/complete-structured.ts +6 -3
- package/src/utils/runtime-model-catalog.ts +1 -1
- package/src/workflows/engine.ts +17 -1
- package/src/workflows/executors/agent-task.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +3 -3
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +1 -1
- package/src/workflows/executors/sub-workflow.ts +135 -0
- package/src/workflows/executors/wait.ts +3 -3
- package/src/workflows/recovery.ts +11 -1
- package/src/workflows/resume.ts +77 -0
- package/src/workflows/wait-filter.ts +5 -7
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/slack-reply-gate/README.md +98 -0
- package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
- package/templates/extensions/slack-reply-gate/manifest.json +8 -0
- package/templates/skills/heartbeat-runbook/content.md +3 -1
- package/templates/skills/pages/content.md +25 -0
- package/templates/skills/swarm-scripts/SKILL.md +10 -0
- 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.
|
|
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
|
|
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;
|