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