@desplega.ai/agent-swarm 1.157.0 → 1.158.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-qhqrk4q0.js → acp-adapter-xaq4n6pc.js} +1 -1
- package/dist/{actions-9rrg0zxm.js → actions-p9hz5yat.js} +15 -15
- package/dist/{app-enysy1zd.js → app-g26wdcz6.js} +4 -4
- package/dist/{artifact-td4q4dch.js → artifact-3v5t9jtg.js} +1 -1
- package/dist/{assistant-zegamy48.js → assistant-wydfqkb3.js} +16 -16
- package/dist/{boot-reembed-r6shx5qx.js → boot-reembed-g5ckg9yb.js} +6 -6
- package/dist/{boot-reembed-zfxb937k.js → boot-reembed-se8zmdcp.js} +5 -5
- package/dist/{boot-scrub-logs-k5y0se6r.js → boot-scrub-logs-wfxqan61.js} +3 -3
- package/dist/{seed-pricing-vqxa06qf.js → boot-seeds-s3q01xj3.js} +10 -10
- package/dist/{claude-adapter-5yc7hxwv.js → claude-adapter-tv2x4f36.js} +7 -7
- package/dist/{claude-managed-adapter-ea2aga6w.js → claude-managed-adapter-41b2s7fd.js} +2 -2
- package/dist/{claude-sdk-session-j4ywn3xf.js → claude-sdk-session-t442qz6f.js} +8 -8
- package/dist/{cli-e781c6f9.js → cli-0xjkj1j7.js} +1 -0
- package/dist/{cli-58rgvkt4.js → cli-1hkrch3k.js} +2 -2
- package/dist/{cli-yscm6cv8.js → cli-20cj37vt.js} +1866 -9
- package/dist/{cli-h0wdadsx.js → cli-4198qc9v.js} +7 -3
- package/dist/{cli-awwphhcs.js → cli-47m6dkd3.js} +362 -126
- package/dist/{cli-wb2zvbfg.js → cli-53sz8gkz.js} +1 -1
- package/dist/{cli-005pze6s.js → cli-5d10vd2m.js} +1 -1
- package/dist/{cli-v53rbqp3.js → cli-65hjmev5.js} +57 -17
- package/dist/{cli-scwq51gn.js → cli-68w09sjw.js} +7 -1
- package/dist/{cli-s98skd5r.js → cli-7gtvvpbh.js} +4 -4
- package/dist/{cli-y3nxwx0t.js → cli-7s3syk22.js} +18 -11
- package/dist/{cli-74h6f2bp.js → cli-8qmme6vd.js} +2 -2
- package/dist/{cli-wsed24mk.js → cli-987hg0aa.js} +2 -2
- package/dist/{cli-9n76jszt.js → cli-9httdpyq.js} +1 -1
- package/dist/{cli-f3va018d.js → cli-aky8tctm.js} +26 -93
- package/dist/{cli-sgyqyyxe.js → cli-c07r9a2m.js} +2 -1
- package/dist/{cli-c41wjx0r.js → cli-fgqxwrpv.js} +291 -12
- package/dist/{cli-01vfmq8c.js → cli-ftfvggzx.js} +607 -164
- package/dist/{cli-ytxs7mn1.js → cli-g8djp6qe.js} +17 -0
- package/dist/{cli-avkd6mqv.js → cli-h967nb6r.js} +551 -0
- package/dist/{cli-r9ee9c4w.js → cli-j55n64b6.js} +23 -22
- package/dist/{cli-9p33cz9h.js → cli-j57qkrpq.js} +86 -28
- package/dist/{cli-tjmghmm5.js → cli-q67y4wzm.js} +6 -3
- package/dist/{cli-n6zq3kbd.js → cli-rj19hc34.js} +1 -1
- package/dist/{cli-jytrw2na.js → cli-rjehvrts.js} +4 -4
- package/dist/{cli-tpv97m71.js → cli-rqz8qbw7.js} +245 -75
- package/dist/{cli-p37sg0a9.js → cli-s8eet254.js} +1 -1
- package/dist/cli-t1ze64h3.js +811 -0
- package/dist/{cli-6jjzxv3b.js → cli-ws6rqycm.js} +3 -2
- package/dist/{cli-xf5b7y1f.js → cli-wscdvcqz.js} +13 -5
- package/dist/{cli-5j7bm4tb.js → cli-x24pshtq.js} +5 -5
- package/dist/{cli-nbp6m6gp.js → cli-xberxa1r.js} +51 -26
- package/dist/{cli-vx9p3qdt.js → cli-xhaeg2nc.js} +1 -1
- package/dist/cli.js +29 -15
- package/dist/{codex-adapter-gns3s6c3.js → codex-adapter-cnm8wk11.js} +5 -5
- package/dist/{codex-session-runner-gqnxys5w.js → codex-session-runner-gfg547zj.js} +5 -5
- package/dist/{commands-hxy2y2sp.js → commands-e3bn0p75.js} +3 -3
- package/dist/{db-w8ghhsgf.js → db-03v9kzr2.js} +17 -3
- package/dist/{dispatcher-cp1rknjz.js → dispatcher-nrmg8yht.js} +14 -14
- package/dist/{dsh-adapter-1dwg97zv.js → dsh-adapter-k03nfqa6.js} +2 -2
- package/dist/{handlers-egfhr351.js → handlers-aqcedbyb.js} +17 -17
- package/dist/{hook-x4kftwzt.js → hook-7vkskkre.js} +19 -11
- package/dist/{hook-fbxjqfjz.js → hook-ybvmrmhq.js} +4 -4
- package/dist/{http-c7jfx990.js → http-2eqa8t24.js} +432 -195
- package/dist/{index-4k9hct21.js → index-1yks3qcr.js} +14 -14
- package/dist/{index-jtvpgb8f.js → index-a3vn96n8.js} +30 -27
- package/dist/{index-dc2ek6bw.js → index-c6zsv1bd.js} +5 -5
- package/dist/{index-yy07ef2s.js → index-chgdkq36.js} +14 -14
- package/dist/{index-r6bty6tb.js → index-x71bvq0p.js} +15 -15
- package/dist/{keepalive-f7ekefyc.js → keepalive-vtkf5n99.js} +5 -5
- package/dist/{lead-h053syts.js → lead-h3sp357x.js} +26 -26
- package/dist/{maintenance-a3w6zs54.js → maintenance-jzeqtk3q.js} +6 -6
- package/dist/{oauth-refresh-sweep-vra1dhkd.js → oauth-refresh-sweep-qp3dzmdp.js} +4 -4
- package/dist/{onboard-mfgzeqxs.js → onboard-tmsp0qbz.js} +278 -81
- package/dist/{opencode-adapter-z64xjzec.js → opencode-adapter-x6x2da1z.js} +3 -3
- package/dist/{otel-impl-2ez6xk57.js → otel-impl-9mz73q65.js} +416 -301
- package/dist/{pi-mono-adapter-2b7mw16r.js → pi-mono-adapter-m6dvv0dz.js} +2 -2
- package/dist/{pricing-refresh-6sw65tck.js → pricing-refresh-v1cbr9te.js} +5 -5
- package/dist/{rbac-roles-nm6924sy.js → rbac-roles-c9wqz83x.js} +4 -4
- package/dist/{render-v2-f026j6c4.js → render-v2-gryrh9h8.js} +4 -4
- package/dist/{setup-w606fr1q.js → setup-q9720bt6.js} +2 -2
- package/dist/{virtual-modules-8qheft18.js → virtual-modules-3htpgn8x.js} +1 -1
- package/dist/{work-objects-ezmggdne.js → work-objects-b76x7deb.js} +17 -17
- package/dist/{worker-1mbyv29k.js → worker-6vrxbv41.js} +26 -26
- package/openapi.json +1626 -629
- package/package.json +2 -1
- package/src/apps/catalog.generated.json +229 -0
- package/src/apps/page-validator.ts +71 -16
- package/src/apps/row-store.ts +66 -11
- package/src/apps/sync.ts +59 -17
- package/src/artifact-sdk/browser-sdk.ts +436 -2
- package/src/be/approval-sweeps.ts +109 -0
- package/src/be/boot-seeds.ts +40 -0
- package/src/be/chunking.ts +1 -1
- package/src/be/db/agents.ts +20 -0
- package/src/be/db/approvals.ts +78 -0
- package/src/be/db/tasks/read.ts +197 -39
- package/src/be/db/tasks/write.ts +41 -9
- package/src/be/db-client.ts +107 -48
- package/src/be/db.ts +219 -76
- package/src/be/events.ts +33 -1
- package/src/be/memory/graph-expansion.ts +10 -3
- package/src/be/memory/providers/sqlite-store.ts +55 -13
- package/src/be/memory/raters/retrieval.ts +5 -2
- package/src/be/memory/reranker.ts +26 -2
- package/src/be/memory/types.ts +2 -0
- package/src/be/migrations/166_approval_request_auto_cancellation.sql +9 -0
- package/src/be/migrations/171_memory_retrieval_relevance.sql +8 -0
- package/src/be/migrations/175_workflow_runs_list_index.sql +11 -0
- package/src/be/migrations/177_events_event_agent_created_index.sql +11 -0
- package/src/be/models-catalog.ts +2 -0
- package/src/be/modelsdev-cache.json +1857 -0
- package/src/be/modelsdev-cache.ts +14 -1
- package/src/be/pricing-normalize.ts +2 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +3 -1
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/steering.ts +53 -3
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/be/task-citations.ts +29 -0
- package/src/cli.tsx +14 -1
- package/src/commands/onboard/compose-generator.ts +8 -3
- package/src/commands/onboard/container-engine.ts +220 -0
- package/src/commands/onboard/manifest.ts +1 -0
- package/src/commands/onboard/steps/health-check.tsx +4 -1
- package/src/commands/onboard/steps/prereq-check.tsx +50 -54
- package/src/commands/onboard/steps/start.tsx +31 -16
- package/src/commands/onboard/types.ts +7 -2
- package/src/commands/onboard.tsx +39 -4
- package/src/commands/profile-sync.ts +79 -26
- package/src/commands/runner.ts +8 -1
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/heartbeat/heartbeat.ts +263 -103
- package/src/hooks/hook.ts +21 -12
- package/src/http/agents.ts +35 -0
- package/src/http/approval-requests.ts +136 -64
- package/src/http/apps.ts +14 -3
- package/src/http/config.ts +15 -1
- package/src/http/events.ts +62 -2
- package/src/http/index.ts +6 -4
- package/src/http/memory.ts +21 -7
- package/src/http/schedules.ts +1 -1
- package/src/http/session-data.ts +9 -4
- package/src/http/sessions.ts +25 -18
- package/src/http/stats.ts +19 -7
- package/src/http/status.ts +11 -1
- package/src/http/tasks.ts +40 -8
- package/src/http/webhooks.ts +67 -46
- package/src/http/workflows.ts +15 -20
- package/src/linear/outbound.ts +37 -15
- package/src/otel-impl.ts +151 -1
- package/src/otel.ts +14 -3
- package/src/prompts/memories.ts +19 -2
- package/src/providers/claude-managed-models.ts +8 -0
- package/src/providers/modelsdev-reasoning.json +551 -0
- package/src/rbac/legacy-policy.ts +16 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/scheduler/schedule-task.ts +3 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/swarm-sdk.ts +9 -0
- package/src/scripts-runtime/types/stdlib.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +1 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
- package/src/server.ts +12 -7
- package/src/slack/app.ts +35 -4
- package/src/slack/connection-state.ts +17 -0
- package/src/tasks/context-key.ts +1 -0
- package/src/tasks/worker-follow-up.ts +6 -2
- package/src/tests/agent-task-activity.test.ts +74 -0
- package/src/tests/agents-list-model-display.test.ts +6 -1
- package/src/tests/approval-auto-cancellation-migration.test.ts +104 -0
- package/src/tests/approval-requests.test.ts +395 -0
- package/src/tests/approval-sweeps-config.test.ts +47 -0
- package/src/tests/approval-sweeps.test.ts +508 -0
- package/src/tests/apps-kibo-blocks.test.ts +77 -0
- package/src/tests/apps-spike.test.ts +56 -1
- package/src/tests/apps-sync-engine.test.ts +20 -9
- package/src/tests/boot-seeds.test.ts +170 -0
- package/src/tests/cancel-approval-request-tool.test.ts +115 -0
- package/src/tests/claude-managed-adapter.test.ts +6 -0
- package/src/tests/config-key-filter.test.ts +80 -0
- package/src/tests/context-window.test.ts +1 -0
- package/src/tests/db-client-busy-retry.test.ts +96 -0
- package/src/tests/db-client.test.ts +60 -1
- package/src/tests/db-task-read-contract.test.ts +10 -0
- package/src/tests/db-task-write-contract.test.ts +9 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/events-http.test.ts +103 -0
- package/src/tests/extensions-catalog.test.ts +22 -17
- package/src/tests/extensions-pre-task.test.ts +107 -0
- package/src/tests/fixtures/kibo-blocks-definition.json.txt +105 -0
- package/src/tests/follow-up-config-inheritance.test.ts +260 -0
- package/src/tests/heartbeat-orphan-resume.test.ts +84 -0
- package/src/tests/heartbeat-reboot-live.test.ts +95 -0
- package/src/tests/heartbeat-stall-cas.test.ts +80 -0
- package/src/tests/heartbeat.test.ts +99 -3
- package/src/tests/hook-mcp-config.test.ts +29 -0
- package/src/tests/http-api-integration.test.ts +16 -2
- package/src/tests/kv-storage.test.ts +62 -0
- package/src/tests/linear-outbound-sync.test.ts +120 -5
- package/src/tests/list-endpoint-slimming.test.ts +91 -0
- package/src/tests/memory-graph-organic-route.test.ts +155 -0
- package/src/tests/memory-http-recall-gating.test.ts +14 -4
- package/src/tests/memory-hybrid.test.ts +161 -1
- package/src/tests/memory-prompt-recall-crowdout.test.ts +297 -0
- package/src/tests/memory.test.ts +22 -0
- package/src/tests/onboard-container-engine.test.ts +240 -0
- package/src/tests/otel-event-loop-and-logs.test.ts +99 -0
- package/src/tests/pricing-routes.test.ts +14 -0
- package/src/tests/profile-sync-warning.test.ts +8 -4
- package/src/tests/promote-draft-task-route.test.ts +19 -0
- package/src/tests/provider-registration.test.ts +296 -42
- package/src/tests/rbac-engine.test.ts +30 -0
- package/src/tests/reasoning-effort.test.ts +8 -0
- package/src/tests/script-connections.test.ts +7 -0
- package/src/tests/sdk-allowlist.test.ts +39 -0
- package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
- package/src/tests/sessions-detail-batch.test.ts +250 -0
- package/src/tests/sessions.test.ts +118 -0
- package/src/tests/slack-config.test.ts +22 -0
- package/src/tests/sonnet-5-5-registry.test.ts +44 -0
- package/src/tests/status.test.ts +36 -1
- package/src/tests/swarm-diff.test.ts +316 -10
- package/src/tests/task-completion-idempotency.test.ts +2 -1
- package/src/tests/tasks-requested-by-filter.test.ts +3 -1
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tests/ui-list-slim-projections.test.ts +158 -0
- package/src/tests/webhooks-task-blocked.test.ts +127 -0
- package/src/tests/workflow-approval-recovery.test.ts +203 -0
- package/src/tests/workflow-hitl-routing.test.ts +22 -1
- package/src/tests/workflow-hitl-slack-card.test.ts +52 -0
- package/src/tests/workflow-http-v2.test.ts +99 -8
- package/src/tests/workflow-retry-validation.test.ts +56 -0
- package/src/tests/workflow-tla-races.test.ts +389 -0
- package/src/tests/workflow-validation-port-routing.test.ts +113 -1
- package/src/tools/cancel-approval-request.ts +57 -0
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/request-human-input.ts +4 -2
- package/src/tools/send-task.ts +4 -0
- package/src/tools/templates.ts +19 -0
- package/src/tools/tool-config.ts +2 -1
- package/src/tools/workflows/list-workflow-runs.ts +1 -0
- package/src/types.ts +38 -0
- package/src/utils/context-window.ts +1 -0
- package/src/workflows/approval-cancel.ts +152 -0
- package/src/workflows/approval-notifications.ts +84 -0
- package/src/workflows/checkpoint.ts +10 -5
- package/src/workflows/definition.ts +21 -0
- package/src/workflows/engine.ts +152 -21
- package/src/workflows/executors/human-in-the-loop.ts +1 -1
- package/src/workflows/recovery.ts +100 -45
- package/src/workflows/resume.ts +49 -18
- package/src/workflows/retry-poller.ts +71 -21
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/github-sender-allowlist/README.md +18 -0
- package/templates/extensions/github-sender-allowlist/hooks.ts +55 -0
- package/templates/extensions/github-sender-allowlist/manifest.json +8 -0
- package/templates/skills/apps/content.md +20 -1
- package/templates/skills/code-quality/content.md +26 -1
- package/templates/skills/pages/content.md +16 -4
- package/templates/skills/pages/files/examples/multiplayer-board.html +3 -12
- package/dist/cli-2daksqps.js +0 -378
- package/dist/rbac-roles-3gxmnb20.js +0 -37
package/src/types.ts
CHANGED
|
@@ -770,6 +770,15 @@ export const CreateTaskOptionsSchema = z.object({
|
|
|
770
770
|
* Lead-only control-plane authorization.
|
|
771
771
|
*/
|
|
772
772
|
inheritParentRoutingAffinity: z.boolean().optional(),
|
|
773
|
+
/**
|
|
774
|
+
* `followUpConfig` belongs to one piece of work, not to the thread, so a
|
|
775
|
+
* child does NOT inherit the parent's by default. Set this to `true` only on
|
|
776
|
+
* a continuation of the SAME work (interrupted-task resume, reboot-sweep
|
|
777
|
+
* retry, defer-task wake-up, a Lead `resume` re-delegation) so the creator's
|
|
778
|
+
* onCompleted/onFailed still fires when that work truly ends. An explicit
|
|
779
|
+
* `followUpConfig` always wins.
|
|
780
|
+
*/
|
|
781
|
+
inheritParentFollowUpConfig: z.boolean().optional(),
|
|
773
782
|
followUpConfig: FollowUpConfigSchema.optional(),
|
|
774
783
|
requestedByUserId: z.string().optional(),
|
|
775
784
|
contextKey: z.string().optional(),
|
|
@@ -1425,6 +1434,8 @@ export const AgentLogEventTypeSchema = z.enum([
|
|
|
1425
1434
|
"task_dispatch_rejected_affinity",
|
|
1426
1435
|
"task_authorization_rejected",
|
|
1427
1436
|
"task_recovery_authorization",
|
|
1437
|
+
// Reboot sweep moved a never-started dependent from the swept task to its retry
|
|
1438
|
+
"task_dependency_repointed",
|
|
1428
1439
|
"task_released",
|
|
1429
1440
|
// A settled task's settlement fired a deferred wait (metadata names the waiter)
|
|
1430
1441
|
"task_deferred_wait_woke",
|
|
@@ -2412,6 +2423,26 @@ export type AgentTaskSummary = Pick<
|
|
|
2412
2423
|
| "totalCostUsd"
|
|
2413
2424
|
>;
|
|
2414
2425
|
|
|
2426
|
+
/**
|
|
2427
|
+
* `/api/tasks?fields=timeline` list item: only what the dashboard activity
|
|
2428
|
+
* timeline draws (lane, bar extent, label, hover stats). Same bounded `task`
|
|
2429
|
+
* preview as `AgentTaskSummary`.
|
|
2430
|
+
*/
|
|
2431
|
+
export type AgentTaskTimelineItem = Pick<
|
|
2432
|
+
AgentTask,
|
|
2433
|
+
| "id"
|
|
2434
|
+
| "agentId"
|
|
2435
|
+
| "parentTaskId"
|
|
2436
|
+
| "task"
|
|
2437
|
+
| "title"
|
|
2438
|
+
| "status"
|
|
2439
|
+
| "createdAt"
|
|
2440
|
+
| "lastUpdatedAt"
|
|
2441
|
+
| "finishedAt"
|
|
2442
|
+
| "peakContextTokens"
|
|
2443
|
+
| "totalCostUsd"
|
|
2444
|
+
>;
|
|
2445
|
+
|
|
2415
2446
|
export const PageVersionSchema = z
|
|
2416
2447
|
.object({
|
|
2417
2448
|
id: z.string(),
|
|
@@ -2594,6 +2625,12 @@ export const WorkflowRunSchema = z
|
|
|
2594
2625
|
.openapi("WorkflowRun");
|
|
2595
2626
|
export type WorkflowRun = z.infer<typeof WorkflowRunSchema>;
|
|
2596
2627
|
|
|
2628
|
+
/** List row of a run: no `context`, which `GET /api/workflow-runs/{id}` serves. */
|
|
2629
|
+
export const WorkflowRunSummarySchema = WorkflowRunSchema.omit({ context: true }).openapi(
|
|
2630
|
+
"WorkflowRunSummary",
|
|
2631
|
+
);
|
|
2632
|
+
export type WorkflowRunSummary = z.infer<typeof WorkflowRunSummarySchema>;
|
|
2633
|
+
|
|
2597
2634
|
// --- Script Workflow Runs ---
|
|
2598
2635
|
|
|
2599
2636
|
export const ScriptRunStatusSchema = z.enum([
|
|
@@ -3321,6 +3358,7 @@ export const PricingProviderSchema = z.enum([
|
|
|
3321
3358
|
// adapter reports `totalCostUsd: 0`, so these rows settle at
|
|
3322
3359
|
// `costSource: 'unpriced'`. Accepted here so the row is recorded at all.
|
|
3323
3360
|
"acp",
|
|
3361
|
+
"dsh",
|
|
3324
3362
|
]);
|
|
3325
3363
|
export type PricingProvider = z.infer<typeof PricingProviderSchema>;
|
|
3326
3364
|
|
|
@@ -33,6 +33,7 @@ const CONTEXT_WINDOW_DEFAULTS: Record<string, number> = {
|
|
|
33
33
|
"claude-opus-5": 1_000_000,
|
|
34
34
|
"claude-fable-5": 1_000_000,
|
|
35
35
|
"claude-mythos-5": 1_000_000,
|
|
36
|
+
"claude-sonnet-5-5": 1_000_000,
|
|
36
37
|
"claude-sonnet-5": 1_000_000,
|
|
37
38
|
// Anthropic 4.x family
|
|
38
39
|
"claude-opus-4-8": 1_000_000,
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ApprovalRequest,
|
|
3
|
+
cancelApprovalRequestById,
|
|
4
|
+
getApprovalRequestById,
|
|
5
|
+
getDbClient,
|
|
6
|
+
getTaskById,
|
|
7
|
+
getWorkflowRun,
|
|
8
|
+
listCancelledApprovalRequestsForRun,
|
|
9
|
+
resolveApprovalRequest,
|
|
10
|
+
} from "../be/db";
|
|
11
|
+
import { can, type RbacPrincipal } from "../rbac";
|
|
12
|
+
import {
|
|
13
|
+
createApprovalFollowUpTask,
|
|
14
|
+
postApprovalCancellationUpdates,
|
|
15
|
+
} from "./approval-notifications";
|
|
16
|
+
import { cancelWorkflowRunRows } from "./resume";
|
|
17
|
+
|
|
18
|
+
export type CancelApprovalRequestResult =
|
|
19
|
+
| { ok: true; request: ApprovalRequest; alreadyCancelled: boolean; runCancelled: boolean }
|
|
20
|
+
| { ok: false; status: 403 | 404 | 409; message: string };
|
|
21
|
+
|
|
22
|
+
const LIVE_RUN_STATUSES = new Set(["running", "waiting"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Cancel 1 pending approval request. Shared by the HTTP route, the MCP tool,
|
|
26
|
+
* and the dashboard Discard button. A request that gates a live workflow run
|
|
27
|
+
* cancels that run too.
|
|
28
|
+
*/
|
|
29
|
+
export async function cancelApprovalRequest(input: {
|
|
30
|
+
id: string;
|
|
31
|
+
reason?: string;
|
|
32
|
+
principal: RbacPrincipal;
|
|
33
|
+
resolvedBy: string | null;
|
|
34
|
+
source?: "http" | "mcp";
|
|
35
|
+
}): Promise<CancelApprovalRequestResult> {
|
|
36
|
+
const finalReason = input.reason ?? "Cancelled through the API";
|
|
37
|
+
let authorized = false;
|
|
38
|
+
|
|
39
|
+
// A null write means a concurrent change; re-read and apply the rules again.
|
|
40
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
41
|
+
const existing = await getApprovalRequestById(input.id);
|
|
42
|
+
if (!existing) return { ok: false, status: 404, message: "Approval request not found" };
|
|
43
|
+
if (existing.status === "cancelled") {
|
|
44
|
+
return { ok: true, request: existing, alreadyCancelled: true, runCancelled: false };
|
|
45
|
+
}
|
|
46
|
+
if (existing.status !== "pending") {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
status: 409,
|
|
50
|
+
message: `Approval request already resolved with status: ${existing.status}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!authorized) {
|
|
55
|
+
const ownerAgentId = existing.sourceTaskId
|
|
56
|
+
? ((await getTaskById(existing.sourceTaskId))?.agentId ?? null)
|
|
57
|
+
: null;
|
|
58
|
+
const decision = can({
|
|
59
|
+
principal: input.principal,
|
|
60
|
+
verb: "approval.cancel.any",
|
|
61
|
+
resource: { kind: "owned", ownerAgentId },
|
|
62
|
+
source: input.source ?? "http",
|
|
63
|
+
});
|
|
64
|
+
if (!decision.allow) return { ok: false, status: 403, message: decision.reason };
|
|
65
|
+
authorized = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = await cancelApprovalRequestAndRun(input.id, {
|
|
69
|
+
reason: finalReason,
|
|
70
|
+
resolvedBy: input.resolvedBy,
|
|
71
|
+
});
|
|
72
|
+
if (!result) continue;
|
|
73
|
+
if ("timedOut" in result) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
status: 409,
|
|
77
|
+
message: `Approval request expired at ${result.timedOut.expiresAt}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
ok: true,
|
|
82
|
+
request: result.request,
|
|
83
|
+
alreadyCancelled: false,
|
|
84
|
+
runCancelled: result.runCancelled,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { ok: false, status: 409, message: "Approval request changed during cancellation" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cancel 1 pending request and its live workflow run in 1 transaction: both
|
|
93
|
+
* commit, or neither does. The Slack thread updates run after COMMIT.
|
|
94
|
+
* Returns null when the request was not pending. A pending request whose
|
|
95
|
+
* explicit expiresAt passed becomes timeout instead, in the same transaction.
|
|
96
|
+
*/
|
|
97
|
+
export async function cancelApprovalRequestAndRun(
|
|
98
|
+
id: string,
|
|
99
|
+
data: { reason: string; resolvedBy: string | null; slackReason?: string },
|
|
100
|
+
): Promise<
|
|
101
|
+
{ request: ApprovalRequest; runCancelled: boolean } | { timedOut: ApprovalRequest } | null
|
|
102
|
+
> {
|
|
103
|
+
const client = getDbClient();
|
|
104
|
+
return await client.transaction(async () => {
|
|
105
|
+
// An elapsed explicit deadline wins over the cancel. A workflow run then
|
|
106
|
+
// routes on its timeout port through getStuckApprovalRuns, as for a late answer.
|
|
107
|
+
const current = await getApprovalRequestById(id);
|
|
108
|
+
if (
|
|
109
|
+
current?.status === "pending" &&
|
|
110
|
+
current.expiresAt &&
|
|
111
|
+
new Date(current.expiresAt) < new Date()
|
|
112
|
+
) {
|
|
113
|
+
const timedOut = await resolveApprovalRequest(id, {
|
|
114
|
+
status: "timeout",
|
|
115
|
+
resolutionReason: `Timed out: the cancellation arrived after the deadline ${current.expiresAt}`,
|
|
116
|
+
});
|
|
117
|
+
if (!timedOut) return null;
|
|
118
|
+
await createApprovalFollowUpTask(timedOut, "hitl.timeout");
|
|
119
|
+
return { timedOut };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const request = await cancelApprovalRequestById(id, {
|
|
123
|
+
reason: data.reason,
|
|
124
|
+
resolvedBy: data.resolvedBy,
|
|
125
|
+
});
|
|
126
|
+
if (!request) return null;
|
|
127
|
+
|
|
128
|
+
// The request is cancelled first so it keeps this reason; the run cancel
|
|
129
|
+
// then skips it because it is no longer pending.
|
|
130
|
+
let runCancelled = false;
|
|
131
|
+
const runId = request.workflowRunId;
|
|
132
|
+
if (runId) {
|
|
133
|
+
const run = await getWorkflowRun(runId);
|
|
134
|
+
if (run && LIVE_RUN_STATUSES.has(run.status)) {
|
|
135
|
+
runCancelled = await cancelWorkflowRunRows(runId, data.reason);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const slackReason = data.slackReason ?? data.reason;
|
|
140
|
+
client.afterCommit(async () => {
|
|
141
|
+
await postApprovalCancellationUpdates([request], slackReason);
|
|
142
|
+
if (runCancelled && runId) {
|
|
143
|
+
// Other requests the run cancel closed get the run's reason.
|
|
144
|
+
const others = (await listCancelledApprovalRequestsForRun(runId)).filter(
|
|
145
|
+
(row) => row.id !== request.id,
|
|
146
|
+
);
|
|
147
|
+
await postApprovalCancellationUpdates(others, data.reason);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return { request, runCancelled };
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -2,9 +2,13 @@ import {
|
|
|
2
2
|
type ApprovalRequest,
|
|
3
3
|
claimApprovalCancellationNotification,
|
|
4
4
|
completeApprovalCancellationNotificationClaim,
|
|
5
|
+
createTaskExtended,
|
|
6
|
+
getTaskById,
|
|
5
7
|
releaseApprovalCancellationNotificationClaim,
|
|
6
8
|
} from "../be/db";
|
|
9
|
+
import { resolveTemplate } from "../prompts/resolver";
|
|
7
10
|
import { getSlackApp } from "../slack/app";
|
|
11
|
+
import { TERMINAL_TASK_STATUSES } from "../types";
|
|
8
12
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
9
13
|
|
|
10
14
|
export interface ApprovalSlackClient {
|
|
@@ -70,3 +74,83 @@ export async function postApprovalCancellationUpdates(
|
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create the `hitl-follow-up` task that tells the requesting agent how a
|
|
80
|
+
* standalone approval request ended. Returns false when no task is created.
|
|
81
|
+
*/
|
|
82
|
+
export async function createApprovalFollowUpTask(
|
|
83
|
+
request: ApprovalRequest,
|
|
84
|
+
templateEventType: "hitl.follow_up" | "hitl.timeout",
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
if (request.workflowRunId || !request.sourceTaskId) return false;
|
|
87
|
+
const sourceTask = await getTaskById(request.sourceTaskId);
|
|
88
|
+
if (!sourceTask) return false;
|
|
89
|
+
// Timeouts only: the first sweep after deploy would otherwise create one task per finished backlog row.
|
|
90
|
+
// A human answer still gets a follow-up after the source task finishes, as before.
|
|
91
|
+
if (
|
|
92
|
+
templateEventType === "hitl.timeout" &&
|
|
93
|
+
(TERMINAL_TASK_STATUSES as readonly string[]).includes(sourceTask.status)
|
|
94
|
+
) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { text: taskText } =
|
|
99
|
+
templateEventType === "hitl.follow_up"
|
|
100
|
+
? resolveTemplate("hitl.follow_up", {
|
|
101
|
+
request_id: request.id,
|
|
102
|
+
title: request.title,
|
|
103
|
+
status: request.status,
|
|
104
|
+
responses: formatResponses(
|
|
105
|
+
request.questions as Array<{ id: string; type: string; label: string }>,
|
|
106
|
+
(request.responses ?? {}) as Record<string, unknown>,
|
|
107
|
+
),
|
|
108
|
+
})
|
|
109
|
+
: resolveTemplate("hitl.timeout", {
|
|
110
|
+
request_id: request.id,
|
|
111
|
+
title: request.title,
|
|
112
|
+
expires_at: request.expiresAt ?? "",
|
|
113
|
+
reason: request.resolutionReason ?? "",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await createTaskExtended(taskText, {
|
|
117
|
+
agentId: sourceTask.agentId,
|
|
118
|
+
routingReason: sourceTask.agentId ? "continuity" : undefined,
|
|
119
|
+
routingSource: sourceTask.agentId ? "engine_default" : undefined,
|
|
120
|
+
parentTaskId: request.sourceTaskId,
|
|
121
|
+
source: "system",
|
|
122
|
+
taskType: "hitl-follow-up",
|
|
123
|
+
tags: ["hitl", "follow-up"],
|
|
124
|
+
// Explicit Slack metadata — parentTaskId auto-inherits too,
|
|
125
|
+
// but being explicit ensures the follow-up task always gets
|
|
126
|
+
// the right thread context even if inheritance logic changes.
|
|
127
|
+
slackChannelId: sourceTask.slackChannelId ?? undefined,
|
|
128
|
+
slackThreadTs: sourceTask.slackThreadTs ?? undefined,
|
|
129
|
+
slackUserId: sourceTask.slackUserId ?? undefined,
|
|
130
|
+
});
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatResponses(
|
|
135
|
+
questions: Array<{ id: string; type: string; label: string }>,
|
|
136
|
+
responses: Record<string, unknown>,
|
|
137
|
+
): string {
|
|
138
|
+
return questions
|
|
139
|
+
.map((q) => {
|
|
140
|
+
const answer = responses[q.id];
|
|
141
|
+
let answerText: string;
|
|
142
|
+
if (answer == null) {
|
|
143
|
+
answerText = "(no answer)";
|
|
144
|
+
} else if (q.type === "approval") {
|
|
145
|
+
const a = answer as { approved?: boolean; comment?: string };
|
|
146
|
+
answerText = a.approved ? "Approved" : "Rejected";
|
|
147
|
+
if (a.comment) answerText += ` — ${a.comment}`;
|
|
148
|
+
} else if (typeof answer === "object") {
|
|
149
|
+
answerText = JSON.stringify(answer);
|
|
150
|
+
} else {
|
|
151
|
+
answerText = String(answer);
|
|
152
|
+
}
|
|
153
|
+
return `- ${q.label}: ${answerText}`;
|
|
154
|
+
})
|
|
155
|
+
.join("\n");
|
|
156
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getDbClient, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
|
|
1
|
+
import { getDbClient, getWorkflowRun, updateWorkflowRun, updateWorkflowRunStep } from "../be/db";
|
|
2
2
|
import type { RetryPolicy } from "../types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -66,10 +66,15 @@ export async function checkpointStepFailure(
|
|
|
66
66
|
|
|
67
67
|
const markRunFailed = options?.markRunFailed ?? true;
|
|
68
68
|
if (markRunFailed) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
// A cancel or finalization that committed while the step ran wins.
|
|
70
|
+
await getDbClient().transaction(async () => {
|
|
71
|
+
const run = await getWorkflowRun(runId);
|
|
72
|
+
if (run?.status !== "running" && run?.status !== "waiting") return;
|
|
73
|
+
await updateWorkflowRun(runId, {
|
|
74
|
+
status: "failed",
|
|
75
|
+
error: `Step failed: ${error}`,
|
|
76
|
+
finishedAt: now,
|
|
77
|
+
});
|
|
73
78
|
});
|
|
74
79
|
}
|
|
75
80
|
|
|
@@ -65,6 +65,27 @@ export function findEntryNodes(def: WorkflowDefinition): WorkflowNode[] {
|
|
|
65
65
|
return def.nodes.filter((n) => !targets.has(n.id));
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Pick the port a node routes on once validation has produced pass/fail.
|
|
70
|
+
*
|
|
71
|
+
* Validation decides when the node uses record-based `next` and the executor
|
|
72
|
+
* either chose no port or chose one that `next` does not declare. `script` and
|
|
73
|
+
* `swarm-script` always emit "success", so a `next: { pass, fail }` record would
|
|
74
|
+
* otherwise match nothing and the branch would end silently. An executor port
|
|
75
|
+
* that IS a key of `next` (property-match "true"/"false", a script node wired
|
|
76
|
+
* as `next: { success }`) stays authoritative.
|
|
77
|
+
*/
|
|
78
|
+
export function resolveValidationPort(
|
|
79
|
+
next: WorkflowNode["next"],
|
|
80
|
+
executorPort: string | undefined,
|
|
81
|
+
validationPassed: boolean | undefined,
|
|
82
|
+
): string | undefined {
|
|
83
|
+
if (validationPassed === undefined) return executorPort;
|
|
84
|
+
if (!next || typeof next !== "object" || Array.isArray(next)) return executorPort;
|
|
85
|
+
if (executorPort && Object.hasOwn(next, executorPort)) return executorPort;
|
|
86
|
+
return validationPassed ? "pass" : "fail";
|
|
87
|
+
}
|
|
88
|
+
|
|
68
89
|
/**
|
|
69
90
|
* Get successor node IDs for a given node and port.
|
|
70
91
|
*/
|
package/src/workflows/engine.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
createWorkflowRun,
|
|
11
11
|
createWorkflowRunStep,
|
|
12
12
|
getCompletedStepNodeIds,
|
|
13
|
+
getCurrentStepForNode,
|
|
13
14
|
getDbClient,
|
|
14
15
|
getLatestStepForNode,
|
|
15
16
|
getStepByIdempotencyKey,
|
|
@@ -24,7 +25,7 @@ import type { Workflow, WorkflowDefinition, WorkflowNode, WorkflowRunStep } from
|
|
|
24
25
|
import { checkpointStep, checkpointStepFailure, checkpointStepWaiting } from "./checkpoint";
|
|
25
26
|
import { loadCompletedStepRouting } from "./completed-step-routing";
|
|
26
27
|
import { shouldSkipCooldown } from "./cooldown";
|
|
27
|
-
import { findEntryNodes, getNextTargets, getSuccessors } from "./definition";
|
|
28
|
+
import { findEntryNodes, getNextTargets, getSuccessors, resolveValidationPort } from "./definition";
|
|
28
29
|
import type { AsyncExecutorResult } from "./executors/base";
|
|
29
30
|
import type { ExecutorRegistry } from "./executors/registry";
|
|
30
31
|
import { FOREACH_TERMINAL_STEP_STATUSES, resolveForeachParent } from "./foreach-join";
|
|
@@ -202,10 +203,32 @@ interface StepResult {
|
|
|
202
203
|
// settles, including checkpointing and routing between executor calls.
|
|
203
204
|
const activeWalks = new Map<string, number>();
|
|
204
205
|
|
|
206
|
+
// Step rows an executeStep call in this process inserted and has not finished
|
|
207
|
+
// yet. Process-local like activeWalks: a crash clears it, so a `running` row
|
|
208
|
+
// left behind is not mistaken for a live execution.
|
|
209
|
+
const executingSteps = new Set<string>();
|
|
210
|
+
|
|
205
211
|
export function isWorkflowRunActive(runId: string): boolean {
|
|
206
212
|
return activeWalks.has(runId);
|
|
207
213
|
}
|
|
208
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Mark the run as owned by this process until the returned release runs.
|
|
217
|
+
* Recovery skips owned runs, so an executor outside walkGraph (the retry
|
|
218
|
+
* poller) holds the run for as long as it executes a step.
|
|
219
|
+
*/
|
|
220
|
+
export function holdWorkflowRun(runId: string): () => void {
|
|
221
|
+
activeWalks.set(runId, (activeWalks.get(runId) ?? 0) + 1);
|
|
222
|
+
let released = false;
|
|
223
|
+
return () => {
|
|
224
|
+
if (released) return;
|
|
225
|
+
released = true;
|
|
226
|
+
const remaining = activeWalks.get(runId)! - 1;
|
|
227
|
+
if (remaining === 0) activeWalks.delete(runId);
|
|
228
|
+
else activeWalks.set(runId, remaining);
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
209
232
|
/**
|
|
210
233
|
* Event-loop style graph walker.
|
|
211
234
|
*
|
|
@@ -223,13 +246,11 @@ export async function walkGraph(
|
|
|
223
246
|
secretKeys: Set<string> = new Set(),
|
|
224
247
|
options: WorkflowExecutionOptions = {},
|
|
225
248
|
): Promise<void> {
|
|
226
|
-
|
|
249
|
+
const release = holdWorkflowRun(runId);
|
|
227
250
|
try {
|
|
228
251
|
await walkGraphOwned(def, runId, ctx, startNodes, registry, workflowId, secretKeys, options);
|
|
229
252
|
} finally {
|
|
230
|
-
|
|
231
|
-
if (remaining === 0) activeWalks.delete(runId);
|
|
232
|
-
else activeWalks.set(runId, remaining);
|
|
253
|
+
release();
|
|
233
254
|
}
|
|
234
255
|
}
|
|
235
256
|
|
|
@@ -293,6 +314,8 @@ async function walkGraphOwned(
|
|
|
293
314
|
}
|
|
294
315
|
}
|
|
295
316
|
|
|
317
|
+
const awaited = awaitedNodeIds(allSteps, activeEdges);
|
|
318
|
+
|
|
296
319
|
// Seed with start nodes whose predecessors are all completed (convergence gate).
|
|
297
320
|
// For entry nodes (no predecessors), skip if already completed — these are
|
|
298
321
|
// re-walk/recovery scenarios where memoization should apply.
|
|
@@ -306,7 +329,9 @@ async function walkGraphOwned(
|
|
|
306
329
|
}
|
|
307
330
|
// Non-entry node — allow through even if completed (loop target).
|
|
308
331
|
// Check predecessors are ready.
|
|
309
|
-
const activePreds = preds.filter(
|
|
332
|
+
const activePreds = preds.filter(
|
|
333
|
+
(predId) => activeEdges.has(`${predId}→${n.id}`) || awaited.has(predId),
|
|
334
|
+
);
|
|
310
335
|
// If no active edges yet (first walk), check ALL structural predecessors
|
|
311
336
|
const predsToCheck = activePreds.length > 0 ? activePreds : preds;
|
|
312
337
|
return predsToCheck.every((p) => completedNodeIds.has(p));
|
|
@@ -376,12 +401,27 @@ async function walkGraphOwned(
|
|
|
376
401
|
// Use executedInThisWalk (not completedNodeIds) to gate dedup — this
|
|
377
402
|
// allows loop targets from prior walks to re-execute while preventing
|
|
378
403
|
// double execution within the same walk.
|
|
404
|
+
// A sibling branch another walker is executing has no edge in this walk's
|
|
405
|
+
// activeEdges, so re-read the steps: a live predecessor holds the join,
|
|
406
|
+
// and one that completed elsewhere is rehydrated for the join's inputs.
|
|
379
407
|
const readyNext: WorkflowNode[] = [];
|
|
408
|
+
const batchSteps = nextBatch.size > 0 ? await getWorkflowRunStepsByRunId(runId) : [];
|
|
409
|
+
const batchAwaited = awaitedNodeIds(batchSteps, activeEdges);
|
|
410
|
+
const batchLatest = latestStepByNode(batchSteps);
|
|
380
411
|
for (const [nodeId, node] of nextBatch) {
|
|
381
412
|
if (executedInThisWalk.has(nodeId)) continue; // Already done in this walk
|
|
382
413
|
|
|
383
414
|
const allPreds = getAllPredecessors(def, nodeId);
|
|
384
|
-
const
|
|
415
|
+
for (const predId of allPreds) {
|
|
416
|
+
if (completedNodeIds.has(predId)) continue;
|
|
417
|
+
const latest = batchLatest.get(predId);
|
|
418
|
+
if (latest?.status !== "completed") continue;
|
|
419
|
+
completedNodeIds.add(predId);
|
|
420
|
+
if (latest.output !== undefined) ctx[predId] = latest.output;
|
|
421
|
+
}
|
|
422
|
+
const activePreds = allPreds.filter(
|
|
423
|
+
(predId) => activeEdges.has(`${predId}→${nodeId}`) || batchAwaited.has(predId),
|
|
424
|
+
);
|
|
385
425
|
const allActivePredsCompleted = activePreds.every((p) => completedNodeIds.has(p));
|
|
386
426
|
|
|
387
427
|
if (allActivePredsCompleted) {
|
|
@@ -405,6 +445,8 @@ async function walkGraphOwned(
|
|
|
405
445
|
if (!run || run.status !== "running") return;
|
|
406
446
|
|
|
407
447
|
const finalSteps = await getWorkflowRunStepsByRunId(runId);
|
|
448
|
+
// Another walker is still executing a branch; it finalizes the run.
|
|
449
|
+
if (hasRunningStep(finalSteps)) return;
|
|
408
450
|
const hasWaitingSteps = finalSteps.some((s) => s.status === "waiting");
|
|
409
451
|
const hasPendingRetries = finalSteps.some(
|
|
410
452
|
(s) => s.status === "failed" && s.nextRetryAt != null,
|
|
@@ -453,6 +495,42 @@ async function walkGraphOwned(
|
|
|
453
495
|
});
|
|
454
496
|
}
|
|
455
497
|
|
|
498
|
+
/** Each node's latest step. Steps arrive in insert order (startedAt ASC). */
|
|
499
|
+
function latestStepByNode(steps: WorkflowRunStep[]): Map<string, WorkflowRunStep> {
|
|
500
|
+
const latest = new Map<string, WorkflowRunStep>();
|
|
501
|
+
for (const step of steps) latest.set(step.nodeId, step);
|
|
502
|
+
return latest;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Nodes a join must wait for even without an active edge to it: the node's
|
|
507
|
+
* latest step is running or waiting, or a predecessor routed to it and it has
|
|
508
|
+
* no step yet. A terminally failed node is not awaited, so partial failure
|
|
509
|
+
* still lets the join run. An older `running` row superseded by a newer one
|
|
510
|
+
* (crash recovery) and the `pending` row a user retry leaves behind are
|
|
511
|
+
* ignored because only the latest step counts.
|
|
512
|
+
*/
|
|
513
|
+
function awaitedNodeIds(steps: WorkflowRunStep[], activeEdges: Set<string>): Set<string> {
|
|
514
|
+
const latest = latestStepByNode(steps);
|
|
515
|
+
const awaited = new Set<string>();
|
|
516
|
+
for (const [nodeId, step] of latest) {
|
|
517
|
+
if (step.status === "running" || step.status === "waiting") awaited.add(nodeId);
|
|
518
|
+
}
|
|
519
|
+
for (const edge of activeEdges) {
|
|
520
|
+
const target = edge.slice(edge.indexOf("→") + 1);
|
|
521
|
+
if (!latest.has(target)) awaited.add(target);
|
|
522
|
+
}
|
|
523
|
+
return awaited;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** True while some node's latest step is still executing. */
|
|
527
|
+
export function hasRunningStep(steps: WorkflowRunStep[]): boolean {
|
|
528
|
+
for (const step of latestStepByNode(steps).values()) {
|
|
529
|
+
if (step.status === "running") return true;
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
456
534
|
/**
|
|
457
535
|
* Restore completed node outputs from their step checkpoints into workflow context.
|
|
458
536
|
* Every recovery path must do this before resolving a node's declared inputs.
|
|
@@ -541,12 +619,28 @@ async function executeStep(
|
|
|
541
619
|
// rides the INSERT itself — so the UNIQUE(idempotencyKey) index arbitrates
|
|
542
620
|
// concurrent executions of the same node instead of an orphan step row
|
|
543
621
|
// committing before a follow-up key UPDATE throws.
|
|
544
|
-
|
|
622
|
+
let claimedStepId: string | undefined;
|
|
623
|
+
const claimStep = async () => {
|
|
545
624
|
const run = await getWorkflowRun(runId);
|
|
546
625
|
if (!run || (run.status !== "running" && run.status !== "waiting")) {
|
|
547
626
|
return { halted: true as const };
|
|
548
627
|
}
|
|
549
628
|
|
|
629
|
+
// Another walker already started or finished this node for the same
|
|
630
|
+
// predecessor completions (two branch completions both walking the join,
|
|
631
|
+
// or a user retry reaching a branch the live walk is running). The
|
|
632
|
+
// row-count key below is always new, so only this read, inside the insert
|
|
633
|
+
// transaction, stops a second execution. Foreach re-enters its own
|
|
634
|
+
// non-terminal row below and keeps that path.
|
|
635
|
+
if (node.type !== "foreach") {
|
|
636
|
+
const current = await getCurrentStepForNode(runId, node.id, getAllPredecessors(def, node.id));
|
|
637
|
+
// A `running` row this process is not executing was orphaned by a
|
|
638
|
+
// crash; recovery must be able to run the node again.
|
|
639
|
+
if (current && (current.status !== "running" || executingSteps.has(current.id))) {
|
|
640
|
+
return { current };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
550
644
|
// Count existing steps for this node to determine the current iteration.
|
|
551
645
|
const iteration = await getStepCountForNode(runId, node.id);
|
|
552
646
|
const idempotencyKey = `${runId}:${node.id}:${iteration}`;
|
|
@@ -584,10 +678,27 @@ async function executeStep(
|
|
|
584
678
|
idempotencyKey,
|
|
585
679
|
});
|
|
586
680
|
}
|
|
681
|
+
// Registered before the transaction commits, so no other walker can see
|
|
682
|
+
// this `running` row without also seeing it owned.
|
|
683
|
+
claimedStepId = stepId;
|
|
684
|
+
executingSteps.add(stepId);
|
|
587
685
|
return { existingStep, stepId, deduped: false };
|
|
588
|
-
}
|
|
686
|
+
};
|
|
687
|
+
const dedup = await getDbClient()
|
|
688
|
+
.transaction(claimStep)
|
|
689
|
+
.catch((err) => {
|
|
690
|
+
if (claimedStepId) executingSteps.delete(claimedStepId);
|
|
691
|
+
throw err;
|
|
692
|
+
});
|
|
589
693
|
|
|
590
694
|
if ("halted" in dedup) return { outcome: "completed", successors: [] };
|
|
695
|
+
if ("current" in dedup && dedup.current) {
|
|
696
|
+
// The walker that owns the step routes its successors and finalizes the
|
|
697
|
+
// run; this one must not route them again.
|
|
698
|
+
if (dedup.current.status !== "completed") return { outcome: "waiting", successors: [] };
|
|
699
|
+
ctx[node.id] = dedup.current.output;
|
|
700
|
+
return { outcome: "completed", successors: [] };
|
|
701
|
+
}
|
|
591
702
|
|
|
592
703
|
if (dedup.deduped && dedup.existingStep) {
|
|
593
704
|
if (dedup.existingStep.status === "completed") {
|
|
@@ -601,9 +712,35 @@ async function executeStep(
|
|
|
601
712
|
// Don't create a duplicate — just report as waiting.
|
|
602
713
|
return { outcome: "waiting", successors: [] };
|
|
603
714
|
}
|
|
604
|
-
|
|
605
|
-
|
|
715
|
+
try {
|
|
716
|
+
return await runClaimedStep(
|
|
717
|
+
def,
|
|
718
|
+
runId,
|
|
719
|
+
ctx,
|
|
720
|
+
node,
|
|
721
|
+
registry,
|
|
722
|
+
dedup.existingStep,
|
|
723
|
+
dedup.stepId,
|
|
724
|
+
workflowId,
|
|
725
|
+
options,
|
|
726
|
+
);
|
|
727
|
+
} finally {
|
|
728
|
+
executingSteps.delete(dedup.stepId);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
606
731
|
|
|
732
|
+
/** Steps 3-10 of executeStep, for a step row this call inserted and owns. */
|
|
733
|
+
async function runClaimedStep(
|
|
734
|
+
def: WorkflowDefinition,
|
|
735
|
+
runId: string,
|
|
736
|
+
ctx: Record<string, unknown>,
|
|
737
|
+
node: WorkflowNode,
|
|
738
|
+
registry: ExecutorRegistry,
|
|
739
|
+
existingStep: WorkflowRunStep | null | undefined,
|
|
740
|
+
stepId: string,
|
|
741
|
+
workflowId?: string,
|
|
742
|
+
options: WorkflowExecutionOptions = {},
|
|
743
|
+
): Promise<StepResult> {
|
|
607
744
|
// 3. Get executor
|
|
608
745
|
const executor = registry.get(node.type);
|
|
609
746
|
|
|
@@ -777,16 +914,10 @@ async function executeStep(
|
|
|
777
914
|
|
|
778
915
|
// 8. Set nextPort from validation result for record-based routing
|
|
779
916
|
// When validation determines pass/fail and the node uses port-based `next`,
|
|
780
|
-
// route to the correct port instead of activating all ports.
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
node.next &&
|
|
785
|
-
typeof node.next === "object" &&
|
|
786
|
-
!Array.isArray(node.next)
|
|
787
|
-
) {
|
|
788
|
-
result.nextPort = validationResult.passed ? "pass" : "fail";
|
|
789
|
-
}
|
|
917
|
+
// route to the correct port instead of activating all ports. An executor port
|
|
918
|
+
// that `next` does not declare (script's "success" vs `{ pass, fail }`) is
|
|
919
|
+
// overridden too, otherwise the branch would dead-end.
|
|
920
|
+
result.nextPort = resolveValidationPort(node.next, result.nextPort, validationResult?.passed);
|
|
790
921
|
|
|
791
922
|
// 9. Checkpoint success
|
|
792
923
|
await checkpointStep(runId, stepId, node.id, result, ctx);
|
|
@@ -380,7 +380,7 @@ export class HumanInTheLoopExecutor extends BaseExecutor<
|
|
|
380
380
|
elements: [
|
|
381
381
|
{
|
|
382
382
|
type: "mrkdwn",
|
|
383
|
-
text: `⏱ Timeout: ${formatTimeout(config.timeout.seconds)} —
|
|
383
|
+
text: `⏱ Timeout: ${formatTimeout(config.timeout.seconds)} — times out if not responded`,
|
|
384
384
|
},
|
|
385
385
|
],
|
|
386
386
|
},
|