@provos/ironcurtain 0.9.1 → 0.11.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/README.md +69 -10
- package/dist/auth/oauth-provider.js +1 -4
- package/dist/auth/oauth-provider.js.map +1 -1
- package/dist/cli.js +40 -2
- package/dist/cli.js.map +1 -1
- package/dist/config/config-command.js +174 -10
- package/dist/config/config-command.js.map +1 -1
- package/dist/config/first-start.js +61 -0
- package/dist/config/first-start.js.map +1 -1
- package/dist/config/generated/compiled-policy.json +528 -161
- package/dist/config/generated/test-scenarios.json +4810 -170
- package/dist/config/generated/tool-annotations.json +34 -47
- package/dist/config/index.d.ts +58 -0
- package/dist/config/index.js +97 -15
- package/dist/config/index.js.map +1 -1
- package/dist/config/mcp-servers.json +2 -2
- package/dist/config/model-provider.d.ts +8 -2
- package/dist/config/model-provider.js +52 -9
- package/dist/config/model-provider.js.map +1 -1
- package/dist/config/paths.d.ts +181 -0
- package/dist/config/paths.js +326 -23
- package/dist/config/paths.js.map +1 -1
- package/dist/config/types.d.ts +7 -1
- package/dist/config/user-config.d.ts +61 -4
- package/dist/config/user-config.js +108 -21
- package/dist/config/user-config.js.map +1 -1
- package/dist/config/validate-policy-dir.d.ts +10 -0
- package/dist/config/validate-policy-dir.js +59 -0
- package/dist/config/validate-policy-dir.js.map +1 -0
- package/dist/cron/compile-task-policy.js +1 -0
- package/dist/cron/compile-task-policy.js.map +1 -1
- package/dist/cron/job-commands.js +22 -2
- package/dist/cron/job-commands.js.map +1 -1
- package/dist/cron/types.d.ts +16 -2
- package/dist/cron/types.js +1 -3
- package/dist/cron/types.js.map +1 -1
- package/dist/daemon/daemon-command.js +16 -2
- package/dist/daemon/daemon-command.js.map +1 -1
- package/dist/daemon/ironcurtain-daemon.d.ts +44 -1
- package/dist/daemon/ironcurtain-daemon.js +210 -8
- package/dist/daemon/ironcurtain-daemon.js.map +1 -1
- package/dist/docker/adapters/claude-code.d.ts +2 -1
- package/dist/docker/adapters/claude-code.js +320 -131
- package/dist/docker/adapters/claude-code.js.map +1 -1
- package/dist/docker/adapters/goose.d.ts +12 -11
- package/dist/docker/adapters/goose.js +80 -27
- package/dist/docker/adapters/goose.js.map +1 -1
- package/dist/docker/agent-adapter.d.ts +178 -4
- package/dist/docker/agent-adapter.js +14 -0
- package/dist/docker/agent-adapter.js.map +1 -1
- package/dist/docker/agent-registry.js +3 -2
- package/dist/docker/agent-registry.js.map +1 -1
- package/dist/docker/code-mode-proxy.d.ts +27 -0
- package/dist/docker/code-mode-proxy.js +3 -0
- package/dist/docker/code-mode-proxy.js.map +1 -1
- package/dist/docker/container-lifecycle.d.ts +15 -0
- package/dist/docker/container-lifecycle.js +32 -0
- package/dist/docker/container-lifecycle.js.map +1 -0
- package/dist/docker/docker-agent-session.d.ts +113 -62
- package/dist/docker/docker-agent-session.js +241 -300
- package/dist/docker/docker-agent-session.js.map +1 -1
- package/dist/docker/docker-infrastructure.d.ts +256 -21
- package/dist/docker/docker-infrastructure.js +585 -30
- package/dist/docker/docker-infrastructure.js.map +1 -1
- package/dist/docker/docker-manager.d.ts +36 -1
- package/dist/docker/docker-manager.js +168 -26
- package/dist/docker/docker-manager.js.map +1 -1
- package/dist/docker/docker-progress-sink.d.ts +43 -0
- package/dist/docker/docker-progress-sink.js +299 -0
- package/dist/docker/docker-progress-sink.js.map +1 -0
- package/dist/docker/mitm-proxy.d.ts +90 -1
- package/dist/docker/mitm-proxy.js +720 -40
- package/dist/docker/mitm-proxy.js.map +1 -1
- package/dist/docker/oauth-credentials.d.ts +46 -3
- package/dist/docker/oauth-credentials.js +56 -14
- package/dist/docker/oauth-credentials.js.map +1 -1
- package/dist/docker/oauth-token-manager.js +2 -2
- package/dist/docker/oauth-token-manager.js.map +1 -1
- package/dist/docker/orientation.d.ts +1 -1
- package/dist/docker/provider-config.d.ts +81 -2
- package/dist/docker/provider-config.js +222 -28
- package/dist/docker/provider-config.js.map +1 -1
- package/dist/docker/proxy-tools.d.ts +0 -2
- package/dist/docker/proxy-tools.js +2 -7
- package/dist/docker/proxy-tools.js.map +1 -1
- package/dist/docker/pty-session.d.ts +49 -1
- package/dist/docker/pty-session.js +212 -90
- package/dist/docker/pty-session.js.map +1 -1
- package/dist/docker/pty-types.d.ts +0 -2
- package/dist/docker/pty-types.js +0 -2
- package/dist/docker/pty-types.js.map +1 -1
- package/dist/docker/resource-limits.d.ts +125 -0
- package/dist/docker/resource-limits.js +200 -0
- package/dist/docker/resource-limits.js.map +1 -0
- package/dist/docker/spawn-with-idle-timeout.d.ts +39 -0
- package/dist/docker/spawn-with-idle-timeout.js +152 -0
- package/dist/docker/spawn-with-idle-timeout.js.map +1 -0
- package/dist/docker/sse-extractor.d.ts +51 -0
- package/dist/docker/sse-extractor.js +303 -0
- package/dist/docker/sse-extractor.js.map +1 -0
- package/dist/docker/token-stream-bus.d.ts +83 -0
- package/dist/docker/token-stream-bus.js +98 -0
- package/dist/docker/token-stream-bus.js.map +1 -0
- package/dist/docker/token-stream-types.d.ts +71 -0
- package/dist/docker/token-stream-types.js +8 -0
- package/dist/docker/token-stream-types.js.map +1 -0
- package/dist/docker/types.d.ts +61 -6
- package/dist/doctor/checks.d.ts +138 -0
- package/dist/doctor/checks.js +511 -0
- package/dist/doctor/checks.js.map +1 -0
- package/dist/doctor/doctor-command.d.ts +29 -0
- package/dist/doctor/doctor-command.js +160 -0
- package/dist/doctor/doctor-command.js.map +1 -0
- package/dist/doctor/mcp-liveness.d.ts +28 -0
- package/dist/doctor/mcp-liveness.js +84 -0
- package/dist/doctor/mcp-liveness.js.map +1 -0
- package/dist/doctor/oauth-checks.d.ts +23 -0
- package/dist/doctor/oauth-checks.js +157 -0
- package/dist/doctor/oauth-checks.js.map +1 -0
- package/dist/doctor/output.d.ts +26 -0
- package/dist/doctor/output.js +82 -0
- package/dist/doctor/output.js.map +1 -0
- package/dist/escalation/listener-command.js +0 -3
- package/dist/escalation/listener-command.js.map +1 -1
- package/dist/event-bus/typed-event-bus.d.ts +26 -0
- package/dist/event-bus/typed-event-bus.js +35 -0
- package/dist/event-bus/typed-event-bus.js.map +1 -0
- package/dist/index.js +32 -9
- package/dist/index.js.map +1 -1
- package/dist/logger.js +31 -2
- package/dist/logger.js.map +1 -1
- package/dist/memory/auto-save.d.ts +18 -4
- package/dist/memory/auto-save.js +46 -6
- package/dist/memory/auto-save.js.map +1 -1
- package/dist/memory/memory-policy.d.ts +41 -0
- package/dist/memory/memory-policy.js +36 -0
- package/dist/memory/memory-policy.js.map +1 -0
- package/dist/mux/mux-app.d.ts +2 -0
- package/dist/mux/mux-app.js +30 -34
- package/dist/mux/mux-app.js.map +1 -1
- package/dist/mux/mux-command.d.ts +10 -1
- package/dist/mux/mux-command.js +62 -35
- package/dist/mux/mux-command.js.map +1 -1
- package/dist/mux/mux-renderer.js +22 -17
- package/dist/mux/mux-renderer.js.map +1 -1
- package/dist/mux/pty-bridge.d.ts +2 -0
- package/dist/mux/pty-bridge.js +3 -0
- package/dist/mux/pty-bridge.js.map +1 -1
- package/dist/mux/session-scanner.d.ts +7 -0
- package/dist/mux/session-scanner.js +21 -2
- package/dist/mux/session-scanner.js.map +1 -1
- package/dist/mux/types.d.ts +0 -4
- package/dist/mux/types.js.map +1 -1
- package/dist/{pipeline → observability}/llm-logger.d.ts +9 -3
- package/dist/{pipeline → observability}/llm-logger.js +16 -4
- package/dist/observability/llm-logger.js.map +1 -0
- package/dist/observe/observe-command.d.ts +12 -0
- package/dist/observe/observe-command.js +298 -0
- package/dist/observe/observe-command.js.map +1 -0
- package/dist/observe/observe-renderer.d.ts +24 -0
- package/dist/observe/observe-renderer.js +87 -0
- package/dist/observe/observe-renderer.js.map +1 -0
- package/dist/observe/observe-tui-rain.d.ts +42 -0
- package/dist/observe/observe-tui-rain.js +531 -0
- package/dist/observe/observe-tui-rain.js.map +1 -0
- package/dist/observe/observe-tui-text-panel.d.ts +46 -0
- package/dist/observe/observe-tui-text-panel.js +525 -0
- package/dist/observe/observe-tui-text-panel.js.map +1 -0
- package/dist/observe/observe-tui-types.d.ts +270 -0
- package/dist/observe/observe-tui-types.js +200 -0
- package/dist/observe/observe-tui-types.js.map +1 -0
- package/dist/observe/observe-tui-word-scorer.d.ts +143 -0
- package/dist/observe/observe-tui-word-scorer.js +572 -0
- package/dist/observe/observe-tui-word-scorer.js.map +1 -0
- package/dist/observe/observe-tui.d.ts +59 -0
- package/dist/observe/observe-tui.js +592 -0
- package/dist/observe/observe-tui.js.map +1 -0
- package/dist/persona/compile-persona-policy.js +1 -0
- package/dist/persona/compile-persona-policy.js.map +1 -1
- package/dist/persona/memory-gate.d.ts +19 -0
- package/dist/persona/memory-gate.js +33 -0
- package/dist/persona/memory-gate.js.map +1 -0
- package/dist/persona/persona-command.js +41 -6
- package/dist/persona/persona-command.js.map +1 -1
- package/dist/persona/resolve.d.ts +22 -0
- package/dist/persona/resolve.js +45 -2
- package/dist/persona/resolve.js.map +1 -1
- package/dist/persona/types.d.ts +16 -0
- package/dist/pipeline/annotate.d.ts +7 -1
- package/dist/pipeline/annotate.js +92 -2
- package/dist/pipeline/annotate.js.map +1 -1
- package/dist/pipeline/compile.js +2 -0
- package/dist/pipeline/compile.js.map +1 -1
- package/dist/pipeline/constitution-compiler.d.ts +25 -14
- package/dist/pipeline/constitution-compiler.js +107 -38
- package/dist/pipeline/constitution-compiler.js.map +1 -1
- package/dist/pipeline/dynamic-list-types.d.ts +3 -6
- package/dist/pipeline/dynamic-list-types.js +3 -16
- package/dist/pipeline/dynamic-list-types.js.map +1 -1
- package/dist/pipeline/parallel-progress.d.ts +59 -0
- package/dist/pipeline/parallel-progress.js +263 -0
- package/dist/pipeline/parallel-progress.js.map +1 -0
- package/dist/pipeline/pipeline-runner.d.ts +49 -8
- package/dist/pipeline/pipeline-runner.js +419 -231
- package/dist/pipeline/pipeline-runner.js.map +1 -1
- package/dist/pipeline/pipeline-shared.d.ts +59 -1
- package/dist/pipeline/pipeline-shared.js +127 -12
- package/dist/pipeline/pipeline-shared.js.map +1 -1
- package/dist/pipeline/policy-verifier.js +16 -3
- package/dist/pipeline/policy-verifier.js.map +1 -1
- package/dist/pipeline/proxy-mcp-connections.js +17 -2
- package/dist/pipeline/proxy-mcp-connections.js.map +1 -1
- package/dist/pipeline/scenario-generator.d.ts +4 -8
- package/dist/pipeline/scenario-generator.js +13 -46
- package/dist/pipeline/scenario-generator.js.map +1 -1
- package/dist/pipeline/server-prefilter.d.ts +31 -0
- package/dist/pipeline/server-prefilter.js +115 -0
- package/dist/pipeline/server-prefilter.js.map +1 -0
- package/dist/pipeline/types.d.ts +5 -0
- package/dist/sandbox/index.d.ts +14 -0
- package/dist/sandbox/index.js +335 -116
- package/dist/sandbox/index.js.map +1 -1
- package/dist/sandbox/ironcurtain-protocol.d.ts +52 -0
- package/dist/sandbox/ironcurtain-protocol.js +185 -0
- package/dist/sandbox/ironcurtain-protocol.js.map +1 -0
- package/dist/session/agent-session.d.ts +6 -0
- package/dist/session/agent-session.js +16 -5
- package/dist/session/agent-session.js.map +1 -1
- package/dist/session/cli-transport.js +0 -6
- package/dist/session/cli-transport.js.map +1 -1
- package/dist/session/index.d.ts +35 -14
- package/dist/session/index.js +324 -130
- package/dist/session/index.js.map +1 -1
- package/dist/session/preflight.d.ts +50 -13
- package/dist/session/preflight.js +252 -80
- package/dist/session/preflight.js.map +1 -1
- package/dist/session/prompts.d.ts +2 -5
- package/dist/session/prompts.js.map +1 -1
- package/dist/session/session-manager.d.ts +61 -6
- package/dist/session/session-manager.js +72 -10
- package/dist/session/session-manager.js.map +1 -1
- package/dist/session/session-metadata.d.ts +21 -5
- package/dist/session/session-metadata.js +34 -12
- package/dist/session/session-metadata.js.map +1 -1
- package/dist/session/types.d.ts +311 -5
- package/dist/session/types.js +57 -1
- package/dist/session/types.js.map +1 -1
- package/dist/signal/setup-signal.js +13 -21
- package/dist/signal/setup-signal.js.map +1 -1
- package/dist/signal/signal-bot-daemon.d.ts +16 -1
- package/dist/signal/signal-bot-daemon.js +39 -27
- package/dist/signal/signal-bot-daemon.js.map +1 -1
- package/dist/signal/signal-container.js +7 -1
- package/dist/signal/signal-container.js.map +1 -1
- package/dist/skills/discovery.d.ts +77 -0
- package/dist/skills/discovery.js +206 -0
- package/dist/skills/discovery.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.js +3 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/staging.d.ts +28 -0
- package/dist/skills/staging.js +68 -0
- package/dist/skills/staging.js.map +1 -0
- package/dist/skills/types.d.ts +46 -0
- package/dist/skills/types.js +2 -0
- package/dist/skills/types.js.map +1 -0
- package/dist/trusted-process/approval-whitelist.d.ts +0 -2
- package/dist/trusted-process/approval-whitelist.js +17 -17
- package/dist/trusted-process/approval-whitelist.js.map +1 -1
- package/dist/trusted-process/async-mutex.d.ts +32 -0
- package/dist/trusted-process/async-mutex.js +50 -0
- package/dist/trusted-process/async-mutex.js.map +1 -0
- package/dist/trusted-process/audit-log.d.ts +38 -1
- package/dist/trusted-process/audit-log.js +82 -13
- package/dist/trusted-process/audit-log.js.map +1 -1
- package/dist/trusted-process/call-circuit-breaker.js +2 -1
- package/dist/trusted-process/call-circuit-breaker.js.map +1 -1
- package/dist/trusted-process/control-server.d.ts +101 -0
- package/dist/trusted-process/control-server.js +282 -0
- package/dist/trusted-process/control-server.js.map +1 -0
- package/dist/trusted-process/domain-utils.d.ts +6 -6
- package/dist/trusted-process/domain-utils.js +7 -23
- package/dist/trusted-process/domain-utils.js.map +1 -1
- package/dist/trusted-process/error-prefixes.d.ts +16 -0
- package/dist/trusted-process/error-prefixes.js +17 -0
- package/dist/trusted-process/error-prefixes.js.map +1 -0
- package/dist/trusted-process/index.d.ts +40 -23
- package/dist/trusted-process/index.js +131 -225
- package/dist/trusted-process/index.js.map +1 -1
- package/dist/trusted-process/list-matcher.d.ts +17 -0
- package/dist/trusted-process/list-matcher.js +31 -0
- package/dist/trusted-process/list-matcher.js.map +1 -0
- package/dist/trusted-process/mcp-client-manager.d.ts +35 -6
- package/dist/trusted-process/mcp-client-manager.js +40 -45
- package/dist/trusted-process/mcp-client-manager.js.map +1 -1
- package/dist/trusted-process/mcp-proxy-server.d.ts +31 -120
- package/dist/trusted-process/mcp-proxy-server.js +166 -657
- package/dist/trusted-process/mcp-proxy-server.js.map +1 -1
- package/dist/trusted-process/policy-engine.d.ts +16 -4
- package/dist/trusted-process/policy-engine.js +90 -45
- package/dist/trusted-process/policy-engine.js.map +1 -1
- package/dist/trusted-process/policy-roots.d.ts +6 -0
- package/dist/trusted-process/policy-roots.js +20 -0
- package/dist/trusted-process/policy-roots.js.map +1 -1
- package/dist/trusted-process/sandbox-integration.d.ts +12 -0
- package/dist/trusted-process/sandbox-integration.js +26 -0
- package/dist/trusted-process/sandbox-integration.js.map +1 -1
- package/dist/trusted-process/tool-call-coordinator.d.ts +269 -0
- package/dist/trusted-process/tool-call-coordinator.js +508 -0
- package/dist/trusted-process/tool-call-coordinator.js.map +1 -0
- package/dist/trusted-process/tool-call-pipeline.d.ts +205 -0
- package/dist/trusted-process/tool-call-pipeline.js +798 -0
- package/dist/trusted-process/tool-call-pipeline.js.map +1 -0
- package/dist/trusted-process/tool-description-hints.d.ts +1 -1
- package/dist/types/argument-roles.d.ts +1 -1
- package/dist/types/argument-roles.js +14 -1
- package/dist/types/argument-roles.js.map +1 -1
- package/dist/types/audit.d.ts +9 -0
- package/dist/{session → types}/errors.d.ts +8 -2
- package/dist/{session → types}/errors.js +8 -2
- package/dist/types/errors.js.map +1 -0
- package/dist/types/reserved-servers.d.ts +11 -0
- package/dist/types/reserved-servers.js +12 -0
- package/dist/types/reserved-servers.js.map +1 -0
- package/dist/types/server-listing.d.ts +14 -0
- package/dist/types/server-listing.js +2 -0
- package/dist/types/server-listing.js.map +1 -0
- package/dist/types/slug.d.ts +2 -0
- package/dist/types/slug.js +2 -0
- package/dist/types/slug.js.map +1 -1
- package/dist/types/url-normalize.d.ts +16 -0
- package/dist/types/url-normalize.js +33 -0
- package/dist/types/url-normalize.js.map +1 -0
- package/dist/utils/error-message.d.ts +8 -0
- package/dist/utils/error-message.js +11 -0
- package/dist/utils/error-message.js.map +1 -0
- package/dist/utils/exec-error.d.ts +19 -0
- package/dist/utils/exec-error.js +16 -0
- package/dist/utils/exec-error.js.map +1 -0
- package/dist/utils/is-plain-object.d.ts +7 -0
- package/dist/utils/is-plain-object.js +12 -0
- package/dist/utils/is-plain-object.js.map +1 -0
- package/dist/utils/preflight-checks.d.ts +16 -0
- package/dist/utils/preflight-checks.js +178 -0
- package/dist/utils/preflight-checks.js.map +1 -0
- package/dist/web-ui/__tests__/json-rpc-dispatch.test.d.ts +7 -0
- package/dist/web-ui/__tests__/json-rpc-dispatch.test.js +725 -0
- package/dist/web-ui/__tests__/json-rpc-dispatch.test.js.map +1 -0
- package/dist/web-ui/dispatch/escalation-dispatch.d.ts +7 -0
- package/dist/web-ui/dispatch/escalation-dispatch.js +52 -0
- package/dist/web-ui/dispatch/escalation-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/job-dispatch.d.ts +8 -0
- package/dist/web-ui/dispatch/job-dispatch.js +83 -0
- package/dist/web-ui/dispatch/job-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/persona-dispatch.d.ts +6 -0
- package/dist/web-ui/dispatch/persona-dispatch.js +116 -0
- package/dist/web-ui/dispatch/persona-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/session-dispatch.d.ts +9 -0
- package/dist/web-ui/dispatch/session-dispatch.js +201 -0
- package/dist/web-ui/dispatch/session-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/token-stream-dispatch.d.ts +15 -0
- package/dist/web-ui/dispatch/token-stream-dispatch.js +55 -0
- package/dist/web-ui/dispatch/token-stream-dispatch.js.map +1 -0
- package/dist/web-ui/dispatch/types.d.ts +31 -0
- package/dist/web-ui/dispatch/types.js +68 -0
- package/dist/web-ui/dispatch/types.js.map +1 -0
- package/dist/web-ui/dispatch/workflow-dispatch.d.ts +103 -0
- package/dist/web-ui/dispatch/workflow-dispatch.js +925 -0
- package/dist/web-ui/dispatch/workflow-dispatch.js.map +1 -0
- package/dist/web-ui/json-rpc-dispatch.d.ts +12 -0
- package/dist/web-ui/json-rpc-dispatch.js +34 -0
- package/dist/web-ui/json-rpc-dispatch.js.map +1 -0
- package/dist/web-ui/state-graph.d.ts +13 -0
- package/dist/web-ui/state-graph.js +82 -0
- package/dist/web-ui/state-graph.js.map +1 -0
- package/dist/web-ui/token-stream-bridge.d.ts +76 -0
- package/dist/web-ui/token-stream-bridge.js +265 -0
- package/dist/web-ui/token-stream-bridge.js.map +1 -0
- package/dist/web-ui/web-event-bus.d.ts +152 -0
- package/dist/web-ui/web-event-bus.js +19 -0
- package/dist/web-ui/web-event-bus.js.map +1 -0
- package/dist/web-ui/web-session-transport.d.ts +31 -0
- package/dist/web-ui/web-session-transport.js +109 -0
- package/dist/web-ui/web-session-transport.js.map +1 -0
- package/dist/web-ui/web-ui-server.d.ts +76 -0
- package/dist/web-ui/web-ui-server.js +461 -0
- package/dist/web-ui/web-ui-server.js.map +1 -0
- package/dist/web-ui/web-ui-types.d.ts +290 -0
- package/dist/web-ui/web-ui-types.js +32 -0
- package/dist/web-ui/web-ui-types.js.map +1 -0
- package/dist/web-ui/ws-utils.d.ts +5 -0
- package/dist/web-ui/ws-utils.js +12 -0
- package/dist/web-ui/ws-utils.js.map +1 -0
- package/dist/web-ui-static/assets/index-BFO5z1js.js +76 -0
- package/dist/web-ui-static/assets/index-CCUwrkIL.css +1 -0
- package/dist/web-ui-static/assets/orbitron-latin-700-normal-4jsRXGGJ.woff2 +0 -0
- package/dist/web-ui-static/assets/orbitron-latin-700-normal-DmYOh-W9.woff +0 -0
- package/dist/web-ui-static/index.html +14 -0
- package/dist/workflow/artifacts.d.ts +59 -0
- package/dist/workflow/artifacts.js +188 -0
- package/dist/workflow/artifacts.js.map +1 -0
- package/dist/workflow/checkpoint-selection.d.ts +34 -0
- package/dist/workflow/checkpoint-selection.js +48 -0
- package/dist/workflow/checkpoint-selection.js.map +1 -0
- package/dist/workflow/checkpoint.d.ts +35 -0
- package/dist/workflow/checkpoint.js +84 -0
- package/dist/workflow/checkpoint.js.map +1 -0
- package/dist/workflow/cli-support.d.ts +92 -0
- package/dist/workflow/cli-support.js +423 -0
- package/dist/workflow/cli-support.js.map +1 -0
- package/dist/workflow/definition-loader.d.ts +32 -0
- package/dist/workflow/definition-loader.js +56 -0
- package/dist/workflow/definition-loader.js.map +1 -0
- package/dist/workflow/discovery.d.ts +78 -0
- package/dist/workflow/discovery.js +203 -0
- package/dist/workflow/discovery.js.map +1 -0
- package/dist/workflow/errors.d.ts +66 -0
- package/dist/workflow/errors.js +49 -0
- package/dist/workflow/errors.js.map +1 -0
- package/dist/workflow/guards.d.ts +8 -0
- package/dist/workflow/guards.js +38 -0
- package/dist/workflow/guards.js.map +1 -0
- package/dist/workflow/lint-integration.d.ts +64 -0
- package/dist/workflow/lint-integration.js +56 -0
- package/dist/workflow/lint-integration.js.map +1 -0
- package/dist/workflow/lint.d.ts +66 -0
- package/dist/workflow/lint.js +410 -0
- package/dist/workflow/lint.js.map +1 -0
- package/dist/workflow/machine-builder.d.ts +90 -0
- package/dist/workflow/machine-builder.js +485 -0
- package/dist/workflow/machine-builder.js.map +1 -0
- package/dist/workflow/message-log.d.ts +85 -0
- package/dist/workflow/message-log.js +40 -0
- package/dist/workflow/message-log.js.map +1 -0
- package/dist/workflow/orchestrator.d.ts +450 -0
- package/dist/workflow/orchestrator.js +1941 -0
- package/dist/workflow/orchestrator.js.map +1 -0
- package/dist/workflow/prompt-builder.d.ts +39 -0
- package/dist/workflow/prompt-builder.js +242 -0
- package/dist/workflow/prompt-builder.js.map +1 -0
- package/dist/workflow/run-state-command.d.ts +9 -0
- package/dist/workflow/run-state-command.js +414 -0
- package/dist/workflow/run-state-command.js.map +1 -0
- package/dist/workflow/run-state-debug-capture.d.ts +40 -0
- package/dist/workflow/run-state-debug-capture.js +101 -0
- package/dist/workflow/run-state-debug-capture.js.map +1 -0
- package/dist/workflow/status-parser.d.ts +68 -0
- package/dist/workflow/status-parser.js +259 -0
- package/dist/workflow/status-parser.js.map +1 -0
- package/dist/workflow/types.d.ts +498 -0
- package/dist/workflow/types.js +83 -0
- package/dist/workflow/types.js.map +1 -0
- package/dist/workflow/validate.d.ts +58 -0
- package/dist/workflow/validate.js +499 -0
- package/dist/workflow/validate.js.map +1 -0
- package/dist/workflow/workflow-command.d.ts +26 -0
- package/dist/workflow/workflow-command.js +648 -0
- package/dist/workflow/workflow-command.js.map +1 -0
- package/dist/workflow/workflow-discovery.d.ts +25 -0
- package/dist/workflow/workflow-discovery.js +100 -0
- package/dist/workflow/workflow-discovery.js.map +1 -0
- package/dist/workflow/workflow-manager.d.ts +122 -0
- package/dist/workflow/workflow-manager.js +308 -0
- package/dist/workflow/workflow-manager.js.map +1 -0
- package/dist/workflow/workflows/design-and-code/workflow.yaml +169 -0
- package/dist/workflow/workflows/test-email-summary/skills/email-formatting/SKILL.md +55 -0
- package/dist/workflow/workflows/test-email-summary/skills/gmail-query-syntax/SKILL.md +63 -0
- package/dist/workflow/workflows/test-email-summary/skills/json-schema-emails/SKILL.md +41 -0
- package/dist/workflow/workflows/test-email-summary/skills/json-schema-emails/schema.json +34 -0
- package/dist/workflow/workflows/test-email-summary/skills/summary-style-guide/SKILL.md +51 -0
- package/dist/workflow/workflows/test-email-summary/workflow.yaml +89 -0
- package/dist/workflow/workflows/vuln-discovery/skills/harness-design-fuzzing/SKILL.md +201 -0
- package/dist/workflow/workflows/vuln-discovery/skills/memory-safety-c-cpp/SKILL.md +166 -0
- package/dist/workflow/workflows/vuln-discovery/skills/vulnerability-triage/SKILL.md +185 -0
- package/dist/workflow/workflows/vuln-discovery/workflow.yaml +920 -0
- package/dist/workflow/worktree.d.ts +43 -0
- package/dist/workflow/worktree.js +106 -0
- package/dist/workflow/worktree.js.map +1 -0
- package/docker/Dockerfile.base +12 -1
- package/docker/Dockerfile.base.arm64 +17 -1
- package/docker/entrypoint-claude-code.sh +46 -0
- package/docker/entrypoint-goose.sh +25 -0
- package/package.json +22 -8
- package/packages/web-ui/README.md +113 -0
- package/packages/web-ui/docs/workflow-state-machine.png +0 -0
- package/src/config/generated/compiled-policy.json +528 -161
- package/src/config/generated/test-scenarios.json +4810 -170
- package/src/config/generated/tool-annotations.json +34 -47
- package/src/config/mcp-servers.json +2 -2
- package/src/workflow/workflows/design-and-code/workflow.yaml +169 -0
- package/src/workflow/workflows/test-email-summary/skills/email-formatting/SKILL.md +55 -0
- package/src/workflow/workflows/test-email-summary/skills/gmail-query-syntax/SKILL.md +63 -0
- package/src/workflow/workflows/test-email-summary/skills/json-schema-emails/SKILL.md +41 -0
- package/src/workflow/workflows/test-email-summary/skills/json-schema-emails/schema.json +34 -0
- package/src/workflow/workflows/test-email-summary/skills/summary-style-guide/SKILL.md +51 -0
- package/src/workflow/workflows/test-email-summary/workflow.yaml +89 -0
- package/src/workflow/workflows/vuln-discovery/skills/harness-design-fuzzing/SKILL.md +201 -0
- package/src/workflow/workflows/vuln-discovery/skills/memory-safety-c-cpp/SKILL.md +166 -0
- package/src/workflow/workflows/vuln-discovery/skills/vulnerability-triage/SKILL.md +185 -0
- package/src/workflow/workflows/vuln-discovery/workflow.yaml +920 -0
- package/dist/pipeline/llm-logger.js.map +0 -1
- package/dist/session/errors.js.map +0 -1
|
@@ -0,0 +1,1941 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync, readdirSync, statSync, unlinkSync, cpSync, } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { MessageLog } from './message-log.js';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { execFile as execFileCb } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import * as http from 'node:http';
|
|
8
|
+
import { createActor, fromPromise } from 'xstate';
|
|
9
|
+
import { createWorkflowId, WORKFLOW_ARTIFACT_DIR, GLOBAL_PERSONA, DEFAULT_CONTAINER_SCOPE, resolveWorkflowSkillsOptions, } from './types.js';
|
|
10
|
+
import { getBundleAuditLogPath, getBundleBundleDir, getBundleControlSocketPath, getBundleRuntimeRoot, getBundleStatesDir, getInvocationDir, nextStateSlug, } from '../config/paths.js';
|
|
11
|
+
import { POLICY_LOAD_PATH } from '../trusted-process/control-server.js';
|
|
12
|
+
import { loadConfig, loadPersonaPolicyArtifacts } from '../config/index.js';
|
|
13
|
+
import { validatePolicyDir } from '../config/validate-policy-dir.js';
|
|
14
|
+
import { getTokenStreamBus } from '../docker/token-stream-bus.js';
|
|
15
|
+
import { getPersonaDefinitionPath, resolvePersona } from '../persona/resolve.js';
|
|
16
|
+
import { isMemoryEnabledForPersonaName } from '../persona/memory-gate.js';
|
|
17
|
+
import { extractRequiredServers } from '../trusted-process/policy-roots.js';
|
|
18
|
+
import { createPersonaName } from '../persona/types.js';
|
|
19
|
+
import { MEMORY_SERVER_NAME } from '../memory/memory-annotations.js';
|
|
20
|
+
import { createAgentConversationId, createBundleId } from '../session/types.js';
|
|
21
|
+
import { describeTransientFailureKind } from '../docker/agent-adapter.js';
|
|
22
|
+
import { ensureSecureBundleDir } from '../docker/docker-infrastructure.js';
|
|
23
|
+
import { buildWorkflowMachine, } from './machine-builder.js';
|
|
24
|
+
import { parseAgentStatus, AgentStatusParseError, buildStatusBlockReprompt, getValidVerdicts, buildInvalidVerdictReprompt, } from './status-parser.js';
|
|
25
|
+
import { buildAgentCommand, buildArtifactReprompt, buildStatusInstructions } from './prompt-builder.js';
|
|
26
|
+
import { collectFilesRecursive, hasAnyFiles, snapshotArtifacts } from './artifacts.js';
|
|
27
|
+
import { parseArtifactRef, validateDefinition, validateWorkflowSkillReferences } from './validate.js';
|
|
28
|
+
import { parseDefinitionFile, getWorkflowPackageDir } from './discovery.js';
|
|
29
|
+
import { resolveSkillsForSession } from '../skills/discovery.js';
|
|
30
|
+
import { discoverWorkflowRuns } from './workflow-discovery.js';
|
|
31
|
+
import { isCheckpointResumable } from './checkpoint.js';
|
|
32
|
+
import { AgentInvocationError, WorkflowQuotaExhaustedError, WorkflowTransientFailureError, isWorkflowQuotaExhaustedError, isWorkflowTransientFailureError, } from './errors.js';
|
|
33
|
+
const execFileAsync = promisify(execFileCb);
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Transition message truncation
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
const MAX_TRANSITION_MESSAGE_BYTES = 4096;
|
|
38
|
+
const TRANSITION_TRUNCATION_NOTICE = '\n\n[... truncated]';
|
|
39
|
+
/**
|
|
40
|
+
* Default timeout for a `loadPolicy` RPC to the workflow coordinator.
|
|
41
|
+
* Generous because the coordinator's mutex may be held briefly by an
|
|
42
|
+
* in-flight tool call; tight because "control socket unreachable" must
|
|
43
|
+
* surface fast rather than wedge the workflow.
|
|
44
|
+
*/
|
|
45
|
+
const LOAD_POLICY_RPC_TIMEOUT_MS = 10_000;
|
|
46
|
+
function truncateForTransition(text) {
|
|
47
|
+
if (!text)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (Buffer.byteLength(text, 'utf-8') <= MAX_TRANSITION_MESSAGE_BYTES) {
|
|
50
|
+
return text;
|
|
51
|
+
}
|
|
52
|
+
const noticeBudget = MAX_TRANSITION_MESSAGE_BYTES - Buffer.byteLength(TRANSITION_TRUNCATION_NOTICE, 'utf-8');
|
|
53
|
+
let truncated = text;
|
|
54
|
+
while (Buffer.byteLength(truncated, 'utf-8') > noticeBudget) {
|
|
55
|
+
truncated = truncated.slice(0, Math.floor(truncated.length * 0.9));
|
|
56
|
+
}
|
|
57
|
+
return truncated + TRANSITION_TRUNCATION_NOTICE;
|
|
58
|
+
}
|
|
59
|
+
function toErrorMessage(err) {
|
|
60
|
+
return err instanceof Error ? err.message : String(err);
|
|
61
|
+
}
|
|
62
|
+
function tryParseAgentStatus(responseText) {
|
|
63
|
+
try {
|
|
64
|
+
const output = parseAgentStatus(responseText);
|
|
65
|
+
return output ? { kind: 'ok', output } : { kind: 'missing' };
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (err instanceof AgentStatusParseError) {
|
|
69
|
+
return { kind: 'malformed', error: err };
|
|
70
|
+
}
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Writes directly to process.stderr, bypassing any console hijacking
|
|
76
|
+
* (e.g., logger.setup() redirecting console.error to a file).
|
|
77
|
+
* Used for critical error messages that MUST reach the terminal.
|
|
78
|
+
*/
|
|
79
|
+
function writeStderr(message) {
|
|
80
|
+
process.stderr.write(`${message}\n`);
|
|
81
|
+
}
|
|
82
|
+
/** Per-run subdirectory name for the staged copy of the workflow's bundled `skills/` tree. */
|
|
83
|
+
const STAGED_WORKFLOW_SKILLS_SUBDIR = 'workflow-skills';
|
|
84
|
+
/**
|
|
85
|
+
* Stages the workflow package's `skills/` tree into the run directory
|
|
86
|
+
* so the workflow becomes self-contained at start time. After this call
|
|
87
|
+
* the per-run path (`<runMetaDir>/workflow-skills/`) is the canonical
|
|
88
|
+
* source of workflow-bundled skills for both initial-set staging and
|
|
89
|
+
* per-state restage; the original package path under
|
|
90
|
+
* `getWorkflowPackageDir(definitionPath)` is no longer consulted at
|
|
91
|
+
* runtime.
|
|
92
|
+
*
|
|
93
|
+
* Returns the absolute path to the staged copy when source `skills/`
|
|
94
|
+
* exists and was copied, `undefined` when the source has no `skills/`
|
|
95
|
+
* directory (the workflow simply ships none — not an error). Throws
|
|
96
|
+
* only on copy I/O failure, since a half-copied tree would be worse
|
|
97
|
+
* than a missing one.
|
|
98
|
+
*
|
|
99
|
+
* Why copy at start (not symlink): the package directory may be
|
|
100
|
+
* deleted, moved, or rebuilt between start and resume — symlink chases
|
|
101
|
+
* lead to "skills silently empty on resume" without diagnostic. A
|
|
102
|
+
* shallow-recursive copy decouples the run from its source-of-record
|
|
103
|
+
* location.
|
|
104
|
+
*/
|
|
105
|
+
export function stageWorkflowSkillsAtStart(packageDir, runMetaDir) {
|
|
106
|
+
const sourceSkillsDir = resolve(packageDir, 'skills');
|
|
107
|
+
if (!existsSync(sourceSkillsDir))
|
|
108
|
+
return undefined;
|
|
109
|
+
const stagedDir = resolve(runMetaDir, STAGED_WORKFLOW_SKILLS_SUBDIR);
|
|
110
|
+
cpSync(sourceSkillsDir, stagedDir, { recursive: true });
|
|
111
|
+
return stagedDir;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Resolves the workflow-skills directory to use for a resumed workflow.
|
|
115
|
+
*
|
|
116
|
+
* Priority order, from most-trusted to most-degraded:
|
|
117
|
+
* 1. The checkpointed staged path, if it still exists. The orchestrator
|
|
118
|
+
* writes this at start under the run dir; we own the lifecycle so
|
|
119
|
+
* it should normally still be there.
|
|
120
|
+
* 2. A fresh stage from the original package dir, when the staged
|
|
121
|
+
* copy is gone but the source is reachable. Recovers from manual
|
|
122
|
+
* pruning of the run dir without losing skills on resume.
|
|
123
|
+
* 3. `undefined`, with a stderr warning. Both the staged copy and
|
|
124
|
+
* the source are gone; degrade gracefully but make the loss
|
|
125
|
+
* visible (the original bug — silent empty discovery — is what
|
|
126
|
+
* this whole resolver exists to prevent).
|
|
127
|
+
*
|
|
128
|
+
* Pre-feature checkpoints have no `workflowSkillsDir` field; they
|
|
129
|
+
* trigger the case-(2) fresh stage transparently.
|
|
130
|
+
*/
|
|
131
|
+
function resolveWorkflowSkillsDirOnResume(opts) {
|
|
132
|
+
const { workflowId, checkpointedStagedDir, packageDir, runMetaDir } = opts;
|
|
133
|
+
if (checkpointedStagedDir !== undefined && existsSync(checkpointedStagedDir)) {
|
|
134
|
+
return checkpointedStagedDir;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const restaged = stageWorkflowSkillsAtStart(packageDir, runMetaDir);
|
|
138
|
+
if (restaged !== undefined) {
|
|
139
|
+
writeStderr(`[workflow] Resume ${workflowId}: workflow-skills staged copy missing, re-staged from ${packageDir}.`);
|
|
140
|
+
return restaged;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
writeStderr(`[workflow] Resume ${workflowId}: failed to re-stage workflow skills from ${packageDir}: ${toErrorMessage(err)}`);
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
// Both paths gone. Without this warning the resumed run silently
|
|
148
|
+
// loses every workflow-bundled skill — the exact failure mode this
|
|
149
|
+
// function was added to prevent.
|
|
150
|
+
writeStderr(`[workflow] Resume ${workflowId}: no workflow-skills available (staged copy and ${packageDir}/skills both missing). Workflow-bundled skills will not be available in this run.`);
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Orchestrator
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
export class WorkflowOrchestrator {
|
|
157
|
+
deps;
|
|
158
|
+
workflows = new Map();
|
|
159
|
+
lifecycleCallbacks = [];
|
|
160
|
+
constructor(deps) {
|
|
161
|
+
this.deps = deps;
|
|
162
|
+
}
|
|
163
|
+
/** Build a partial log entry with shared fields for the given workflow instance. */
|
|
164
|
+
logBase(instance) {
|
|
165
|
+
return {
|
|
166
|
+
ts: new Date().toISOString(),
|
|
167
|
+
workflowId: instance.id,
|
|
168
|
+
state: instance.currentState,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// -----------------------------------------------------------------------
|
|
172
|
+
// Pre-flight validation
|
|
173
|
+
// -----------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Validates that all non-"global" personas referenced in the workflow
|
|
176
|
+
* definition actually exist on disk. Fails fast with a clear error
|
|
177
|
+
* listing all missing personas.
|
|
178
|
+
*
|
|
179
|
+
* Only checks for persona.json existence -- does NOT verify compiled
|
|
180
|
+
* policy (that happens at session creation time via resolvePersona).
|
|
181
|
+
*/
|
|
182
|
+
validatePersonas(definition) {
|
|
183
|
+
const missing = [];
|
|
184
|
+
for (const [stateId, state] of Object.entries(definition.states)) {
|
|
185
|
+
if (state.type !== 'agent')
|
|
186
|
+
continue;
|
|
187
|
+
const persona = state.persona;
|
|
188
|
+
if (persona === GLOBAL_PERSONA)
|
|
189
|
+
continue;
|
|
190
|
+
const defPath = getPersonaDefinitionPath(createPersonaName(persona));
|
|
191
|
+
if (!existsSync(defPath)) {
|
|
192
|
+
missing.push(`"${persona}" (used by state "${stateId}")`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
throw new Error(`Workflow references personas that do not exist:\n` +
|
|
197
|
+
` ${missing.join('\n ')}\n` +
|
|
198
|
+
`Create them with: ironcurtain persona create <name>`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// -----------------------------------------------------------------------
|
|
202
|
+
// Workflow-scoped Docker infrastructure lifecycle
|
|
203
|
+
// -----------------------------------------------------------------------
|
|
204
|
+
/**
|
|
205
|
+
* Determines whether a workflow should run in shared-container mode.
|
|
206
|
+
* Requires both an explicit opt-in via `settings.sharedContainer` and
|
|
207
|
+
* a Docker-backed agent. Builtin workflows ignore the flag entirely.
|
|
208
|
+
*/
|
|
209
|
+
shouldUseSharedContainer(definition) {
|
|
210
|
+
const settings = definition.settings ?? {};
|
|
211
|
+
if (settings.sharedContainer !== true)
|
|
212
|
+
return false;
|
|
213
|
+
if (settings.mode === 'builtin')
|
|
214
|
+
return false;
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Returns the bundle for `scope`, minting a new one on first use.
|
|
219
|
+
*
|
|
220
|
+
* Each unique `containerScope` used across the workflow's states gets
|
|
221
|
+
* exactly one bundle: the first `executeAgentState` call under a given
|
|
222
|
+
* scope creates the bundle, attaches its coordinator control server,
|
|
223
|
+
* and inserts the result into `instance.bundlesByScope`. Later states
|
|
224
|
+
* with the same scope borrow the existing entry (no factory call).
|
|
225
|
+
*
|
|
226
|
+
* Replaces the pre-Step-6 eager workflow-start mint. Only invoked when
|
|
227
|
+
* `shouldUseSharedContainer(definition)` returns true; callers must
|
|
228
|
+
* gate on that check.
|
|
229
|
+
*
|
|
230
|
+
* On control-server attach failure, the just-created bundle is torn
|
|
231
|
+
* down before the error propagates so we do not leak Docker resources
|
|
232
|
+
* on partial initialization. The map is **not** populated in that case
|
|
233
|
+
* — the scope entry remains absent so a later state can retry.
|
|
234
|
+
*
|
|
235
|
+
* Concurrency: relies on serial XState invocations — there is no
|
|
236
|
+
* parallel agent-state fan-out today. If that ever lands, add an
|
|
237
|
+
* in-flight-promise guard to prevent concurrent lazy-mint races for
|
|
238
|
+
* the same scope.
|
|
239
|
+
*
|
|
240
|
+
* Abort race: if `destroyWorkflowInfrastructure` begins while a mint
|
|
241
|
+
* is suspended at either `await factory(...)` or `await startServer(...)`,
|
|
242
|
+
* the mint completes but must not publish into `bundlesByScope` —
|
|
243
|
+
* destroy already snapshot-cleared the map and would miss the late
|
|
244
|
+
* insertion. `instance.aborted` is the barrier, consulted at both
|
|
245
|
+
* `await` points; on observed abort we tear down the just-built
|
|
246
|
+
* bundle and throw without updating the map.
|
|
247
|
+
*/
|
|
248
|
+
async ensureBundleForScope(instance, scope) {
|
|
249
|
+
if (instance.aborted) {
|
|
250
|
+
throw new Error(`Workflow ${instance.id} is aborting; cannot mint bundle for scope "${scope}"`);
|
|
251
|
+
}
|
|
252
|
+
const existing = instance.bundlesByScope.get(scope);
|
|
253
|
+
if (existing)
|
|
254
|
+
return existing;
|
|
255
|
+
const settings = instance.definition.settings ?? {};
|
|
256
|
+
const agentId = (settings.dockerAgent ?? 'claude-code');
|
|
257
|
+
// Mint a fresh UUID per scope: each bundle needs its own on-disk
|
|
258
|
+
// directory tree, coordinator control socket, and Docker container
|
|
259
|
+
// name. Unlike single-session CLI (`bundleId === sessionId`),
|
|
260
|
+
// workflow scopes produce genuine standalone `BundleId`s.
|
|
261
|
+
const bundleId = createBundleId();
|
|
262
|
+
const controlSocketPath = getBundleControlSocketPath(bundleId);
|
|
263
|
+
// Ensure the per-bundle runtime root exists BEFORE the coordinator
|
|
264
|
+
// tries to bind its UDS there. Routes through `ensureSecureBundleDir`
|
|
265
|
+
// so the same symlink-rejection + 0o700-enforcement hardening that
|
|
266
|
+
// guards `sockets/` and `host/` (in `prepareDockerInfrastructure`)
|
|
267
|
+
// also guards the root the coordinator's `ctrl.sock` binds into.
|
|
268
|
+
ensureSecureBundleDir(getBundleRuntimeRoot(bundleId));
|
|
269
|
+
const requiredServers = this.getRequiredServersForScope(instance, scope);
|
|
270
|
+
// Persona-less initial set; per-state restaging fills in persona skills later.
|
|
271
|
+
// Reads the cached per-run staged path (set at start / resume) so
|
|
272
|
+
// the bundle never depends on the original package directory still
|
|
273
|
+
// being on disk.
|
|
274
|
+
const resolvedSkills = resolveSkillsForSession({ workflowSkillsDir: instance.workflowSkillsDir });
|
|
275
|
+
const factory = this.deps.createWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureFactory());
|
|
276
|
+
const infra = await factory({
|
|
277
|
+
workflowId: instance.id,
|
|
278
|
+
bundleId,
|
|
279
|
+
agentId,
|
|
280
|
+
controlSocketPath,
|
|
281
|
+
workspacePath: instance.workspacePath,
|
|
282
|
+
scope,
|
|
283
|
+
requiredServers,
|
|
284
|
+
resolvedSkills,
|
|
285
|
+
});
|
|
286
|
+
// Abort may have landed while `factory()` was suspended. Publishing
|
|
287
|
+
// into `bundlesByScope` now would leak past the destroy's leak
|
|
288
|
+
// assertion; destroy the orphan inline and throw. ESLint cannot see
|
|
289
|
+
// that `instance.aborted` is mutated by the concurrent
|
|
290
|
+
// `destroyWorkflowInfrastructure` call between the two reads.
|
|
291
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
292
|
+
if (instance.aborted) {
|
|
293
|
+
const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
|
|
294
|
+
await destroy(infra).catch((teardownErr) => {
|
|
295
|
+
writeStderr(`[workflow] destroyDockerInfrastructure during abort recovery for ${instance.id} (bundle ${bundleId}): ${toErrorMessage(teardownErr)}`);
|
|
296
|
+
});
|
|
297
|
+
throw new Error(`Workflow ${instance.id} was aborted during bundle mint for scope "${scope}"`);
|
|
298
|
+
}
|
|
299
|
+
// Attach the control server. On failure, tear down the bundle so
|
|
300
|
+
// we don't leak containers/proxies; the error propagates so the
|
|
301
|
+
// caller (executeAgentState) can fail the state invoke.
|
|
302
|
+
try {
|
|
303
|
+
const startServer = this.deps.startWorkflowControlServer ?? defaultStartWorkflowControlServer;
|
|
304
|
+
await startServer({ infra, socketPath: controlSocketPath });
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
|
|
308
|
+
await destroy(infra).catch((teardownErr) => {
|
|
309
|
+
writeStderr(`[workflow] destroyDockerInfrastructure during control-server recovery for ${instance.id}: ${toErrorMessage(teardownErr)}`);
|
|
310
|
+
});
|
|
311
|
+
throw err;
|
|
312
|
+
}
|
|
313
|
+
// Abort check #2: abort may have landed while `startServer()` was
|
|
314
|
+
// suspended. Publishing into `bundlesByScope` now would leak past
|
|
315
|
+
// the destroy's leak assertion; tear down the just-built bundle
|
|
316
|
+
// and throw. ESLint cannot see that `instance.aborted` is mutated
|
|
317
|
+
// by the concurrent `destroyWorkflowInfrastructure` call between
|
|
318
|
+
// the two reads.
|
|
319
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
320
|
+
if (instance.aborted) {
|
|
321
|
+
const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
|
|
322
|
+
await destroy(infra).catch((teardownErr) => {
|
|
323
|
+
writeStderr(`[workflow] destroyDockerInfrastructure during abort recovery (post-startServer) for ${instance.id} (bundle ${bundleId}): ${toErrorMessage(teardownErr)}`);
|
|
324
|
+
});
|
|
325
|
+
throw new Error(`Workflow ${instance.id} was aborted during control-server attach for scope "${scope}"`);
|
|
326
|
+
}
|
|
327
|
+
instance.bundlesByScope.set(scope, infra);
|
|
328
|
+
instance.mintedServersByBundle.set(bundleId, requiredServers);
|
|
329
|
+
return infra;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Returns the cached policyDir for `persona`, canonicalizing on first
|
|
333
|
+
* lookup so required-server derivation and the coordinator's
|
|
334
|
+
* `loadPolicy` RPC operate on the same realpath-resolved file (any
|
|
335
|
+
* symlink under the persona dir is collapsed once, here, instead of
|
|
336
|
+
* re-resolved by every reader).
|
|
337
|
+
*/
|
|
338
|
+
getPolicyDir(instance, persona) {
|
|
339
|
+
const cached = instance.policyDirByPersona.get(persona);
|
|
340
|
+
if (cached !== undefined)
|
|
341
|
+
return cached;
|
|
342
|
+
const dir = validatePolicyDir(resolvePersonaPolicyDir(persona));
|
|
343
|
+
instance.policyDirByPersona.set(persona, dir);
|
|
344
|
+
return dir;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Computes the union of MCP server names required by every agent state
|
|
348
|
+
* sharing `scope`. Threaded into the workflow infrastructure factory so
|
|
349
|
+
* the bundle only spawns proxies the policies actually reference.
|
|
350
|
+
*/
|
|
351
|
+
getRequiredServersForScope(instance, scope) {
|
|
352
|
+
const union = new Set();
|
|
353
|
+
const seenPersonas = new Set();
|
|
354
|
+
for (const stateConfig of Object.values(instance.definition.states)) {
|
|
355
|
+
if (stateConfig.type !== 'agent')
|
|
356
|
+
continue;
|
|
357
|
+
const stateScope = stateConfig.containerScope ?? DEFAULT_CONTAINER_SCOPE;
|
|
358
|
+
if (stateScope !== scope)
|
|
359
|
+
continue;
|
|
360
|
+
if (seenPersonas.has(stateConfig.persona))
|
|
361
|
+
continue;
|
|
362
|
+
seenPersonas.add(stateConfig.persona);
|
|
363
|
+
const { compiledPolicy } = loadPersonaPolicyArtifacts(this.getPolicyDir(instance, stateConfig.persona));
|
|
364
|
+
for (const server of extractRequiredServers(compiledPolicy)) {
|
|
365
|
+
union.add(server);
|
|
366
|
+
}
|
|
367
|
+
// Memory is bolt-on (not in compiled policy). `Set.add` is
|
|
368
|
+
// idempotent, so adding here per-persona preserves any-wants-it.
|
|
369
|
+
if (isMemoryEnabledForPersonaName(stateConfig.persona, this.deps.userConfig)) {
|
|
370
|
+
union.add(MEMORY_SERVER_NAME);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return union;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Reloads the coordinator's policy for the given persona on the given
|
|
377
|
+
* bundle. Called once per agent state invocation (including re-entries)
|
|
378
|
+
* in shared-container mode. The coordinator stamps `persona` onto every
|
|
379
|
+
* subsequent audit entry, so consumers can reconstruct per-persona /
|
|
380
|
+
* per-re-entry slices from the bundle's `audit.jsonl` file.
|
|
381
|
+
*
|
|
382
|
+
* Each scope owns its own bundle, coordinator, and control socket, so
|
|
383
|
+
* the bundle is passed explicitly — cycle the scope the state is
|
|
384
|
+
* about to enter, not some other scope's bundle.
|
|
385
|
+
*
|
|
386
|
+
* On failure (control socket unreachable, coordinator reports a load
|
|
387
|
+
* error) this throws — the workflow must not proceed under the
|
|
388
|
+
* previous persona's policy.
|
|
389
|
+
*/
|
|
390
|
+
async cyclePolicy(instance, persona, bundle) {
|
|
391
|
+
// Skip the RPC when the bundle's coordinator already has this
|
|
392
|
+
// persona loaded. Consecutive states on the same scope+persona
|
|
393
|
+
// would otherwise re-send an identical `loadPolicy` — a no-op on
|
|
394
|
+
// the coordinator that still costs a UDS round-trip and a policy
|
|
395
|
+
// engine rebuild.
|
|
396
|
+
if (instance.currentPersonaByBundle.get(bundle.bundleId) === persona)
|
|
397
|
+
return;
|
|
398
|
+
const policyDir = this.getPolicyDir(instance, persona);
|
|
399
|
+
// Guard against mid-workflow recompiles that add a server: the bundle
|
|
400
|
+
// was minted with a fixed required-server set, so a new policy that
|
|
401
|
+
// expands the set would have the coordinator allow tools no relay was
|
|
402
|
+
// spawned for. Fail fast with a clear error instead.
|
|
403
|
+
const minted = instance.mintedServersByBundle.get(bundle.bundleId);
|
|
404
|
+
if (minted) {
|
|
405
|
+
const { compiledPolicy } = loadPersonaPolicyArtifacts(policyDir);
|
|
406
|
+
const newServers = extractRequiredServers(compiledPolicy);
|
|
407
|
+
const added = [];
|
|
408
|
+
for (const s of newServers)
|
|
409
|
+
if (!minted.has(s))
|
|
410
|
+
added.push(s);
|
|
411
|
+
if (added.length > 0) {
|
|
412
|
+
throw new Error(`Persona "${persona}" requires server(s) [${added.join(', ')}] that were not spawned for this scope ` +
|
|
413
|
+
`(bundle minted with [${[...minted].sort().join(', ')}]). ` +
|
|
414
|
+
`A persona recompile that expands the server set requires restarting the workflow.`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// Target this bundle's coordinator. Under bifurcated workflows every
|
|
418
|
+
// bundle has its own socket, so the bundle argument fully determines
|
|
419
|
+
// the route.
|
|
420
|
+
const socketPath = getBundleControlSocketPath(bundle.bundleId);
|
|
421
|
+
// Invalidate the cache entry BEFORE the RPC. If `loadPolicy` fails
|
|
422
|
+
// after the coordinator already applied the new policy (e.g., the
|
|
423
|
+
// RPC times out during the response), leaving the cache pointed at
|
|
424
|
+
// the previous persona would silently skip the next cycle and run
|
|
425
|
+
// a subsequent state under the wrong policy. Clear-then-rewrite
|
|
426
|
+
// ensures the cache is never stale relative to the coordinator's
|
|
427
|
+
// actual state — at worst we re-send an identical `loadPolicy`.
|
|
428
|
+
instance.currentPersonaByBundle.delete(bundle.bundleId);
|
|
429
|
+
const loadPolicy = this.deps.loadPolicyRpc ?? defaultLoadPolicyRpc;
|
|
430
|
+
await loadPolicy({
|
|
431
|
+
socketPath,
|
|
432
|
+
persona,
|
|
433
|
+
policyDir,
|
|
434
|
+
timeoutMs: LOAD_POLICY_RPC_TIMEOUT_MS,
|
|
435
|
+
});
|
|
436
|
+
instance.currentPersonaByBundle.set(bundle.bundleId, persona);
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Tears down every bundle this workflow owns. Walks
|
|
440
|
+
* `instance.bundlesByScope.values()` in parallel via `Promise.allSettled`
|
|
441
|
+
* so failures in one bundle's teardown do not block the others. The
|
|
442
|
+
* map is drained before the awaits so a second concurrent caller (e.g.
|
|
443
|
+
* `shutdownAll` racing the fire-and-forget destroy in
|
|
444
|
+
* `handleWorkflowComplete`) sees an empty map and returns without
|
|
445
|
+
* re-entering `destroy`.
|
|
446
|
+
*
|
|
447
|
+
* Callers in recovery paths rely on **expected** failure modes being
|
|
448
|
+
* swallowed so abort/shutdown flows can complete:
|
|
449
|
+
* - Failures in `destroy(infra)` are logged to stderr and swallowed.
|
|
450
|
+
* - Failures in socket unlinks are logged and swallowed.
|
|
451
|
+
*
|
|
452
|
+
* **One exception — intentional throw on internal invariant violation.**
|
|
453
|
+
* After teardown the helper asserts `bundlesByScope.size === 0`. A
|
|
454
|
+
* non-empty map at that point is a bug: something added an entry back
|
|
455
|
+
* while teardown was in flight. The invariant is enforced by the
|
|
456
|
+
* abort-guard in `ensureBundleForScope` (post-`factory` flag check);
|
|
457
|
+
* reaching this throw means the guard has a hole and we want it to
|
|
458
|
+
* surface loudly rather than silently strand a bundle. Callers are
|
|
459
|
+
* `abort()`, `shutdownAll()`, and the fire-and-forget `.catch()` in
|
|
460
|
+
* `handleWorkflowComplete` — in the expected invariant-holds case
|
|
461
|
+
* none of them see an exception. A thrown leak assertion in
|
|
462
|
+
* `abort()` / `shutdownAll()` will propagate to their own caller (the
|
|
463
|
+
* test or CLI layer), which is the intended signal.
|
|
464
|
+
*/
|
|
465
|
+
async destroyWorkflowInfrastructure(instance) {
|
|
466
|
+
// Set BEFORE any short-circuit: an in-flight `ensureBundleForScope`
|
|
467
|
+
// that resumes from its `await factory(...)` must observe this flag
|
|
468
|
+
// and tear down its own orphan rather than publishing into the map.
|
|
469
|
+
instance.aborted = true;
|
|
470
|
+
if (instance.bundlesByScope.size === 0)
|
|
471
|
+
return;
|
|
472
|
+
// Snapshot and clear BEFORE the awaits so a concurrent caller sees
|
|
473
|
+
// an empty map and bails out. See JSDoc for the rationale.
|
|
474
|
+
const bundles = [...instance.bundlesByScope.values()];
|
|
475
|
+
instance.bundlesByScope.clear();
|
|
476
|
+
const destroy = this.deps.destroyWorkflowInfrastructure ?? (await this.loadDefaultInfrastructureTeardown());
|
|
477
|
+
await Promise.allSettled(bundles.map(async (infra) => {
|
|
478
|
+
try {
|
|
479
|
+
await destroy(infra);
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
writeStderr(`[workflow] destroyDockerInfrastructure failed for ${instance.id} (bundle ${infra.bundleId}): ${toErrorMessage(err)}`);
|
|
483
|
+
}
|
|
484
|
+
// Best-effort: unlink the coordinator control socket. Swallow
|
|
485
|
+
// ENOENT because the socket may never have been bound (e.g.,
|
|
486
|
+
// when destroy runs after a failure before control-server attach).
|
|
487
|
+
try {
|
|
488
|
+
unlinkSync(getBundleControlSocketPath(infra.bundleId));
|
|
489
|
+
}
|
|
490
|
+
catch (err) {
|
|
491
|
+
const code = err.code;
|
|
492
|
+
if (code !== 'ENOENT') {
|
|
493
|
+
writeStderr(`[workflow] Failed to unlink control socket for ${instance.id} (bundle ${infra.bundleId}): ${toErrorMessage(err)}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}));
|
|
497
|
+
// Leak assertion: after snapshot-and-clear + parallel destroy, the
|
|
498
|
+
// map MUST be empty. A non-empty map means a destroy path silently
|
|
499
|
+
// added an entry back after teardown began — a real bug we surface
|
|
500
|
+
// as a synchronous error so it cannot be swallowed.
|
|
501
|
+
if (instance.bundlesByScope.size !== 0) {
|
|
502
|
+
const leaked = [...instance.bundlesByScope.keys()];
|
|
503
|
+
throw new Error(`Leaked workflow bundle scopes after teardown: ${JSON.stringify(leaked)}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Lazy-loads the default `createDockerInfrastructure` wrapper. The real
|
|
508
|
+
* helper requires Docker dependencies and the session config, so we
|
|
509
|
+
* construct them here rather than eagerly at orchestrator construction
|
|
510
|
+
* time (which would pay the import cost for every workflow run,
|
|
511
|
+
* including builtin ones that don't need Docker).
|
|
512
|
+
*/
|
|
513
|
+
async loadDefaultInfrastructureFactory() {
|
|
514
|
+
const { createDockerInfrastructure } = await import('../docker/docker-infrastructure.js');
|
|
515
|
+
const { loadConfig, applyAllowedDirectoryToMcpArgs } = await import('../config/index.js');
|
|
516
|
+
const { filterMcpServersByPolicy } = await import('../persona/resolve.js');
|
|
517
|
+
return async (input) => {
|
|
518
|
+
const config = loadConfig();
|
|
519
|
+
// Bundle dir holds the bundle's shared Docker artifacts
|
|
520
|
+
// (orientation, sockets, claude-state, escalations, system
|
|
521
|
+
// prompt). It lives under the bundle's per-bundleId subtree so
|
|
522
|
+
// bifurcated workflows can host multiple bundles side by side
|
|
523
|
+
// without key collisions.
|
|
524
|
+
// Audit entries go to the per-bundle audit file (one file per
|
|
525
|
+
// bundle, with per-entry persona tagging).
|
|
526
|
+
const bundleDir = getBundleBundleDir(input.workflowId, input.bundleId);
|
|
527
|
+
const bundleEscalationDir = resolve(bundleDir, 'escalations');
|
|
528
|
+
// All sessions in a workflow share the workflow's workspace dir
|
|
529
|
+
// as their allowed directory. Do NOT use a session-scoped sandbox
|
|
530
|
+
// here: the orchestrator's artifact checks look inside the
|
|
531
|
+
// workspace, so artifacts the agent writes must land in the same
|
|
532
|
+
// tree.
|
|
533
|
+
mkdirSync(bundleDir, { recursive: true });
|
|
534
|
+
mkdirSync(bundleEscalationDir, { recursive: true });
|
|
535
|
+
mkdirSync(input.workspacePath, { recursive: true });
|
|
536
|
+
// Rewrite the allowed directory and audit path onto the loaded
|
|
537
|
+
// config. `config.allowedDirectory` drives the filesystem MCP
|
|
538
|
+
// server's --allowed-directory; `config.auditLogPath` is what the
|
|
539
|
+
// ToolCallCoordinator (via Sandbox) reads when creating the
|
|
540
|
+
// AuditLog. Without the audit rewrite, audit entries fall through
|
|
541
|
+
// to the default `./audit.jsonl` against process.cwd().
|
|
542
|
+
config.allowedDirectory = input.workspacePath;
|
|
543
|
+
config.auditLogPath = getBundleAuditLogPath(input.workflowId, input.bundleId);
|
|
544
|
+
config.mcpServers = filterMcpServersByPolicy(config.mcpServers, input.requiredServers);
|
|
545
|
+
applyAllowedDirectoryToMcpArgs(config.mcpServers, input.workspacePath);
|
|
546
|
+
return createDockerInfrastructure(config, { kind: 'docker', agent: input.agentId }, bundleDir, input.workspacePath, bundleEscalationDir, input.bundleId,
|
|
547
|
+
// Threading the workflowId + scope here drives the
|
|
548
|
+
// `ironcurtain.workflow` and `ironcurtain.scope` Docker labels
|
|
549
|
+
// emitted by `createSessionContainers()` via `buildBundleLabels`.
|
|
550
|
+
input.workflowId, input.scope, input.resolvedSkills);
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
/** Lazy-loads the default `destroyDockerInfrastructure` helper. */
|
|
554
|
+
async loadDefaultInfrastructureTeardown() {
|
|
555
|
+
const { destroyDockerInfrastructure } = await import('../docker/docker-infrastructure.js');
|
|
556
|
+
return destroyDockerInfrastructure;
|
|
557
|
+
}
|
|
558
|
+
// -----------------------------------------------------------------------
|
|
559
|
+
// Token-stream accumulation (workflow-scoped totalTokens counter)
|
|
560
|
+
// -----------------------------------------------------------------------
|
|
561
|
+
//
|
|
562
|
+
// The token-stream bus is global (see `getTokenStreamBus()` in
|
|
563
|
+
// `src/docker/token-stream-bus.ts`), so the orchestrator subscribes with
|
|
564
|
+
// `subscribeAll()` and filters inside the listener by the workflow's own
|
|
565
|
+
// session-ID set. Per-workflow rather than per-agent: `ctx.totalTokens`
|
|
566
|
+
// is a workflow-level cumulative sum, so one long-lived subscription is
|
|
567
|
+
// cheaper (one bus listener, one unsubscribe on completion) than
|
|
568
|
+
// subscribing and unsubscribing on every `agent_started` /
|
|
569
|
+
// `agent_session_ended` pair.
|
|
570
|
+
/**
|
|
571
|
+
* Subscribes the workflow to the global token-stream bus. Accumulates
|
|
572
|
+
* `message_end.outputTokens` into `instance.tokens.outputTokens` for any
|
|
573
|
+
* event whose session ID is in `instance.tokens.sessionIds`. The set is managed
|
|
574
|
+
* by `executeAgentState` (added before `agent_started`, removed in the
|
|
575
|
+
* `finally` block after `agent_session_ended`).
|
|
576
|
+
*
|
|
577
|
+
* Installed synchronously so the subscription is live before any agent
|
|
578
|
+
* state runs — no window where bus events go unobserved.
|
|
579
|
+
*/
|
|
580
|
+
setupTokenSubscription(instance) {
|
|
581
|
+
// Defensive: re-entry by resume-after-crash should not double-subscribe.
|
|
582
|
+
if (instance.tokens.unsubscribe)
|
|
583
|
+
return;
|
|
584
|
+
instance.tokens.unsubscribe = getTokenStreamBus().subscribeAll((sessionId, event) => {
|
|
585
|
+
if (event.kind !== 'message_end')
|
|
586
|
+
return;
|
|
587
|
+
if (!instance.tokens.sessionIds.has(sessionId))
|
|
588
|
+
return;
|
|
589
|
+
instance.tokens.outputTokens += event.outputTokens;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Tears down the token-stream bus subscription. Idempotent: safe to
|
|
594
|
+
* call from both normal completion and abort paths.
|
|
595
|
+
*/
|
|
596
|
+
teardownTokenSubscription(instance) {
|
|
597
|
+
if (instance.tokens.unsubscribe) {
|
|
598
|
+
try {
|
|
599
|
+
instance.tokens.unsubscribe();
|
|
600
|
+
}
|
|
601
|
+
catch {
|
|
602
|
+
// Unsubscribe is best-effort; errors here should never prevent
|
|
603
|
+
// workflow teardown from completing.
|
|
604
|
+
}
|
|
605
|
+
instance.tokens.unsubscribe = undefined;
|
|
606
|
+
}
|
|
607
|
+
instance.tokens.sessionIds.clear();
|
|
608
|
+
}
|
|
609
|
+
// -----------------------------------------------------------------------
|
|
610
|
+
// WorkflowController implementation
|
|
611
|
+
// -----------------------------------------------------------------------
|
|
612
|
+
// Keeps the `async` signature even though the body no longer awaits:
|
|
613
|
+
// (a) preserves the `WorkflowController.start()` contract (Promise<WorkflowId>),
|
|
614
|
+
// (b) leaves a hook for future async bootstrap work without another
|
|
615
|
+
// interface change.
|
|
616
|
+
// eslint-disable-next-line @typescript-eslint/require-await
|
|
617
|
+
async start(definitionPath, taskDescription, workspacePath) {
|
|
618
|
+
const raw = parseDefinitionFile(definitionPath);
|
|
619
|
+
const definition = validateDefinition(raw);
|
|
620
|
+
this.validatePersonas(definition);
|
|
621
|
+
validateWorkflowSkillReferences(definition, getWorkflowPackageDir(definitionPath));
|
|
622
|
+
const workflowId = createWorkflowId();
|
|
623
|
+
const resolvedWorkspace = workspacePath ?? resolve(this.deps.baseDir, workflowId, 'workspace');
|
|
624
|
+
// Reject if any active workflow already uses this workspace path
|
|
625
|
+
for (const instance of this.workflows.values()) {
|
|
626
|
+
if (instance.workspacePath === resolvedWorkspace && !instance.finalStatus) {
|
|
627
|
+
throw new Error(`Workspace ${resolvedWorkspace} is already in use by workflow ${instance.id}`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
mkdirSync(resolvedWorkspace, { recursive: true });
|
|
631
|
+
const artifactDir = resolve(resolvedWorkspace, WORKFLOW_ARTIFACT_DIR);
|
|
632
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
633
|
+
const taskDir = resolve(artifactDir, 'task');
|
|
634
|
+
mkdirSync(taskDir, { recursive: true });
|
|
635
|
+
writeFileSync(resolve(taskDir, 'description.md'), taskDescription);
|
|
636
|
+
ensureWorkflowGitignored(resolvedWorkspace);
|
|
637
|
+
// Copy definition to baseDir for resume portability
|
|
638
|
+
const metaDir = resolve(this.deps.baseDir, workflowId);
|
|
639
|
+
mkdirSync(metaDir, { recursive: true });
|
|
640
|
+
writeFileSync(resolve(metaDir, 'definition.json'), JSON.stringify(definition, null, 2));
|
|
641
|
+
// Stage the workflow package's `skills/` tree into the run dir so
|
|
642
|
+
// resume is independent of whether the original package path still
|
|
643
|
+
// exists. Cached on the instance and persisted in every checkpoint
|
|
644
|
+
// so all subsequent reads point at this stable per-run copy.
|
|
645
|
+
const workflowSkillsDir = stageWorkflowSkillsAtStart(getWorkflowPackageDir(definitionPath), metaDir);
|
|
646
|
+
const { machine, gateStateNames, terminalStateNames } = buildWorkflowMachine(definition, taskDescription);
|
|
647
|
+
const providedMachine = this.provideActors(machine, workflowId, definition);
|
|
648
|
+
const actor = createActor(providedMachine);
|
|
649
|
+
const tab = this.deps.createWorkflowTab(definition.name, workflowId);
|
|
650
|
+
const messageLog = new MessageLog(resolve(this.deps.baseDir, workflowId, 'messages.jsonl'));
|
|
651
|
+
const instance = {
|
|
652
|
+
id: workflowId,
|
|
653
|
+
definition,
|
|
654
|
+
definitionPath,
|
|
655
|
+
workflowSkillsDir,
|
|
656
|
+
actor,
|
|
657
|
+
gateStateNames,
|
|
658
|
+
terminalStateNames,
|
|
659
|
+
activeSessions: new Set(),
|
|
660
|
+
artifactDir,
|
|
661
|
+
workspacePath: resolvedWorkspace,
|
|
662
|
+
tab,
|
|
663
|
+
transitionHistory: [],
|
|
664
|
+
currentState: definition.initial,
|
|
665
|
+
stateEnteredAt: Date.now(),
|
|
666
|
+
messageLog,
|
|
667
|
+
bundlesByScope: new Map(),
|
|
668
|
+
policyDirByPersona: new Map(),
|
|
669
|
+
currentPersonaByBundle: new Map(),
|
|
670
|
+
mintedServersByBundle: new Map(),
|
|
671
|
+
aborted: false,
|
|
672
|
+
tokens: {
|
|
673
|
+
outputTokens: 0,
|
|
674
|
+
sessionIds: new Set(),
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
// Under `sharedContainer: true`, bundles are minted lazily by
|
|
678
|
+
// `ensureBundleForScope` on the first `executeAgentState` call for
|
|
679
|
+
// each scope. There is no eager workflow-start mint — the first
|
|
680
|
+
// state pays the cost of spinning up the bundle it needs. This also
|
|
681
|
+
// removes a latent "Docker not available" failure mode at workflow
|
|
682
|
+
// registration time: the workflow enters the actor's first state
|
|
683
|
+
// before any Docker resource is touched.
|
|
684
|
+
this.workflows.set(workflowId, instance);
|
|
685
|
+
this.setupTokenSubscription(instance);
|
|
686
|
+
this.emitLifecycleEvent({ kind: 'started', workflowId, name: definition.name, taskDescription });
|
|
687
|
+
this.subscribeToActor(instance);
|
|
688
|
+
actor.start();
|
|
689
|
+
return workflowId;
|
|
690
|
+
}
|
|
691
|
+
async resume(workflowId) {
|
|
692
|
+
const checkpoint = await Promise.resolve(this.deps.checkpointStore.load(workflowId));
|
|
693
|
+
if (!checkpoint) {
|
|
694
|
+
throw new Error(`No checkpoint found for workflow ${workflowId}`);
|
|
695
|
+
}
|
|
696
|
+
const definitionCopyPath = resolve(this.deps.baseDir, workflowId, 'definition.json');
|
|
697
|
+
const definitionPath = existsSync(definitionCopyPath) ? definitionCopyPath : checkpoint.definitionPath;
|
|
698
|
+
// Runtime copies are always JSON; original user files may be YAML
|
|
699
|
+
const raw = parseDefinitionFile(definitionPath);
|
|
700
|
+
const definition = validateDefinition(raw);
|
|
701
|
+
const { machine, gateStateNames, terminalStateNames } = buildWorkflowMachine(definition, checkpoint.context.taskDescription);
|
|
702
|
+
const providedMachine = this.provideActors(machine, workflowId, definition);
|
|
703
|
+
const restoredSnapshot = providedMachine.resolveState({
|
|
704
|
+
value: checkpoint.machineState,
|
|
705
|
+
context: checkpoint.context,
|
|
706
|
+
});
|
|
707
|
+
const actor = createActor(providedMachine, { snapshot: restoredSnapshot });
|
|
708
|
+
const workspacePath = checkpoint.workspacePath ?? resolve(this.deps.baseDir, workflowId, 'workspace');
|
|
709
|
+
const artifactDir = resolve(workspacePath, WORKFLOW_ARTIFACT_DIR);
|
|
710
|
+
// Ensure artifact dir exists (backward compat with pre-workspace checkpoints)
|
|
711
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
712
|
+
const tab = this.deps.createWorkflowTab(definition.name, workflowId);
|
|
713
|
+
// Append to existing log file (resume must not overwrite)
|
|
714
|
+
const messageLog = new MessageLog(resolve(this.deps.baseDir, workflowId, 'messages.jsonl'));
|
|
715
|
+
// Resolve workflow-skills with the run-dir staged copy as primary,
|
|
716
|
+
// a fresh re-stage from the original package dir as recovery, and
|
|
717
|
+
// a logged `undefined` as the last-resort degradation. See
|
|
718
|
+
// `resolveWorkflowSkillsDirOnResume` for the full priority order.
|
|
719
|
+
//
|
|
720
|
+
// Recovery uses `checkpoint.definitionPath` (the user's original
|
|
721
|
+
// package), not `definitionPath` — the latter resolves to the
|
|
722
|
+
// run-dir copy of `definition.json` whose `getWorkflowPackageDir`
|
|
723
|
+
// is the run dir itself, which never contains a sibling `skills/`.
|
|
724
|
+
const runMetaDir = resolve(this.deps.baseDir, workflowId);
|
|
725
|
+
const workflowSkillsDir = resolveWorkflowSkillsDirOnResume({
|
|
726
|
+
workflowId,
|
|
727
|
+
checkpointedStagedDir: checkpoint.workflowSkillsDir,
|
|
728
|
+
packageDir: getWorkflowPackageDir(checkpoint.definitionPath),
|
|
729
|
+
runMetaDir,
|
|
730
|
+
});
|
|
731
|
+
const instance = {
|
|
732
|
+
id: workflowId,
|
|
733
|
+
definition,
|
|
734
|
+
definitionPath: checkpoint.definitionPath,
|
|
735
|
+
workflowSkillsDir,
|
|
736
|
+
actor,
|
|
737
|
+
gateStateNames,
|
|
738
|
+
terminalStateNames,
|
|
739
|
+
activeSessions: new Set(),
|
|
740
|
+
artifactDir,
|
|
741
|
+
workspacePath,
|
|
742
|
+
tab,
|
|
743
|
+
transitionHistory: [...checkpoint.transitionHistory],
|
|
744
|
+
currentState: String(checkpoint.machineState),
|
|
745
|
+
stateEnteredAt: Date.now(),
|
|
746
|
+
messageLog,
|
|
747
|
+
bundlesByScope: new Map(),
|
|
748
|
+
policyDirByPersona: new Map(),
|
|
749
|
+
currentPersonaByBundle: new Map(),
|
|
750
|
+
mintedServersByBundle: new Map(),
|
|
751
|
+
aborted: false,
|
|
752
|
+
tokens: {
|
|
753
|
+
// Resume picks up the checkpointed totalTokens as the accumulator's
|
|
754
|
+
// starting point so post-resume message_end events keep adding to
|
|
755
|
+
// the running total instead of resetting it.
|
|
756
|
+
outputTokens: checkpoint.context.totalTokens,
|
|
757
|
+
sessionIds: new Set(),
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
// Resume does NOT reclaim the original containers — any
|
|
761
|
+
// dependencies installed in the previous run are lost. Under the
|
|
762
|
+
// lazy-mint model there is nothing to do at resume time:
|
|
763
|
+
// `bundlesByScope` starts empty and the first state to execute
|
|
764
|
+
// mints its bundle afresh. Container reclamation across resume is
|
|
765
|
+
// tracked as a follow-up.
|
|
766
|
+
if (this.shouldUseSharedContainer(definition)) {
|
|
767
|
+
writeStderr(`[workflow] Resuming ${workflowId} in shared-container mode: bundles will be re-created lazily per scope. ` +
|
|
768
|
+
`Any dependencies installed in pre-resume containers are lost.`);
|
|
769
|
+
}
|
|
770
|
+
this.workflows.set(workflowId, instance);
|
|
771
|
+
this.setupTokenSubscription(instance);
|
|
772
|
+
this.emitLifecycleEvent({
|
|
773
|
+
kind: 'started',
|
|
774
|
+
workflowId,
|
|
775
|
+
name: definition.name,
|
|
776
|
+
taskDescription: checkpoint.context.taskDescription,
|
|
777
|
+
});
|
|
778
|
+
this.subscribeToActor(instance);
|
|
779
|
+
actor.start();
|
|
780
|
+
// XState v5's resolveState() restores *to* a state but does not *enter* it.
|
|
781
|
+
// Invoke services (agent/deterministic) only start on state entry via transition.
|
|
782
|
+
// For invoke states, we must manually execute the service and feed the result
|
|
783
|
+
// back to the actor as an xstate.done.actor / xstate.error.actor event.
|
|
784
|
+
const restoredState = String(checkpoint.machineState);
|
|
785
|
+
const stateDef = definition.states[restoredState];
|
|
786
|
+
if (stateDef.type === 'agent' || stateDef.type === 'deterministic') {
|
|
787
|
+
this.replayInvokeForRestoredState(workflowId, restoredState, stateDef, definition);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Manually executes the invoke service for a state that was restored from
|
|
792
|
+
* a checkpoint. XState does not re-trigger invocations when an actor is
|
|
793
|
+
* started from a persisted snapshot, so we run the service ourselves and
|
|
794
|
+
* send the result event to the actor.
|
|
795
|
+
*/
|
|
796
|
+
replayInvokeForRestoredState(workflowId, stateId, stateDef, definition) {
|
|
797
|
+
const instance = this.workflows.get(workflowId);
|
|
798
|
+
if (!instance)
|
|
799
|
+
return;
|
|
800
|
+
const snapshot = instance.actor.getSnapshot();
|
|
801
|
+
const context = snapshot.context;
|
|
802
|
+
// Build the service promise based on state type
|
|
803
|
+
const servicePromise = stateDef.type === 'agent'
|
|
804
|
+
? this.executeAgentState(workflowId, { stateId, stateConfig: stateDef, context }, definition)
|
|
805
|
+
: this.executeDeterministicState({ stateId, commands: stateDef.run, context });
|
|
806
|
+
// Feed the result back to the actor as an XState internal invoke event.
|
|
807
|
+
// These event types don't exist in our WorkflowEvent union, so we cast
|
|
808
|
+
// through unknown to satisfy TypeScript while matching XState's internal
|
|
809
|
+
// event format that onDone/onError handlers expect.
|
|
810
|
+
servicePromise
|
|
811
|
+
.then((output) => {
|
|
812
|
+
instance.actor.send({ type: `xstate.done.actor.${stateId}`, output });
|
|
813
|
+
})
|
|
814
|
+
.catch((err) => {
|
|
815
|
+
instance.actor.send({ type: `xstate.error.actor.${stateId}`, error: err });
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
listResumable() {
|
|
819
|
+
const runs = discoverWorkflowRuns(this.deps.baseDir);
|
|
820
|
+
const activeIds = new Set(this.listActive());
|
|
821
|
+
const resumable = [];
|
|
822
|
+
for (const run of runs) {
|
|
823
|
+
if (!run.hasCheckpoint || activeIds.has(run.workflowId))
|
|
824
|
+
continue;
|
|
825
|
+
// FileCheckpointStore.load() re-throws on JSON.parse failure (it only
|
|
826
|
+
// swallows ENOENT). A single corrupt checkpoint.json would otherwise
|
|
827
|
+
// poison the whole list and break `workflow inspect` discovery + the
|
|
828
|
+
// web UI past-runs panel. Skip and log instead.
|
|
829
|
+
let cp;
|
|
830
|
+
try {
|
|
831
|
+
cp = this.deps.checkpointStore.load(run.workflowId);
|
|
832
|
+
}
|
|
833
|
+
catch (err) {
|
|
834
|
+
writeStderr(`[workflow] Failed to load checkpoint for ${run.workflowId}: ${toErrorMessage(err)}; skipping in resumable list`);
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (cp !== undefined && isCheckpointResumable(cp)) {
|
|
838
|
+
resumable.push(run.workflowId);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return resumable;
|
|
842
|
+
}
|
|
843
|
+
getStatus(id) {
|
|
844
|
+
const instance = this.workflows.get(id);
|
|
845
|
+
if (!instance)
|
|
846
|
+
return undefined;
|
|
847
|
+
if (instance.finalStatus)
|
|
848
|
+
return instance.finalStatus;
|
|
849
|
+
if (instance.activeGateId) {
|
|
850
|
+
const gateName = instance.currentState;
|
|
851
|
+
const stateDef = instance.definition.states[gateName];
|
|
852
|
+
if (stateDef.type === 'human_gate') {
|
|
853
|
+
return {
|
|
854
|
+
phase: 'waiting_human',
|
|
855
|
+
gate: this.buildGateRequest(id, gateName, stateDef),
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
phase: 'running',
|
|
861
|
+
currentState: instance.currentState,
|
|
862
|
+
activeAgents: [],
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
getDetail(id) {
|
|
866
|
+
const instance = this.workflows.get(id);
|
|
867
|
+
if (!instance)
|
|
868
|
+
return undefined;
|
|
869
|
+
const snapshot = instance.actor.getSnapshot();
|
|
870
|
+
const ctx = snapshot.context;
|
|
871
|
+
return {
|
|
872
|
+
definition: instance.definition,
|
|
873
|
+
transitionHistory: [...instance.transitionHistory],
|
|
874
|
+
workspacePath: instance.workspacePath,
|
|
875
|
+
context: {
|
|
876
|
+
taskDescription: ctx.taskDescription,
|
|
877
|
+
round: ctx.round,
|
|
878
|
+
maxRounds: ctx.maxRounds,
|
|
879
|
+
totalTokens: ctx.totalTokens,
|
|
880
|
+
visitCounts: { ...ctx.visitCounts },
|
|
881
|
+
},
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
listActive() {
|
|
885
|
+
return [...this.workflows.keys()].filter((id) => {
|
|
886
|
+
const instance = this.workflows.get(id);
|
|
887
|
+
return instance && !instance.finalStatus;
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
resolveGate(id, event) {
|
|
891
|
+
// FORCE_REVISION and REPLAN loop back to an earlier state with the
|
|
892
|
+
// feedback injected into the next agent's prompt (via context.humanPrompt).
|
|
893
|
+
// Without feedback the agent has no signal for what to change and the
|
|
894
|
+
// re-entry prompt ("Revise it to address the human feedback above")
|
|
895
|
+
// references content that does not exist. Require non-empty feedback at
|
|
896
|
+
// the source so every entry point (CLI, web UI, programmatic) fails fast.
|
|
897
|
+
if (event.type === 'FORCE_REVISION' || event.type === 'REPLAN') {
|
|
898
|
+
if (!event.prompt || event.prompt.trim().length === 0) {
|
|
899
|
+
throw new Error(`Feedback is required for ${event.type} events`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const instance = this.workflows.get(id);
|
|
903
|
+
if (!instance)
|
|
904
|
+
return;
|
|
905
|
+
const gateId = instance.activeGateId;
|
|
906
|
+
if (!gateId) {
|
|
907
|
+
// No active gate -- prevent double-resolution
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
// Clear before sending to prevent concurrent double-resolution
|
|
911
|
+
instance.activeGateId = undefined;
|
|
912
|
+
this.deps.dismissGate(instance.id, gateId);
|
|
913
|
+
instance.messageLog.append({
|
|
914
|
+
...this.logBase(instance),
|
|
915
|
+
type: 'gate_resolved',
|
|
916
|
+
event: event.type,
|
|
917
|
+
prompt: event.prompt ?? null,
|
|
918
|
+
});
|
|
919
|
+
const xstateEventName = `HUMAN_${event.type}`;
|
|
920
|
+
instance.actor.send({
|
|
921
|
+
type: xstateEventName,
|
|
922
|
+
prompt: event.prompt,
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
async abort(id) {
|
|
926
|
+
const instance = this.workflows.get(id);
|
|
927
|
+
if (!instance)
|
|
928
|
+
return;
|
|
929
|
+
if (instance.finalStatus?.phase === 'completed' ||
|
|
930
|
+
instance.finalStatus?.phase === 'aborted' ||
|
|
931
|
+
instance.finalStatus?.phase === 'failed') {
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const closePromises = [];
|
|
935
|
+
for (const session of instance.activeSessions) {
|
|
936
|
+
closePromises.push(session.close().catch(() => { }));
|
|
937
|
+
}
|
|
938
|
+
await Promise.allSettled(closePromises);
|
|
939
|
+
instance.activeSessions.clear();
|
|
940
|
+
instance.actor.stop();
|
|
941
|
+
instance.finalStatus = {
|
|
942
|
+
phase: 'aborted',
|
|
943
|
+
reason: 'Workflow aborted by user',
|
|
944
|
+
};
|
|
945
|
+
// Release the token-stream bus subscription before the async infra
|
|
946
|
+
// teardown so no late events land against a finalized workflow.
|
|
947
|
+
this.teardownTokenSubscription(instance);
|
|
948
|
+
// Tear down workflow-scoped Docker infrastructure after all sessions
|
|
949
|
+
// are closed. Error-tolerant (see destroyWorkflowInfrastructure).
|
|
950
|
+
await this.destroyWorkflowInfrastructure(instance);
|
|
951
|
+
// Intentionally leave the checkpoint in place: a user-triggered abort
|
|
952
|
+
// should remain resumable via `workflow resume`. The checkpoint is only
|
|
953
|
+
// removed on successful completion (see handleWorkflowComplete).
|
|
954
|
+
instance.tab.write('[aborted]');
|
|
955
|
+
instance.tab.close();
|
|
956
|
+
this.emitLifecycleEvent({
|
|
957
|
+
kind: 'failed',
|
|
958
|
+
workflowId: id,
|
|
959
|
+
error: 'Workflow aborted by user',
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
onEvent(callback) {
|
|
963
|
+
this.lifecycleCallbacks.push(callback);
|
|
964
|
+
}
|
|
965
|
+
async shutdownAll() {
|
|
966
|
+
const ids = [...this.workflows.keys()];
|
|
967
|
+
// Abort runs full teardown (including destroyWorkflowInfrastructure) for
|
|
968
|
+
// workflows not yet in a terminal state. For already-terminal workflows,
|
|
969
|
+
// abort() early-returns — so we follow up with an explicit destroy pass
|
|
970
|
+
// to defend against any case where a terminal transition skipped infra
|
|
971
|
+
// teardown (e.g., tests forcing finalStatus directly). The destroy call
|
|
972
|
+
// is idempotent when instance.bundlesByScope is empty.
|
|
973
|
+
await Promise.allSettled(ids.map((id) => this.abort(id)));
|
|
974
|
+
await Promise.allSettled(ids.map((id) => {
|
|
975
|
+
const instance = this.workflows.get(id);
|
|
976
|
+
return instance ? this.destroyWorkflowInfrastructure(instance) : Promise.resolve();
|
|
977
|
+
}));
|
|
978
|
+
}
|
|
979
|
+
// -----------------------------------------------------------------------
|
|
980
|
+
// Actor setup helpers
|
|
981
|
+
// -----------------------------------------------------------------------
|
|
982
|
+
/** Injects concrete agent/deterministic service implementations into the machine. */
|
|
983
|
+
provideActors(machine, workflowId, definition) {
|
|
984
|
+
return machine.provide({
|
|
985
|
+
actors: {
|
|
986
|
+
agentService: fromPromise(async ({ input }) => {
|
|
987
|
+
try {
|
|
988
|
+
return await this.executeAgentState(workflowId, input, definition);
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
writeStderr(`[workflow] agentService invoke rejected for "${input.stateId}": ${toErrorMessage(err)}`);
|
|
992
|
+
throw err;
|
|
993
|
+
}
|
|
994
|
+
}),
|
|
995
|
+
deterministicService: fromPromise(async ({ input }) => {
|
|
996
|
+
try {
|
|
997
|
+
return await this.executeDeterministicState(input);
|
|
998
|
+
}
|
|
999
|
+
catch (err) {
|
|
1000
|
+
writeStderr(`[workflow] deterministicService invoke rejected for "${input.stateId}": ${toErrorMessage(err)}`);
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
}),
|
|
1004
|
+
},
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
/** Subscribes to actor snapshot changes for lifecycle events, gates, and checkpointing. */
|
|
1008
|
+
subscribeToActor(instance) {
|
|
1009
|
+
const { actor, gateStateNames, definition, id: workflowId } = instance;
|
|
1010
|
+
actor.subscribe((rawSnapshot) => {
|
|
1011
|
+
const snapshot = rawSnapshot;
|
|
1012
|
+
const stateValue = String(snapshot.value);
|
|
1013
|
+
const previousState = instance.currentState;
|
|
1014
|
+
const now = Date.now();
|
|
1015
|
+
// Record transition and checkpoint only on actual state changes
|
|
1016
|
+
if (stateValue !== previousState) {
|
|
1017
|
+
const duration = instance.stateEnteredAt ? now - instance.stateEnteredAt : 0;
|
|
1018
|
+
const stateType = definition.states[previousState].type;
|
|
1019
|
+
let agentMessage;
|
|
1020
|
+
if (stateType === 'human_gate') {
|
|
1021
|
+
agentMessage = truncateForTransition(snapshot.context.humanPrompt ?? snapshot.context.previousAgentOutput);
|
|
1022
|
+
}
|
|
1023
|
+
else if (stateType === 'agent') {
|
|
1024
|
+
agentMessage = truncateForTransition(snapshot.context.previousAgentOutput);
|
|
1025
|
+
}
|
|
1026
|
+
// deterministic and terminal: no agentMessage (leave undefined)
|
|
1027
|
+
instance.transitionHistory.push({
|
|
1028
|
+
from: previousState,
|
|
1029
|
+
to: stateValue,
|
|
1030
|
+
event: 'transition',
|
|
1031
|
+
timestamp: new Date(now).toISOString(),
|
|
1032
|
+
duration_ms: duration,
|
|
1033
|
+
agentMessage,
|
|
1034
|
+
});
|
|
1035
|
+
instance.stateEnteredAt = now;
|
|
1036
|
+
// Skip checkpointing transitions into terminal states. On successful
|
|
1037
|
+
// completion `handleWorkflowComplete` removes the checkpoint anyway;
|
|
1038
|
+
// on abort we want the surviving checkpoint to point at the last
|
|
1039
|
+
// non-terminal state so `resume()` can restart the run meaningfully.
|
|
1040
|
+
if (!this.isTerminalStateValue(definition, stateValue)) {
|
|
1041
|
+
this.saveCheckpoint(instance, snapshot);
|
|
1042
|
+
}
|
|
1043
|
+
instance.messageLog.append({
|
|
1044
|
+
...this.logBase(instance),
|
|
1045
|
+
type: 'state_transition',
|
|
1046
|
+
from: previousState,
|
|
1047
|
+
event: stateValue,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
instance.currentState = stateValue;
|
|
1051
|
+
instance.tab.write(`[state] ${stateValue}`);
|
|
1052
|
+
// Surface errors from invoke failures (storeError action sets lastError)
|
|
1053
|
+
const ctx = snapshot.context;
|
|
1054
|
+
if (ctx.lastError && ctx.lastError !== instance.lastSurfacedError) {
|
|
1055
|
+
instance.lastSurfacedError = ctx.lastError;
|
|
1056
|
+
writeStderr(`[workflow] Error in context after state "${stateValue}": ${ctx.lastError}`);
|
|
1057
|
+
instance.tab.write(`[error] Agent invoke failed: ${ctx.lastError}`);
|
|
1058
|
+
this.emitLifecycleEvent({
|
|
1059
|
+
kind: 'failed',
|
|
1060
|
+
workflowId,
|
|
1061
|
+
error: `Agent "${previousState}" failed: ${ctx.lastError}`,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
else if (!ctx.lastError && instance.lastSurfacedError) {
|
|
1065
|
+
instance.lastSurfacedError = undefined;
|
|
1066
|
+
}
|
|
1067
|
+
for (const gateName of gateStateNames) {
|
|
1068
|
+
if (snapshot.matches(gateName)) {
|
|
1069
|
+
const stateDef = definition.states[gateName];
|
|
1070
|
+
if (stateDef.type === 'human_gate') {
|
|
1071
|
+
this.handleGateEntry(workflowId, gateName, stateDef);
|
|
1072
|
+
}
|
|
1073
|
+
break;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
this.emitLifecycleEvent({
|
|
1077
|
+
kind: 'state_entered',
|
|
1078
|
+
workflowId,
|
|
1079
|
+
state: stateValue,
|
|
1080
|
+
});
|
|
1081
|
+
// Check for terminal states
|
|
1082
|
+
if (snapshot.status === 'done') {
|
|
1083
|
+
this.handleWorkflowComplete(workflowId, snapshot.context);
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
// -----------------------------------------------------------------------
|
|
1088
|
+
// Checkpointing
|
|
1089
|
+
// -----------------------------------------------------------------------
|
|
1090
|
+
/**
|
|
1091
|
+
* Returns true if `stateValue` names a terminal state in the workflow
|
|
1092
|
+
* definition. Used to skip checkpointing on the final transition so the
|
|
1093
|
+
* last on-disk checkpoint points at the last non-terminal state (important
|
|
1094
|
+
* for resume-after-abort, which would otherwise reload an `aborted`
|
|
1095
|
+
* snapshot and immediately re-terminate).
|
|
1096
|
+
*/
|
|
1097
|
+
isTerminalStateValue(definition, stateValue) {
|
|
1098
|
+
return definition.states[stateValue].type === 'terminal';
|
|
1099
|
+
}
|
|
1100
|
+
// `waiting_human` would smuggle a ReadonlyMap (gate.presentedArtifacts) into
|
|
1101
|
+
// JSON.stringify, which silently emits `{}`. handleWorkflowComplete only
|
|
1102
|
+
// assigns `completed` or `aborted`, so the cycle is safe today.
|
|
1103
|
+
buildCheckpoint(instance, snapshot, finalStatus) {
|
|
1104
|
+
return {
|
|
1105
|
+
machineState: snapshot.value,
|
|
1106
|
+
context: snapshot.context,
|
|
1107
|
+
timestamp: new Date().toISOString(),
|
|
1108
|
+
transitionHistory: [...instance.transitionHistory],
|
|
1109
|
+
definitionPath: instance.definitionPath,
|
|
1110
|
+
workspacePath: instance.workspacePath,
|
|
1111
|
+
// Persisted so resume can read the run-dir staged copy directly
|
|
1112
|
+
// instead of recomputing from the (possibly moved/deleted)
|
|
1113
|
+
// package path. Skipped when the workflow shipped no skills, to
|
|
1114
|
+
// keep the checkpoint shape tight for the common case.
|
|
1115
|
+
...(instance.workflowSkillsDir !== undefined ? { workflowSkillsDir: instance.workflowSkillsDir } : {}),
|
|
1116
|
+
...(finalStatus !== undefined ? { finalStatus } : {}),
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
saveCheckpoint(instance, snapshot) {
|
|
1120
|
+
const checkpoint = this.buildCheckpoint(instance, snapshot);
|
|
1121
|
+
try {
|
|
1122
|
+
this.deps.checkpointStore.save(instance.id, checkpoint);
|
|
1123
|
+
}
|
|
1124
|
+
catch (err) {
|
|
1125
|
+
writeStderr(`[workflow] Failed to save checkpoint for ${instance.id}: ${toErrorMessage(err)}`);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
// -----------------------------------------------------------------------
|
|
1129
|
+
// Agent state execution
|
|
1130
|
+
// -----------------------------------------------------------------------
|
|
1131
|
+
// TODO(refactor): this method is ~230 lines and carries three
|
|
1132
|
+
// invocation-scoped closures (`logReceived`, `logAgentRetry`,
|
|
1133
|
+
// `sendAgentTurn`) that all capture the same bundle of state —
|
|
1134
|
+
// `session`, `instance`, `stateConfig`, `stateId`, `messageLog`, and
|
|
1135
|
+
// `this`. The closure pattern is idiomatic here only because every
|
|
1136
|
+
// alternative (methods, helpers) would propagate that bundle through
|
|
1137
|
+
// 5–6 parameters per site.
|
|
1138
|
+
//
|
|
1139
|
+
// The right fix is to extract the whole turn-handling pipeline
|
|
1140
|
+
// (send/parse/retry/reprompt + the three log helpers) into a
|
|
1141
|
+
// dedicated class — e.g. `AgentTurnRunner` — constructed once per
|
|
1142
|
+
// invocation with the shared state injected. That shrinks this
|
|
1143
|
+
// method from ~230 lines to ~80, and makes turn handling
|
|
1144
|
+
// unit-testable in isolation (today it is reachable only through
|
|
1145
|
+
// end-to-end workflow tests). Pair the extraction with a review of
|
|
1146
|
+
// `AgentInvocationError` wrapping so the conversation-id recovery
|
|
1147
|
+
// semantics survive the move. Worth doing as its own PR; do NOT
|
|
1148
|
+
// fold it into a feature change.
|
|
1149
|
+
async executeAgentState(workflowId, input, definition) {
|
|
1150
|
+
const { stateId, stateConfig, context } = input;
|
|
1151
|
+
const instance = this.workflows.get(workflowId);
|
|
1152
|
+
if (!instance)
|
|
1153
|
+
throw new Error(`Workflow ${workflowId} not found`);
|
|
1154
|
+
const settings = definition.settings ?? {};
|
|
1155
|
+
// Version artifact directories before re-entering a state
|
|
1156
|
+
const visitCount = context.visitCounts[stateId] ?? 0;
|
|
1157
|
+
if (visitCount > 1) {
|
|
1158
|
+
const unversioned = new Set(settings.unversionedArtifacts ?? []);
|
|
1159
|
+
snapshotArtifacts(instance.artifactDir, stateConfig.outputs, visitCount, unversioned);
|
|
1160
|
+
}
|
|
1161
|
+
instance.tab.write(`[agent] Starting "${stateId}" (persona: ${stateConfig.persona})`);
|
|
1162
|
+
const command = buildAgentCommand(stateId, stateConfig, context, definition);
|
|
1163
|
+
const mode = settings.mode === 'builtin'
|
|
1164
|
+
? { kind: 'builtin' }
|
|
1165
|
+
: { kind: 'docker', agent: (settings.dockerAgent ?? 'claude-code') };
|
|
1166
|
+
const effectiveModel = stateConfig.model ?? settings.model;
|
|
1167
|
+
// Shared-container mode only: resolve (or lazily mint) the bundle
|
|
1168
|
+
// for this state's scope, rotate the coordinator's audit stream, and
|
|
1169
|
+
// swap policy before constructing the borrowing session. In
|
|
1170
|
+
// per-state-container mode the new session builds its own
|
|
1171
|
+
// coordinator, so there is nothing to cycle.
|
|
1172
|
+
//
|
|
1173
|
+
// Scope lookup algorithm (docs/designs/workflow-session-identity.md §3):
|
|
1174
|
+
// 1. scope := stateConfig.containerScope ?? "primary"
|
|
1175
|
+
// 2. bundle := instance.bundlesByScope.get(scope)
|
|
1176
|
+
// 3. if absent: lazy-mint via ensureBundleForScope(instance, scope)
|
|
1177
|
+
// 4. borrow bundle via SessionOptions.workflow.infrastructure
|
|
1178
|
+
let bundle;
|
|
1179
|
+
if (this.shouldUseSharedContainer(definition)) {
|
|
1180
|
+
const scope = stateConfig.containerScope ?? DEFAULT_CONTAINER_SCOPE;
|
|
1181
|
+
try {
|
|
1182
|
+
bundle = await this.ensureBundleForScope(instance, scope);
|
|
1183
|
+
}
|
|
1184
|
+
catch (err) {
|
|
1185
|
+
const errMsg = toErrorMessage(err);
|
|
1186
|
+
writeStderr(`[workflow] ensureBundleForScope failed for "${stateId}" (scope=${scope}): ${errMsg}`);
|
|
1187
|
+
instance.tab.write(`[error] Infrastructure setup failed for "${stateId}": ${errMsg}`);
|
|
1188
|
+
instance.messageLog.append({
|
|
1189
|
+
...this.logBase(instance),
|
|
1190
|
+
type: 'error',
|
|
1191
|
+
error: errMsg,
|
|
1192
|
+
context: `ensureBundleForScope "${stateId}" (scope: ${scope})`,
|
|
1193
|
+
});
|
|
1194
|
+
throw err;
|
|
1195
|
+
}
|
|
1196
|
+
try {
|
|
1197
|
+
await this.cyclePolicy(instance, stateConfig.persona, bundle);
|
|
1198
|
+
}
|
|
1199
|
+
catch (err) {
|
|
1200
|
+
const errMsg = toErrorMessage(err);
|
|
1201
|
+
writeStderr(`[workflow] cyclePolicy failed for "${stateId}": ${errMsg}`);
|
|
1202
|
+
instance.tab.write(`[error] Policy cycle failed for "${stateId}": ${errMsg}`);
|
|
1203
|
+
instance.messageLog.append({
|
|
1204
|
+
...this.logBase(instance),
|
|
1205
|
+
type: 'error',
|
|
1206
|
+
error: errMsg,
|
|
1207
|
+
context: `cyclePolicy for "${stateId}" (persona: ${stateConfig.persona})`,
|
|
1208
|
+
});
|
|
1209
|
+
throw err;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
// Agent-CLI conversation identity for this invocation. Decision table
|
|
1213
|
+
// (docs/designs/workflow-session-identity.md §3):
|
|
1214
|
+
// - freshSession:false AND prior entry in agentConversationsByState -> reuse
|
|
1215
|
+
// - otherwise -> mint fresh via createAgentConversationId()
|
|
1216
|
+
// The Docker adapter decides --session-id vs --resume by probing
|
|
1217
|
+
// conversationStateDir for `<agentConversationId>.jsonl`; a minted-fresh
|
|
1218
|
+
// id has no prior file, a reused one does.
|
|
1219
|
+
const priorConversationId = stateConfig.freshSession === false ? context.agentConversationsByState[stateId] : undefined;
|
|
1220
|
+
const agentConversationId = priorConversationId ?? createAgentConversationId();
|
|
1221
|
+
// Mutable tracker updated by the hard-failure retry loop when the
|
|
1222
|
+
// session rotates its conversation id. The final value is what lands
|
|
1223
|
+
// in the invocation result / error and hence in the checkpoint's
|
|
1224
|
+
// `agentConversationsByState`, so a later `freshSession: false` visit
|
|
1225
|
+
// resumes the id whose transcript actually exists on disk.
|
|
1226
|
+
let currentConversationId = agentConversationId;
|
|
1227
|
+
// In borrow mode, route this invocation's per-state artifacts
|
|
1228
|
+
// (session.log, session-metadata.json) under a slug-keyed directory
|
|
1229
|
+
// inside the workflow run. The slug is the next available `.N` in
|
|
1230
|
+
// the bundle's states dir, so true logical re-visits AND resume legs
|
|
1231
|
+
// both get their own dir — never appending into a prior leg's logs.
|
|
1232
|
+
let stateSlug;
|
|
1233
|
+
let workflowStateDir;
|
|
1234
|
+
if (bundle) {
|
|
1235
|
+
stateSlug = nextStateSlug(getBundleStatesDir(instance.id, bundle.bundleId), stateId);
|
|
1236
|
+
workflowStateDir = getInvocationDir(instance.id, bundle.bundleId, stateSlug);
|
|
1237
|
+
mkdirSync(workflowStateDir, { recursive: true });
|
|
1238
|
+
}
|
|
1239
|
+
// Read the cached per-run staged path; never recompute from
|
|
1240
|
+
// `instance.definitionPath` here. Computing it per-state would
|
|
1241
|
+
// re-introduce the resume-fragility bug fixed by staging at start.
|
|
1242
|
+
const workflowSkillsDir = instance.workflowSkillsDir;
|
|
1243
|
+
// Workflow context for the session. Always emitted for workflow
|
|
1244
|
+
// runs (so the orchestrator's identity isn't ambiguous), even when
|
|
1245
|
+
// there's no bundle to borrow — `infrastructure` / `stateDir` /
|
|
1246
|
+
// `stateSlug` opt in only when shared-container mode applies.
|
|
1247
|
+
const workflowOptions = {
|
|
1248
|
+
...(bundle ? { infrastructure: bundle } : {}),
|
|
1249
|
+
...(workflowStateDir ? { stateDir: workflowStateDir, stateSlug } : {}),
|
|
1250
|
+
...resolveWorkflowSkillsOptions(stateConfig.skills, workflowSkillsDir),
|
|
1251
|
+
};
|
|
1252
|
+
let session;
|
|
1253
|
+
try {
|
|
1254
|
+
session = await this.deps.createSession({
|
|
1255
|
+
persona: stateConfig.persona,
|
|
1256
|
+
mode,
|
|
1257
|
+
agentConversationId,
|
|
1258
|
+
workspacePath: instance.workspacePath,
|
|
1259
|
+
systemPromptAugmentation: definition.settings?.systemPrompt,
|
|
1260
|
+
...(effectiveModel != null ? { agentModelOverride: effectiveModel } : {}),
|
|
1261
|
+
...(settings.maxSessionSeconds != null
|
|
1262
|
+
? { resourceBudgetOverrides: { maxSessionSeconds: settings.maxSessionSeconds } }
|
|
1263
|
+
: {}),
|
|
1264
|
+
// The nested record colocates the borrowed bundle, per-state
|
|
1265
|
+
// artifact dir, and workflow-bundled skills. `buildSessionConfig`
|
|
1266
|
+
// enforces the borrow-mode invariant (stateDir requires
|
|
1267
|
+
// infrastructure) at runtime.
|
|
1268
|
+
...(Object.keys(workflowOptions).length > 0 ? { workflow: workflowOptions } : {}),
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
const errMsg = toErrorMessage(err);
|
|
1273
|
+
writeStderr(`[workflow] Session creation failed for "${stateId}": ${errMsg}`);
|
|
1274
|
+
instance.tab.write(`[error] Session creation failed for "${stateId}": ${errMsg}`);
|
|
1275
|
+
instance.messageLog.append({
|
|
1276
|
+
...this.logBase(instance),
|
|
1277
|
+
type: 'error',
|
|
1278
|
+
error: errMsg,
|
|
1279
|
+
context: `session creation for "${stateId}"`,
|
|
1280
|
+
});
|
|
1281
|
+
throw new AgentInvocationError({ stateId, agentConversationId: currentConversationId, cause: err });
|
|
1282
|
+
}
|
|
1283
|
+
instance.activeSessions.add(session);
|
|
1284
|
+
try {
|
|
1285
|
+
instance.tab.write(`[agent] Sending command to "${stateId}"...`);
|
|
1286
|
+
const { messageLog } = instance;
|
|
1287
|
+
const statusInstructions = buildStatusInstructions(stateConfig.transitions);
|
|
1288
|
+
const logReceived = (text, output) => {
|
|
1289
|
+
messageLog.append({
|
|
1290
|
+
...this.logBase(instance),
|
|
1291
|
+
type: 'agent_received',
|
|
1292
|
+
role: stateConfig.persona,
|
|
1293
|
+
message: text,
|
|
1294
|
+
verdict: output?.verdict ?? null,
|
|
1295
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- logged for diagnostics
|
|
1296
|
+
confidence: output?.confidence ?? null,
|
|
1297
|
+
});
|
|
1298
|
+
};
|
|
1299
|
+
const logAgentRetry = (reason, details, retryMessage) => {
|
|
1300
|
+
messageLog.append({
|
|
1301
|
+
...this.logBase(instance),
|
|
1302
|
+
type: 'agent_retry',
|
|
1303
|
+
role: stateConfig.persona,
|
|
1304
|
+
reason,
|
|
1305
|
+
details,
|
|
1306
|
+
retryMessage,
|
|
1307
|
+
});
|
|
1308
|
+
};
|
|
1309
|
+
// Uniform agent-turn entrypoint: every prompt and reprompt must flow
|
|
1310
|
+
// through here so the quota-exhaustion / transient-failure
|
|
1311
|
+
// short-circuits apply to ALL of them. Retrying or reprompting a turn
|
|
1312
|
+
// whose adapter reported either signal would only burn more of the
|
|
1313
|
+
// already-exhausted budget (quota) or hammer a stalled upstream
|
|
1314
|
+
// (transient) — instead we log the structured event once and throw
|
|
1315
|
+
// a dedicated error so higher layers can distinguish "paused by
|
|
1316
|
+
// provider" / "stalled upstream" from "aborted by bug" (today all
|
|
1317
|
+
// three abort; M4 turns these into a `paused` terminal phase without
|
|
1318
|
+
// touching here).
|
|
1319
|
+
//
|
|
1320
|
+
// Sessions without `sendMessageDetailed` (e.g., built-in in-process
|
|
1321
|
+
// sessions) degrade cleanly to `sendMessage`, which cannot produce
|
|
1322
|
+
// either signal — the short-circuits simply never fire for them.
|
|
1323
|
+
const sendAgentTurn = async (msg) => {
|
|
1324
|
+
const result = session.sendMessageDetailed
|
|
1325
|
+
? await session.sendMessageDetailed(msg)
|
|
1326
|
+
: { text: await session.sendMessage(msg), hardFailure: false };
|
|
1327
|
+
if (result.quotaExhausted) {
|
|
1328
|
+
const { resetAt, rawMessage } = result.quotaExhausted;
|
|
1329
|
+
logReceived(result.text, undefined);
|
|
1330
|
+
messageLog.append({
|
|
1331
|
+
...this.logBase(instance),
|
|
1332
|
+
type: 'quota_exhausted',
|
|
1333
|
+
role: stateConfig.persona,
|
|
1334
|
+
resetAt: resetAt?.toISOString(),
|
|
1335
|
+
rawMessage,
|
|
1336
|
+
});
|
|
1337
|
+
// Stamp the instance before throwing so `handleWorkflowComplete`
|
|
1338
|
+
// can force an abort-preserving terminal regardless of which
|
|
1339
|
+
// state the onError target resolves to — protects checkpoint
|
|
1340
|
+
// retention for workflow definitions whose error target happens
|
|
1341
|
+
// to be a normal terminal (e.g. `done`).
|
|
1342
|
+
instance.quotaExhausted = { resetAt, rawMessage };
|
|
1343
|
+
throw new WorkflowQuotaExhaustedError({ stateId, resetAt, rawMessage });
|
|
1344
|
+
}
|
|
1345
|
+
if (result.transientFailure) {
|
|
1346
|
+
const { kind, rawMessage } = result.transientFailure;
|
|
1347
|
+
// Same shape as the quota branch — append a structured log
|
|
1348
|
+
// entry, stamp the instance, throw a dedicated error — but
|
|
1349
|
+
// deliberately skip `logReceived`. Quota envelopes carry the
|
|
1350
|
+
// provider's actual rate-limit message in `result.text`, which
|
|
1351
|
+
// is worth recording as the agent's turn. Transient envelopes
|
|
1352
|
+
// carry only the agent's preamble with no real assistant
|
|
1353
|
+
// content, so logging it as `agent_received` would falsely
|
|
1354
|
+
// imply the agent produced a turn.
|
|
1355
|
+
messageLog.append({
|
|
1356
|
+
...this.logBase(instance),
|
|
1357
|
+
type: 'transient_failure',
|
|
1358
|
+
role: stateConfig.persona,
|
|
1359
|
+
kind,
|
|
1360
|
+
rawMessage,
|
|
1361
|
+
});
|
|
1362
|
+
instance.transientFailure = { kind, rawMessage };
|
|
1363
|
+
throw new WorkflowTransientFailureError({ stateId, kind, rawMessage });
|
|
1364
|
+
}
|
|
1365
|
+
return result;
|
|
1366
|
+
};
|
|
1367
|
+
messageLog.append({
|
|
1368
|
+
...this.logBase(instance),
|
|
1369
|
+
type: 'agent_sent',
|
|
1370
|
+
role: stateConfig.persona,
|
|
1371
|
+
message: command,
|
|
1372
|
+
});
|
|
1373
|
+
// Shared-container mode: the MITM is long-lived across agents. Flip
|
|
1374
|
+
// its routing id so this agent's events land under its own session id
|
|
1375
|
+
// (matching what the bridge registers below). Optional-chain because
|
|
1376
|
+
// builtin/per-state-container workflows have no shared `infra`.
|
|
1377
|
+
const agentSessionId = session.getInfo().id;
|
|
1378
|
+
bundle?.setTokenSessionId(agentSessionId);
|
|
1379
|
+
instance.tokens.sessionIds.add(agentSessionId);
|
|
1380
|
+
this.emitLifecycleEvent({
|
|
1381
|
+
kind: 'agent_started',
|
|
1382
|
+
workflowId,
|
|
1383
|
+
state: stateId,
|
|
1384
|
+
persona: stateConfig.persona,
|
|
1385
|
+
sessionId: agentSessionId,
|
|
1386
|
+
});
|
|
1387
|
+
// Two-phase retry: (1) hard-failure retries re-send the ORIGINAL
|
|
1388
|
+
// command with a rotated conversation id, (2) soft-failure reprompt
|
|
1389
|
+
// asks the agent to fix a missing/malformed agent_status block.
|
|
1390
|
+
//
|
|
1391
|
+
// Hard failures (exitCode != 0 with empty output, e.g., upstream
|
|
1392
|
+
// provider stall that kills the CLI mid-stream) leave the agent's
|
|
1393
|
+
// session id consumed but no resumable transcript on disk — a retry
|
|
1394
|
+
// with the same id is rejected by the CLI. Rotating and resending the
|
|
1395
|
+
// original prompt is the correct recovery path; a missing-status-block
|
|
1396
|
+
// reprompt into a dead session cannot possibly succeed.
|
|
1397
|
+
const MAX_HARD_RETRIES = 2;
|
|
1398
|
+
let responseText = '';
|
|
1399
|
+
for (let attempt = 0; attempt <= MAX_HARD_RETRIES; attempt++) {
|
|
1400
|
+
const result = await sendAgentTurn(command);
|
|
1401
|
+
responseText = result.text;
|
|
1402
|
+
if (!result.hardFailure)
|
|
1403
|
+
break;
|
|
1404
|
+
logReceived(responseText, undefined);
|
|
1405
|
+
logAgentRetry('upstream_stall', `Agent exited without producing output (attempt ${attempt + 1}/${MAX_HARD_RETRIES + 1})`, command);
|
|
1406
|
+
if (attempt === MAX_HARD_RETRIES) {
|
|
1407
|
+
throw new Error(`Agent failed to produce output after ${MAX_HARD_RETRIES + 1} attempts (upstream stall)`);
|
|
1408
|
+
}
|
|
1409
|
+
const rotated = session.rotateAgentConversationId?.();
|
|
1410
|
+
if (rotated)
|
|
1411
|
+
currentConversationId = rotated;
|
|
1412
|
+
}
|
|
1413
|
+
let parseResult = tryParseAgentStatus(responseText);
|
|
1414
|
+
logReceived(responseText, parseResult.kind === 'ok' ? parseResult.output : undefined);
|
|
1415
|
+
let agentOutput;
|
|
1416
|
+
if (parseResult.kind === 'ok') {
|
|
1417
|
+
agentOutput = parseResult.output;
|
|
1418
|
+
}
|
|
1419
|
+
else {
|
|
1420
|
+
const malformed = parseResult.kind === 'malformed' ? parseResult.error : undefined;
|
|
1421
|
+
const retryMsg = buildStatusBlockReprompt(statusInstructions, malformed);
|
|
1422
|
+
logAgentRetry(malformed ? 'malformed_status_block' : 'missing_status_block', malformed
|
|
1423
|
+
? `Malformed agent_status block: ${malformed.message}`
|
|
1424
|
+
: 'Response did not contain an agent_status block', retryMsg);
|
|
1425
|
+
responseText = (await sendAgentTurn(retryMsg)).text;
|
|
1426
|
+
parseResult = tryParseAgentStatus(responseText);
|
|
1427
|
+
logReceived(responseText, parseResult.kind === 'ok' ? parseResult.output : undefined);
|
|
1428
|
+
if (parseResult.kind !== 'ok') {
|
|
1429
|
+
throw new Error(parseResult.kind === 'malformed'
|
|
1430
|
+
? `Agent produced a malformed agent_status block after retry: ${parseResult.error.message}`
|
|
1431
|
+
: 'Agent failed to provide agent_status block after retry');
|
|
1432
|
+
}
|
|
1433
|
+
agentOutput = parseResult.output;
|
|
1434
|
+
}
|
|
1435
|
+
// Validate verdict against valid transitions before the result reaches XState.
|
|
1436
|
+
// This prevents silent deadlocks when the agent returns a verdict that no
|
|
1437
|
+
// transition's `when` clause matches.
|
|
1438
|
+
const validVerdicts = getValidVerdicts(stateConfig.transitions);
|
|
1439
|
+
if (validVerdicts && !validVerdicts.has(agentOutput.verdict)) {
|
|
1440
|
+
const retryMsg = buildInvalidVerdictReprompt(agentOutput.verdict, stateConfig.transitions);
|
|
1441
|
+
logAgentRetry('invalid_verdict', `Verdict "${agentOutput.verdict}" not in valid set: ${[...validVerdicts].join(', ')}`, retryMsg);
|
|
1442
|
+
responseText = (await sendAgentTurn(retryMsg)).text;
|
|
1443
|
+
const retryParse = tryParseAgentStatus(responseText);
|
|
1444
|
+
logReceived(responseText, retryParse.kind === 'ok' ? retryParse.output : undefined);
|
|
1445
|
+
if (retryParse.kind !== 'ok') {
|
|
1446
|
+
const suffix = retryParse.kind === 'malformed'
|
|
1447
|
+
? `retry produced a malformed status block: ${retryParse.error.message}`
|
|
1448
|
+
: 'retry did not include a status block';
|
|
1449
|
+
throw new Error(`Agent verdict "${agentOutput.verdict}" is not valid for this state ` +
|
|
1450
|
+
`(expected one of: ${[...validVerdicts].join(', ')}), and ${suffix}`);
|
|
1451
|
+
}
|
|
1452
|
+
if (!validVerdicts.has(retryParse.output.verdict)) {
|
|
1453
|
+
throw new Error(`Agent returned invalid verdict "${retryParse.output.verdict}" after retry ` +
|
|
1454
|
+
`(expected one of: ${[...validVerdicts].join(', ')})`);
|
|
1455
|
+
}
|
|
1456
|
+
agentOutput = retryParse.output;
|
|
1457
|
+
}
|
|
1458
|
+
const missingArtifacts = this.findMissingArtifacts(stateConfig, instance.artifactDir);
|
|
1459
|
+
if (missingArtifacts.length > 0) {
|
|
1460
|
+
const artifactRetryMsg = buildArtifactReprompt(missingArtifacts, stateConfig.transitions);
|
|
1461
|
+
logAgentRetry('missing_artifacts', `Missing: ${missingArtifacts.join(', ')}`, artifactRetryMsg);
|
|
1462
|
+
const retryResponse = (await sendAgentTurn(artifactRetryMsg)).text;
|
|
1463
|
+
const retryParse = tryParseAgentStatus(retryResponse);
|
|
1464
|
+
if (retryParse.kind === 'ok') {
|
|
1465
|
+
logReceived(retryResponse, retryParse.output);
|
|
1466
|
+
agentOutput = retryParse.output;
|
|
1467
|
+
}
|
|
1468
|
+
else {
|
|
1469
|
+
logReceived(retryResponse, undefined);
|
|
1470
|
+
}
|
|
1471
|
+
const stillMissing = this.findMissingArtifacts(stateConfig, instance.artifactDir);
|
|
1472
|
+
if (stillMissing.length > 0) {
|
|
1473
|
+
throw new Error(`Missing artifacts after retry: ${stillMissing.join(', ')}`);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
const outputHash = computeOutputHash(stateConfig.outputs, instance.artifactDir, instance.workspacePath);
|
|
1477
|
+
const artifacts = collectArtifactPaths(stateConfig.outputs, instance.artifactDir);
|
|
1478
|
+
instance.tab.write(`[agent] "${stateId}" completed: verdict=${agentOutput.verdict}, artifacts=${Object.keys(artifacts).join(',') || 'none'}`);
|
|
1479
|
+
this.emitLifecycleEvent({
|
|
1480
|
+
kind: 'agent_completed',
|
|
1481
|
+
workflowId,
|
|
1482
|
+
state: stateId,
|
|
1483
|
+
persona: stateConfig.persona,
|
|
1484
|
+
verdict: agentOutput.verdict,
|
|
1485
|
+
});
|
|
1486
|
+
return {
|
|
1487
|
+
output: agentOutput,
|
|
1488
|
+
agentConversationId: currentConversationId,
|
|
1489
|
+
artifacts,
|
|
1490
|
+
outputHash,
|
|
1491
|
+
responseText,
|
|
1492
|
+
// Snapshot the workflow's cumulative output-token count AT THE
|
|
1493
|
+
// END of this agent's turn. The XState assign action uses this
|
|
1494
|
+
// value to update ctx.totalTokens; it reflects every `message_end`
|
|
1495
|
+
// the bus subscriber has seen so far, including all earlier
|
|
1496
|
+
// agents in the workflow.
|
|
1497
|
+
totalTokens: instance.tokens.outputTokens,
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
catch (err) {
|
|
1501
|
+
// Quota exhaustion / transient upstream failure already produced
|
|
1502
|
+
// their own structured log entries inside `sendAgentTurn`;
|
|
1503
|
+
// appending a generic `error` entry here would double-log the same
|
|
1504
|
+
// event and make `workflow inspect` surface a duplicate red error
|
|
1505
|
+
// line alongside the structured signal. Suppress the generic entry
|
|
1506
|
+
// when the cause is a `WorkflowQuotaExhaustedError` or
|
|
1507
|
+
// `WorkflowTransientFailureError`.
|
|
1508
|
+
if (!isWorkflowQuotaExhaustedError(err) && !isWorkflowTransientFailureError(err)) {
|
|
1509
|
+
instance.messageLog.append({
|
|
1510
|
+
...this.logBase(instance),
|
|
1511
|
+
type: 'error',
|
|
1512
|
+
error: toErrorMessage(err),
|
|
1513
|
+
context: `agent "${stateId}" (persona: ${stateConfig.persona})`,
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
throw new AgentInvocationError({ stateId, agentConversationId: currentConversationId, cause: err });
|
|
1517
|
+
}
|
|
1518
|
+
finally {
|
|
1519
|
+
instance.activeSessions.delete(session);
|
|
1520
|
+
const endedSessionId = session.getInfo().id;
|
|
1521
|
+
await session.close().catch((closeErr) => {
|
|
1522
|
+
writeStderr(`[workflow] session.close() failed for "${stateId}": ${toErrorMessage(closeErr)}`);
|
|
1523
|
+
});
|
|
1524
|
+
// session.close() drains in-flight streams that captured the
|
|
1525
|
+
// agent's session id at attach time; they keep posting under it
|
|
1526
|
+
// until they finish, regardless of setTokenSessionId(undefined).
|
|
1527
|
+
// The clear here only governs future attachments.
|
|
1528
|
+
bundle?.setTokenSessionId(undefined);
|
|
1529
|
+
instance.tokens.sessionIds.delete(endedSessionId);
|
|
1530
|
+
// Pairs 1:1 with agent_started; emitted unconditionally in finally
|
|
1531
|
+
// so success, failure, and abort paths all clean up the bridge.
|
|
1532
|
+
this.emitLifecycleEvent({
|
|
1533
|
+
kind: 'agent_session_ended',
|
|
1534
|
+
workflowId,
|
|
1535
|
+
state: stateId,
|
|
1536
|
+
sessionId: endedSessionId,
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
// -----------------------------------------------------------------------
|
|
1541
|
+
// Deterministic state execution
|
|
1542
|
+
// -----------------------------------------------------------------------
|
|
1543
|
+
async executeDeterministicState(input) {
|
|
1544
|
+
const { commands } = input;
|
|
1545
|
+
let totalTestCount = 0;
|
|
1546
|
+
const allErrors = [];
|
|
1547
|
+
for (const cmdArray of commands) {
|
|
1548
|
+
if (cmdArray.length === 0)
|
|
1549
|
+
continue;
|
|
1550
|
+
const [binary, ...args] = cmdArray;
|
|
1551
|
+
try {
|
|
1552
|
+
const { stdout } = await execFileAsync(binary, args);
|
|
1553
|
+
// Try to extract test count from stdout (simple heuristic)
|
|
1554
|
+
const testMatch = /(\d+)\s+(?:tests?|specs?)\s+pass/i.exec(stdout);
|
|
1555
|
+
if (testMatch) {
|
|
1556
|
+
totalTestCount += parseInt(testMatch[1], 10);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
catch (err) {
|
|
1560
|
+
const execErr = err;
|
|
1561
|
+
allErrors.push(execErr.stderr ?? execErr.stdout ?? String(err));
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
return {
|
|
1565
|
+
passed: allErrors.length === 0,
|
|
1566
|
+
testCount: totalTestCount > 0 ? totalTestCount : undefined,
|
|
1567
|
+
errors: allErrors.length > 0 ? allErrors.join('\n') : undefined,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
// -----------------------------------------------------------------------
|
|
1571
|
+
// Gate handling
|
|
1572
|
+
// -----------------------------------------------------------------------
|
|
1573
|
+
handleGateEntry(workflowId, gateName, stateDef) {
|
|
1574
|
+
const instance = this.workflows.get(workflowId);
|
|
1575
|
+
if (!instance)
|
|
1576
|
+
return;
|
|
1577
|
+
const gateRequest = this.buildGateRequest(workflowId, gateName, stateDef);
|
|
1578
|
+
instance.activeGateId = gateRequest.gateId;
|
|
1579
|
+
instance.messageLog.append({
|
|
1580
|
+
...this.logBase(instance),
|
|
1581
|
+
type: 'gate_raised',
|
|
1582
|
+
acceptedEvents: stateDef.acceptedEvents,
|
|
1583
|
+
});
|
|
1584
|
+
this.deps.raiseGate(gateRequest);
|
|
1585
|
+
this.emitLifecycleEvent({
|
|
1586
|
+
kind: 'gate_raised',
|
|
1587
|
+
workflowId,
|
|
1588
|
+
gate: gateRequest,
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1591
|
+
buildGateRequest(workflowId, gateName, stateDef) {
|
|
1592
|
+
const instance = this.workflows.get(workflowId);
|
|
1593
|
+
const presentedArtifacts = new Map();
|
|
1594
|
+
for (const artifactRef of stateDef.present ?? []) {
|
|
1595
|
+
if (instance) {
|
|
1596
|
+
const { name } = parseArtifactRef(artifactRef);
|
|
1597
|
+
const dir = resolve(instance.artifactDir, name);
|
|
1598
|
+
if (existsSync(dir)) {
|
|
1599
|
+
presentedArtifacts.set(name, dir);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
// Surface context.lastError in the gate summary so callers know
|
|
1604
|
+
// whether this gate was reached normally or via an invoke error.
|
|
1605
|
+
const snapshot = instance?.actor.getSnapshot();
|
|
1606
|
+
const errorContext = snapshot?.context?.lastError ? ` (error: ${snapshot.context.lastError})` : '';
|
|
1607
|
+
return {
|
|
1608
|
+
gateId: `${workflowId}-${gateName}`,
|
|
1609
|
+
workflowId,
|
|
1610
|
+
stateName: gateName,
|
|
1611
|
+
acceptedEvents: stateDef.acceptedEvents,
|
|
1612
|
+
presentedArtifacts,
|
|
1613
|
+
summary: `Waiting for human review at ${gateName}${errorContext}`,
|
|
1614
|
+
};
|
|
1615
|
+
}
|
|
1616
|
+
// -----------------------------------------------------------------------
|
|
1617
|
+
// Completion handling
|
|
1618
|
+
// -----------------------------------------------------------------------
|
|
1619
|
+
handleWorkflowComplete(workflowId, context) {
|
|
1620
|
+
const instance = this.workflows.get(workflowId);
|
|
1621
|
+
if (!instance)
|
|
1622
|
+
return;
|
|
1623
|
+
// Guard against XState emitting a terminal transition multiple times.
|
|
1624
|
+
// `finalStatus` being set is the canonical "already completed" signal;
|
|
1625
|
+
// if it's set, skip re-entering terminal handling so we don't call
|
|
1626
|
+
// destroyWorkflowInfrastructure twice (the teardown itself is idempotent,
|
|
1627
|
+
// but duplicate tab.close() / checkpoint.remove() / lifecycle events
|
|
1628
|
+
// are noisy).
|
|
1629
|
+
if (instance.finalStatus)
|
|
1630
|
+
return;
|
|
1631
|
+
// Check if this is a normal completion or an aborted terminal.
|
|
1632
|
+
// Quota exhaustion takes precedence: the error target may have
|
|
1633
|
+
// resolved to any terminal (including `done`-like ones), but a run
|
|
1634
|
+
// that died on upstream quota MUST be treated as aborted so the
|
|
1635
|
+
// checkpoint is preserved and the user can resume once the provider
|
|
1636
|
+
// window reopens. (M4 will upgrade this to a dedicated `paused`
|
|
1637
|
+
// phase; for now `aborted` gives us the same checkpoint retention.)
|
|
1638
|
+
const stateValue = instance.currentState;
|
|
1639
|
+
if (instance.quotaExhausted) {
|
|
1640
|
+
const resetHint = instance.quotaExhausted.resetAt
|
|
1641
|
+
? ` (resets at ${instance.quotaExhausted.resetAt.toISOString()})`
|
|
1642
|
+
: '';
|
|
1643
|
+
instance.finalStatus = {
|
|
1644
|
+
phase: 'aborted',
|
|
1645
|
+
reason: `Upstream quota exhausted${resetHint}`,
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
else if (instance.transientFailure) {
|
|
1649
|
+
// Mirror the quota branch: a transient upstream failure must force
|
|
1650
|
+
// an abort-preserving terminal regardless of which state
|
|
1651
|
+
// `findErrorTarget` resolved to. Without this, a workflow whose
|
|
1652
|
+
// only terminal is `done` would land on `done` and
|
|
1653
|
+
// `handleWorkflowComplete` would mark the run `completed`, breaking
|
|
1654
|
+
// resume.
|
|
1655
|
+
const excerpt = instance.transientFailure.rawMessage.slice(0, 200);
|
|
1656
|
+
instance.finalStatus = {
|
|
1657
|
+
phase: 'aborted',
|
|
1658
|
+
reason: `Transient upstream failure: ${describeTransientFailureKind(instance.transientFailure.kind)} ` +
|
|
1659
|
+
`(resumable — run "ironcurtain workflow resume <baseDir>" once upstream is healthy)\n${excerpt}`,
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
else if (stateValue === 'aborted' || stateValue.includes('abort')) {
|
|
1663
|
+
instance.finalStatus = {
|
|
1664
|
+
phase: 'aborted',
|
|
1665
|
+
reason: 'Workflow reached aborted state',
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
else {
|
|
1669
|
+
const result = { finalArtifacts: { ...context.artifacts } };
|
|
1670
|
+
instance.finalStatus = {
|
|
1671
|
+
phase: 'completed',
|
|
1672
|
+
result,
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
// Persist finalStatus so isCheckpointResumable can later distinguish
|
|
1676
|
+
// completed (excluded) from aborted/failed (still resumable). Preserve
|
|
1677
|
+
// the existing on-disk machineState/context (which points at the last
|
|
1678
|
+
// non-terminal state) so resume-after-abort can re-enter that state
|
|
1679
|
+
// instead of immediately re-completing on a terminal snapshot. The
|
|
1680
|
+
// fallback path covers the unusual case where no prior checkpoint
|
|
1681
|
+
// exists (e.g. a workflow that transitioned straight to terminal
|
|
1682
|
+
// without ever passing through a non-terminal save point).
|
|
1683
|
+
try {
|
|
1684
|
+
const existing = this.deps.checkpointStore.load(workflowId);
|
|
1685
|
+
const terminalCheckpoint = this.buildCheckpoint(instance, instance.actor.getSnapshot(), instance.finalStatus);
|
|
1686
|
+
const checkpoint = existing
|
|
1687
|
+
? {
|
|
1688
|
+
...terminalCheckpoint,
|
|
1689
|
+
machineState: existing.machineState,
|
|
1690
|
+
context: existing.context,
|
|
1691
|
+
}
|
|
1692
|
+
: terminalCheckpoint;
|
|
1693
|
+
this.deps.checkpointStore.save(workflowId, checkpoint);
|
|
1694
|
+
}
|
|
1695
|
+
catch (err) {
|
|
1696
|
+
writeStderr(`[workflow] Failed to save terminal checkpoint for ${workflowId}: ${toErrorMessage(err)}`);
|
|
1697
|
+
}
|
|
1698
|
+
instance.tab.write(`[done] ${instance.finalStatus.phase}`);
|
|
1699
|
+
instance.tab.close();
|
|
1700
|
+
// Release the token-stream bus subscription. Done eagerly (before the
|
|
1701
|
+
// async destroyWorkflowInfrastructure) because it's a synchronous
|
|
1702
|
+
// callback — idempotent if already cleared by abort().
|
|
1703
|
+
this.teardownTokenSubscription(instance);
|
|
1704
|
+
// Tear down workflow-scoped Docker infrastructure. Runs asynchronously
|
|
1705
|
+
// because the actor subscription is sync; destroyWorkflowInfrastructure
|
|
1706
|
+
// is error-tolerant so unhandled rejections should not occur, but we
|
|
1707
|
+
// still catch defensively for a belt-and-suspenders guarantee.
|
|
1708
|
+
void this.destroyWorkflowInfrastructure(instance).catch((err) => {
|
|
1709
|
+
writeStderr(`[workflow] destroyWorkflowInfrastructure unexpectedly threw for ${workflowId}: ${toErrorMessage(err)}`);
|
|
1710
|
+
});
|
|
1711
|
+
this.emitLifecycleEvent({
|
|
1712
|
+
kind: 'completed',
|
|
1713
|
+
workflowId,
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
// -----------------------------------------------------------------------
|
|
1717
|
+
// Artifact helpers
|
|
1718
|
+
// -----------------------------------------------------------------------
|
|
1719
|
+
findMissingArtifacts(stateConfig, artifactDir) {
|
|
1720
|
+
const missing = [];
|
|
1721
|
+
for (const output of stateConfig.outputs) {
|
|
1722
|
+
const dir = resolve(artifactDir, output);
|
|
1723
|
+
if (!hasAnyFiles(dir)) {
|
|
1724
|
+
missing.push(output);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return missing;
|
|
1728
|
+
}
|
|
1729
|
+
// -----------------------------------------------------------------------
|
|
1730
|
+
// Lifecycle event emission
|
|
1731
|
+
// -----------------------------------------------------------------------
|
|
1732
|
+
emitLifecycleEvent(event) {
|
|
1733
|
+
for (const cb of this.lifecycleCallbacks) {
|
|
1734
|
+
try {
|
|
1735
|
+
cb(event);
|
|
1736
|
+
}
|
|
1737
|
+
catch (cbErr) {
|
|
1738
|
+
// Lifecycle callbacks should not crash the orchestrator, but log the failure
|
|
1739
|
+
console.error(`[workflow] Lifecycle callback threw for "${event.kind}":`, cbErr);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
// ---------------------------------------------------------------------------
|
|
1745
|
+
// Standalone helpers (exported for testing)
|
|
1746
|
+
// ---------------------------------------------------------------------------
|
|
1747
|
+
// ---------------------------------------------------------------------------
|
|
1748
|
+
// Policy cycling helpers (workflow shared-container mode)
|
|
1749
|
+
// ---------------------------------------------------------------------------
|
|
1750
|
+
/**
|
|
1751
|
+
* Resolves the compiled-policy directory for the given persona name.
|
|
1752
|
+
*
|
|
1753
|
+
* The reserved sentinel `GLOBAL_PERSONA` maps to the package-bundled
|
|
1754
|
+
* generated dir (same one `loadConfig().generatedDir` resolves to for
|
|
1755
|
+
* CLI runs). All other values go through `resolvePersona()`, which
|
|
1756
|
+
* validates the persona exists and has a compiled policy.
|
|
1757
|
+
*/
|
|
1758
|
+
function resolvePersonaPolicyDir(persona) {
|
|
1759
|
+
if (persona === GLOBAL_PERSONA) {
|
|
1760
|
+
return loadConfig().generatedDir;
|
|
1761
|
+
}
|
|
1762
|
+
return resolvePersona(persona).policyDir;
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* Default `startWorkflowControlServer`: reaches through the infra
|
|
1766
|
+
* bundle's proxy for its policy-swap target and binds a control server
|
|
1767
|
+
* at the workflow-scoped UDS path.
|
|
1768
|
+
*/
|
|
1769
|
+
async function defaultStartWorkflowControlServer(input) {
|
|
1770
|
+
const target = input.infra.proxy.getPolicySwapTarget();
|
|
1771
|
+
if (!target) {
|
|
1772
|
+
throw new Error('Cannot attach workflow control server: the proxy is not yet started. ' +
|
|
1773
|
+
'Call proxy.start() before attaching a control server.');
|
|
1774
|
+
}
|
|
1775
|
+
await target.startControlServer({ socketPath: input.socketPath });
|
|
1776
|
+
}
|
|
1777
|
+
/**
|
|
1778
|
+
* Default `loadPolicyRpc`: speaks HTTP/1.1 over the supplied UDS using
|
|
1779
|
+
* Node's built-in client. Resolves on a 2xx response; rejects on any
|
|
1780
|
+
* non-2xx status, request timeout, or socket error. The coordinator's
|
|
1781
|
+
* reply body is read only on the error path (to surface the error
|
|
1782
|
+
* text) — 2xx bodies are discarded with `res.resume()`.
|
|
1783
|
+
*
|
|
1784
|
+
* Exported so integration tests can drive the wire format without
|
|
1785
|
+
* standing up a full `WorkflowOrchestrator`. Keeping the HTTP request
|
|
1786
|
+
* construction in one place guarantees tests exercise the same bytes
|
|
1787
|
+
* production emits.
|
|
1788
|
+
*/
|
|
1789
|
+
export async function defaultLoadPolicyRpc(input) {
|
|
1790
|
+
const body = JSON.stringify({
|
|
1791
|
+
persona: input.persona,
|
|
1792
|
+
policyDir: input.policyDir,
|
|
1793
|
+
});
|
|
1794
|
+
const timeoutMs = input.timeoutMs ?? LOAD_POLICY_RPC_TIMEOUT_MS;
|
|
1795
|
+
return new Promise((resolveFn, rejectFn) => {
|
|
1796
|
+
let settled = false;
|
|
1797
|
+
const settle = (err) => {
|
|
1798
|
+
if (settled)
|
|
1799
|
+
return;
|
|
1800
|
+
settled = true;
|
|
1801
|
+
if (err)
|
|
1802
|
+
rejectFn(err);
|
|
1803
|
+
else
|
|
1804
|
+
resolveFn();
|
|
1805
|
+
};
|
|
1806
|
+
const req = http.request({
|
|
1807
|
+
socketPath: input.socketPath,
|
|
1808
|
+
method: 'POST',
|
|
1809
|
+
path: POLICY_LOAD_PATH,
|
|
1810
|
+
headers: {
|
|
1811
|
+
'Content-Type': 'application/json',
|
|
1812
|
+
'Content-Length': Buffer.byteLength(body, 'utf-8'),
|
|
1813
|
+
},
|
|
1814
|
+
timeout: timeoutMs,
|
|
1815
|
+
}, (res) => {
|
|
1816
|
+
const status = res.statusCode ?? 0;
|
|
1817
|
+
if (status >= 200 && status < 300) {
|
|
1818
|
+
res.resume();
|
|
1819
|
+
res.on('end', () => settle());
|
|
1820
|
+
res.on('error', settle);
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const chunks = [];
|
|
1824
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
1825
|
+
res.on('end', () => {
|
|
1826
|
+
const text = Buffer.concat(chunks).toString('utf-8');
|
|
1827
|
+
settle(new Error(`loadPolicy RPC failed (status ${status}): ${text.slice(0, 200)}`));
|
|
1828
|
+
});
|
|
1829
|
+
res.on('error', settle);
|
|
1830
|
+
});
|
|
1831
|
+
req.on('timeout', () => {
|
|
1832
|
+
req.destroy(new Error(`loadPolicy RPC timed out after ${timeoutMs}ms`));
|
|
1833
|
+
});
|
|
1834
|
+
req.on('error', settle);
|
|
1835
|
+
req.end(body);
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
// ---------------------------------------------------------------------------
|
|
1839
|
+
// Workspace root hashing (file listing + mtime, no content reads)
|
|
1840
|
+
// ---------------------------------------------------------------------------
|
|
1841
|
+
const WORKSPACE_HASH_EXCLUDED_DIRS = new Set([
|
|
1842
|
+
'.git',
|
|
1843
|
+
WORKFLOW_ARTIFACT_DIR,
|
|
1844
|
+
'node_modules',
|
|
1845
|
+
'__pycache__',
|
|
1846
|
+
'.next',
|
|
1847
|
+
'dist',
|
|
1848
|
+
'build',
|
|
1849
|
+
'.cache',
|
|
1850
|
+
'.venv',
|
|
1851
|
+
'venv',
|
|
1852
|
+
]);
|
|
1853
|
+
/**
|
|
1854
|
+
* Recursively collects `relativePath:mtime` entries for all files
|
|
1855
|
+
* under `basePath`, excluding directories in WORKSPACE_HASH_EXCLUDED_DIRS.
|
|
1856
|
+
*/
|
|
1857
|
+
function collectFileEntries(basePath, relativeTo, out) {
|
|
1858
|
+
const dirents = readdirSync(resolve(basePath, relativeTo), { withFileTypes: true });
|
|
1859
|
+
for (const dirent of dirents) {
|
|
1860
|
+
const relPath = relativeTo ? `${relativeTo}/${dirent.name}` : dirent.name;
|
|
1861
|
+
if (dirent.isDirectory()) {
|
|
1862
|
+
if (WORKSPACE_HASH_EXCLUDED_DIRS.has(dirent.name))
|
|
1863
|
+
continue;
|
|
1864
|
+
collectFileEntries(basePath, relPath, out);
|
|
1865
|
+
}
|
|
1866
|
+
else if (dirent.isFile()) {
|
|
1867
|
+
const fullPath = resolve(basePath, relPath);
|
|
1868
|
+
const mtime = statSync(fullPath).mtimeMs;
|
|
1869
|
+
out.push(`${relPath}:${mtime}`);
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
/** Hashes the workspace root file listing (paths + mtimes) into the given hash. */
|
|
1874
|
+
function hashWorkspaceRoot(hash, workspacePath) {
|
|
1875
|
+
const entries = [];
|
|
1876
|
+
collectFileEntries(workspacePath, '', entries);
|
|
1877
|
+
entries.sort();
|
|
1878
|
+
for (const entry of entries) {
|
|
1879
|
+
hash.update(entry);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
/**
|
|
1883
|
+
* Computes a SHA-256 hash of output artifacts or workspace root file metadata.
|
|
1884
|
+
*
|
|
1885
|
+
* Hashes file metadata (path + size + mtime) — never contents. This bounds cost
|
|
1886
|
+
* regardless of artifact size (fuzz logs, captured stderr, etc. can be many GiB
|
|
1887
|
+
* and would otherwise trip Node's 2 GiB `readFile` ceiling). Any agent rewrite
|
|
1888
|
+
* via fs ops bumps mtime, and same-mtime-same-size collisions in a single ms
|
|
1889
|
+
* window are not a concern for workflow change detection.
|
|
1890
|
+
*/
|
|
1891
|
+
export function computeOutputHash(outputNames, artifactDir, workspacePath) {
|
|
1892
|
+
const hash = createHash('sha256');
|
|
1893
|
+
if (outputNames.length > 0) {
|
|
1894
|
+
for (const output of outputNames) {
|
|
1895
|
+
const dir = resolve(artifactDir, output);
|
|
1896
|
+
const files = collectFilesRecursive(dir);
|
|
1897
|
+
for (const file of files) {
|
|
1898
|
+
const { size, mtimeMs } = statSync(file.fullPath);
|
|
1899
|
+
hash.update(`${file.relativePath}:${size}:${mtimeMs}`);
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
else {
|
|
1904
|
+
hashWorkspaceRoot(hash, workspacePath);
|
|
1905
|
+
}
|
|
1906
|
+
return hash.digest('hex');
|
|
1907
|
+
}
|
|
1908
|
+
// ---------------------------------------------------------------------------
|
|
1909
|
+
// .gitignore management
|
|
1910
|
+
// ---------------------------------------------------------------------------
|
|
1911
|
+
/** Ensures the workflow artifact directory is listed in the workspace root's .gitignore. */
|
|
1912
|
+
function ensureWorkflowGitignored(workspacePath) {
|
|
1913
|
+
const gitignorePath = resolve(workspacePath, '.gitignore');
|
|
1914
|
+
const dirEntry = `${WORKFLOW_ARTIFACT_DIR}/`;
|
|
1915
|
+
if (existsSync(gitignorePath)) {
|
|
1916
|
+
const content = readFileSync(gitignorePath, 'utf-8');
|
|
1917
|
+
const lines = content.split('\n');
|
|
1918
|
+
const alreadyListed = lines.some((line) => line.trim() === dirEntry || line.trim() === WORKFLOW_ARTIFACT_DIR);
|
|
1919
|
+
if (!alreadyListed) {
|
|
1920
|
+
const suffix = content.endsWith('\n') ? '' : '\n';
|
|
1921
|
+
appendFileSync(gitignorePath, `${suffix}${dirEntry}\n`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
else {
|
|
1925
|
+
writeFileSync(gitignorePath, `${dirEntry}\n`);
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
/**
|
|
1929
|
+
* Collects artifact paths: maps output name -> directory path.
|
|
1930
|
+
*/
|
|
1931
|
+
export function collectArtifactPaths(outputNames, artifactDir) {
|
|
1932
|
+
const result = {};
|
|
1933
|
+
for (const output of outputNames) {
|
|
1934
|
+
const dir = resolve(artifactDir, output);
|
|
1935
|
+
if (existsSync(dir)) {
|
|
1936
|
+
result[output] = dir;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
return result;
|
|
1940
|
+
}
|
|
1941
|
+
//# sourceMappingURL=orchestrator.js.map
|