@gajae-code/coding-agent 0.10.2 → 0.11.1
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/CHANGELOG.md +58 -2
- package/dist/types/append-only-mode.d.ts +1 -1
- package/dist/types/async/job-manager.d.ts +46 -7
- package/dist/types/cli/args.d.ts +4 -5
- package/dist/types/cli/daemon-cli.d.ts +12 -1
- package/dist/types/cli/notify-cli.d.ts +21 -1
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +39 -0
- package/dist/types/commands/launch.d.ts +3 -0
- package/dist/types/commands/notify.d.ts +28 -1
- package/dist/types/commands/sdk.d.ts +33 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +85 -0
- package/dist/types/config/model-profile-activation.d.ts +35 -15
- package/dist/types/config/model-profiles.d.ts +11 -5
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +39 -6
- package/dist/types/config/model-selector-value.d.ts +17 -0
- package/dist/types/config/models-config-schema.d.ts +10 -6
- package/dist/types/config/settings-schema.d.ts +133 -6
- package/dist/types/config/settings.d.ts +63 -35
- package/dist/types/coordinator-mcp/model-preset.d.ts +7 -6
- package/dist/types/coordinator-mcp/server.d.ts +6 -49
- package/dist/types/daemon/builtin.d.ts +6 -5
- package/dist/types/daemon/control-types.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +12 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
- package/dist/types/extensibility/extensions/compact-handler.d.ts +4 -2
- package/dist/types/extensibility/extensions/get-commands-handler.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +10 -1
- package/dist/types/extensibility/extensions/runner.d.ts +3 -3
- package/dist/types/extensibility/extensions/types.d.ts +141 -16
- package/dist/types/extensibility/hooks/types.d.ts +1 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -2
- package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +3 -4
- package/dist/types/gjc-runtime/tmux-owner-isolation-cli.d.ts +13 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +4 -1
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
- package/dist/types/gjc-runtime/windows-powershell-command.d.ts +9 -0
- package/dist/types/harness-control-plane/operate.d.ts +5 -5
- package/dist/types/harness-control-plane/owner.d.ts +18 -4
- package/dist/types/harness-control-plane/receipts.d.ts +0 -1
- package/dist/types/harness-control-plane/sdk-transport.d.ts +43 -0
- package/dist/types/harness-control-plane/session-transport.d.ts +48 -0
- package/dist/types/harness-control-plane/types.d.ts +3 -4
- package/dist/types/lsp/index.d.ts +1 -1
- package/dist/types/main.d.ts +32 -38
- package/dist/types/modes/acp/acp-agent.d.ts +224 -53
- package/dist/types/modes/acp/acp-client-bridge.d.ts +1 -0
- package/dist/types/modes/acp/acp-mode.d.ts +6 -15
- package/dist/types/modes/acp/permission-mode.d.ts +4 -0
- package/dist/types/modes/acp/startup-options.d.ts +6 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +3 -0
- package/dist/types/modes/components/model-selector.d.ts +1 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +135 -0
- package/dist/types/modes/components/settings-selector.d.ts +6 -4
- package/dist/types/modes/components/welcome.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +35 -1
- package/dist/types/modes/index.d.ts +0 -4
- package/dist/types/modes/interactive-mode.d.ts +5 -2
- package/dist/types/modes/runtime-init.d.ts +2 -2
- package/dist/types/modes/shared/agent-wire/approval-gate.d.ts +6 -9
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -6
- package/dist/types/modes/shared/agent-wire/event-contract.d.ts +6 -6
- package/dist/types/modes/shared/agent-wire/event-envelope.d.ts +4 -9
- package/dist/types/modes/shared/agent-wire/event-observation.d.ts +1 -1
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +170 -30
- package/dist/types/modes/shared/agent-wire/workflow-gate-schema.d.ts +7 -7
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +105 -0
- package/dist/types/modes/types.d.ts +7 -2
- package/dist/types/runtime-mcp/client.d.ts +2 -0
- package/dist/types/runtime-mcp/config.d.ts +5 -1
- package/dist/types/runtime-mcp/manager.d.ts +8 -1
- package/dist/types/runtime-mcp/types.d.ts +4 -0
- package/dist/types/sdk/acp/adapter.d.ts +67 -0
- package/dist/types/sdk/acp/index.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +53 -0
- package/dist/types/sdk/broker/discovery.d.ts +30 -0
- package/dist/types/sdk/broker/ensure.d.ts +42 -0
- package/dist/types/sdk/broker/identity.d.ts +3 -0
- package/dist/types/sdk/broker/index.d.ts +8 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +95 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +85 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +14 -0
- package/dist/types/sdk/broker/runtime.d.ts +33 -0
- package/dist/types/sdk/broker/session-index.d.ts +57 -0
- package/dist/types/sdk/broker/state-version.d.ts +8 -0
- package/dist/types/sdk/broker/transport.d.ts +9 -0
- package/dist/types/sdk/bus/chat-command-policy.d.ts +49 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +21 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +111 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +77 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +84 -0
- package/dist/types/{notifications → sdk/bus}/config-commands.d.ts +7 -2
- package/dist/types/sdk/bus/config.d.ts +274 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +72 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +58 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +50 -0
- package/dist/types/sdk/bus/discord-live-provider.d.ts +70 -0
- package/dist/types/sdk/bus/discord-provider.d.ts +85 -0
- package/dist/types/{notifications → sdk/bus}/html-format.d.ts +2 -2
- package/dist/types/{notifications → sdk/bus}/index.d.ts +113 -13
- package/dist/types/{notifications → sdk/bus}/lifecycle-control-runtime.d.ts +4 -2
- package/dist/types/{notifications → sdk/bus}/lifecycle-orchestrator.d.ts +18 -21
- package/dist/types/{notifications → sdk/bus}/managed-daemon.d.ts +1 -1
- package/dist/types/sdk/bus/native-runtime-compatibility.d.ts +13 -0
- package/dist/types/sdk/bus/notification-orchestration.d.ts +170 -0
- package/dist/types/{notifications → sdk/bus}/notification-service.d.ts +7 -1
- package/dist/types/sdk/bus/session-control.d.ts +83 -0
- package/dist/types/sdk/bus/slack-conversation.d.ts +56 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +60 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +59 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +55 -0
- package/dist/types/{notifications → sdk/bus}/telegram-cli.d.ts +4 -4
- package/dist/types/{notifications → sdk/bus}/telegram-daemon-cli.d.ts +14 -5
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -0
- package/dist/types/{notifications → sdk/bus}/telegram-daemon-control.d.ts +2 -2
- package/dist/types/{notifications → sdk/bus}/telegram-daemon.d.ts +78 -17
- package/dist/types/{notifications → sdk/bus}/telegram-reference.d.ts +2 -2
- package/dist/types/sdk/bus/telegram-setup.d.ts +128 -0
- package/dist/types/{notifications → sdk/bus}/topic-registry.d.ts +11 -0
- package/dist/types/sdk/cli/index.d.ts +1 -0
- package/dist/types/sdk/cli/session-cli.d.ts +19 -0
- package/dist/types/sdk/client/client.d.ts +1 -0
- package/dist/types/sdk/client/discovery.d.ts +27 -0
- package/dist/types/sdk/client/index.d.ts +2 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +34 -0
- package/dist/types/sdk/host/control/index.d.ts +2 -0
- package/dist/types/sdk/host/control/operations.d.ts +65 -0
- package/dist/types/sdk/host/events.d.ts +35 -0
- package/dist/types/sdk/host/host.d.ts +35 -0
- package/dist/types/sdk/host/index.d.ts +4 -0
- package/dist/types/sdk/host/query/cursor.d.ts +62 -0
- package/dist/types/sdk/host/query/handlers.d.ts +70 -0
- package/dist/types/sdk/host/query/index.d.ts +3 -0
- package/dist/types/sdk/host/query/revision-store.d.ts +60 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +45 -0
- package/dist/types/sdk/host/types.d.ts +32 -0
- package/dist/types/sdk/index.d.ts +6 -0
- package/dist/types/sdk/lifecycle-session.d.ts +14 -0
- package/dist/types/sdk/mcp/index.d.ts +1 -0
- package/dist/types/sdk/mcp/server.d.ts +37 -0
- package/dist/types/sdk/models.d.ts +44 -0
- package/dist/types/sdk/protocol/adapter-validation.d.ts +14 -0
- package/dist/types/sdk/protocol/operation-registry.d.ts +24 -0
- package/dist/types/{sdk.d.ts → sdk/session.d.ts} +44 -38
- package/dist/types/sdk/startup-capability.d.ts +50 -0
- package/dist/types/session/agent-session.d.ts +114 -21
- package/dist/types/session/default-model-selection.d.ts +25 -0
- package/dist/types/session/fallback-chain-controller.d.ts +52 -0
- package/dist/types/session/session-manager.d.ts +58 -1
- package/dist/types/setup/credential-auto-import.d.ts +36 -6
- package/dist/types/tools/ask-answer-registry.d.ts +1 -1
- package/dist/types/tools/browser/benchmark/detector-report.d.ts +63 -0
- package/dist/types/tools/browser/benchmark/run-suite.d.ts +18 -0
- package/dist/types/tools/browser/launch.d.ts +22 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +31 -0
- package/dist/types/tools/browser/profile-posture.d.ts +37 -0
- package/dist/types/tools/browser/profile-reuse.d.ts +39 -0
- package/dist/types/tools/browser/profile-warmup.d.ts +39 -0
- package/dist/types/tools/browser/registry.d.ts +19 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/index.d.ts +8 -8
- package/dist/types/tools/subagent-render.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +6 -1
- package/package.json +34 -13
- package/scripts/build-sdk-package-smoke.ts +99 -0
- package/scripts/capture-notifications-settings-showcase.ts +435 -0
- package/scripts/compile-args.ts +4 -2
- package/scripts/g005-daemon-smoke.ts +4 -5
- package/scripts/g011-daemon-path-smoke.ts +5 -5
- package/scripts/generate-sdk-adapter-parity-manifest.ts +163 -0
- package/scripts/generate-sdk-operation-inventory.ts +867 -0
- package/scripts/generate-telegram-baseline-manifest.ts +128 -0
- package/scripts/manifest-schema.ts +124 -0
- package/scripts/run-test-manifest.ts +153 -0
- package/scripts/run-threat-model-check.ts +157 -0
- package/scripts/verify-gjc-sdk-canonicalization.ts +3467 -0
- package/scripts/verify-gjc-sdk-rename.ts +90 -0
- package/src/append-only-mode.ts +1 -1
- package/src/async/job-manager.ts +406 -63
- package/src/cli/args.ts +43 -12
- package/src/cli/auth-gateway-cli.ts +2 -2
- package/src/cli/config-cli.ts +2 -1
- package/src/cli/daemon-cli.ts +63 -14
- package/src/cli/list-models.ts +1 -1
- package/src/cli/notify-cli.ts +288 -286
- package/src/cli.ts +21 -2
- package/src/commands/daemon.ts +50 -6
- package/src/commands/harness.ts +93 -36
- package/src/commands/launch.ts +5 -2
- package/src/commands/mcp-serve.ts +19 -12
- package/src/commands/notify.ts +36 -3
- package/src/commands/sdk.ts +426 -0
- package/src/commit/agentic/agent.ts +1 -0
- package/src/config/atomic-yaml-patch.ts +423 -0
- package/src/config/model-profile-activation.ts +240 -89
- package/src/config/model-profiles.ts +18 -15
- package/src/config/model-registry.ts +51 -26
- package/src/config/model-resolver.ts +138 -51
- package/src/config/model-selector-value.ts +50 -0
- package/src/config/models-config-schema.ts +14 -21
- package/src/config/settings-schema.ts +128 -10
- package/src/config/settings.ts +1120 -158
- package/src/coordinator-mcp/model-preset.ts +74 -20
- package/src/coordinator-mcp/server.ts +1597 -3052
- package/src/daemon/builtin.ts +8 -5
- package/src/daemon/control-types.ts +1 -1
- package/src/defaults/gjc/skills/team/SKILL.md +4 -4
- package/src/discovery/mcp-json.ts +327 -20
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/compact-handler.ts +3 -3
- package/src/extensibility/extensions/get-commands-handler.ts +2 -2
- package/src/extensibility/extensions/loader.ts +69 -5
- package/src/extensibility/extensions/runner.ts +112 -4
- package/src/extensibility/extensions/types.ts +176 -17
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/gjc-runtime/launch-tmux.ts +170 -86
- package/src/gjc-runtime/psmux-detect.ts +82 -4
- package/src/gjc-runtime/session-state-sidecar.ts +41 -31
- package/src/gjc-runtime/team-runtime.ts +54 -35
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +53 -17
- package/src/gjc-runtime/tmux-owner-isolation.ts +1 -1
- package/src/gjc-runtime/tmux-sessions.ts +19 -13
- package/src/gjc-runtime/ultragoal-guard.ts +108 -14
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +227 -11
- package/src/gjc-runtime/windows-powershell-command.ts +58 -0
- package/src/harness-control-plane/operate.ts +9 -9
- package/src/harness-control-plane/owner.ts +283 -39
- package/src/harness-control-plane/receipts.ts +0 -1
- package/src/harness-control-plane/sdk-transport.ts +395 -0
- package/src/harness-control-plane/session-transport.ts +94 -0
- package/src/harness-control-plane/types.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +34 -35
- package/src/lsp/client.ts +42 -6
- package/src/lsp/index.ts +2 -2
- package/src/main.ts +299 -204
- package/src/modes/DESIGN.md +258 -0
- package/src/modes/acp/acp-agent.ts +1228 -2612
- package/src/modes/acp/acp-client-bridge.ts +2 -20
- package/src/modes/acp/acp-event-mapper.ts +18 -2
- package/src/modes/acp/acp-mode.ts +8 -36
- package/src/modes/acp/permission-mode.ts +25 -0
- package/src/modes/acp/startup-options.ts +6 -0
- package/src/modes/components/agent-dashboard.ts +37 -17
- package/src/modes/components/model-selector.ts +93 -35
- package/src/modes/components/notifications-settings-editor.ts +1379 -0
- package/src/modes/components/settings-selector.ts +60 -11
- package/src/modes/components/welcome.ts +4 -4
- package/src/modes/controllers/command-controller.ts +10 -17
- package/src/modes/controllers/event-controller.ts +11 -16
- package/src/modes/controllers/extension-ui-controller.ts +396 -28
- package/src/modes/controllers/selector-controller.ts +674 -36
- package/src/modes/index.ts +0 -22
- package/src/modes/interactive-mode.ts +87 -36
- package/src/modes/runtime-init.ts +298 -6
- package/src/modes/shared/agent-wire/approval-gate.ts +9 -11
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +10 -11
- package/src/modes/shared/agent-wire/event-contract.ts +7 -8
- package/src/modes/shared/agent-wire/event-envelope.ts +9 -17
- package/src/modes/shared/agent-wire/event-observation.ts +16 -7
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +1258 -87
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +15 -15
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +113 -0
- package/src/modes/types.ts +7 -2
- package/src/runtime-mcp/client.ts +94 -19
- package/src/runtime-mcp/config.ts +27 -15
- package/src/runtime-mcp/manager.ts +343 -105
- package/src/runtime-mcp/oauth-flow.ts +42 -12
- package/src/runtime-mcp/transports/http.ts +49 -19
- package/src/runtime-mcp/transports/stdio.ts +47 -28
- package/src/runtime-mcp/types.ts +10 -0
- package/src/sdk/acp/adapter.ts +418 -0
- package/src/sdk/acp/index.ts +2 -0
- package/src/sdk/broker/broker.ts +642 -0
- package/src/sdk/broker/discovery.ts +113 -0
- package/src/sdk/broker/ensure.ts +307 -0
- package/src/sdk/broker/identity.ts +42 -0
- package/src/sdk/broker/index.ts +8 -0
- package/src/sdk/broker/internal-source-marker-2178.txt +1 -0
- package/src/sdk/broker/internal-source.bunfig.toml +1 -0
- package/src/sdk/broker/lifecycle-ledger.ts +286 -0
- package/src/sdk/broker/lifecycle.ts +2157 -0
- package/src/sdk/broker/process-incarnation.ts +159 -0
- package/src/sdk/broker/runtime.ts +164 -0
- package/src/sdk/broker/session-index.ts +318 -0
- package/src/sdk/broker/state-version.ts +23 -0
- package/src/sdk/broker/transport.ts +125 -0
- package/src/sdk/bus/chat-command-policy.ts +107 -0
- package/src/sdk/bus/chat-daemon-cli.ts +214 -0
- package/src/sdk/bus/chat-daemon-control.ts +713 -0
- package/src/sdk/bus/chat-daemon-runtime.ts +526 -0
- package/src/sdk/bus/chat-effect-journal.ts +355 -0
- package/src/{notifications → sdk/bus}/config-commands.ts +39 -10
- package/src/sdk/bus/config.ts +433 -0
- package/src/sdk/bus/conversation-store.ts +391 -0
- package/src/sdk/bus/discord-conversation.ts +85 -0
- package/src/sdk/bus/discord-daemon.ts +2292 -0
- package/src/sdk/bus/discord-live-provider.ts +521 -0
- package/src/sdk/bus/discord-provider.ts +56 -0
- package/src/{notifications → sdk/bus}/html-format.ts +2 -2
- package/src/sdk/bus/index.ts +3979 -0
- package/src/{notifications → sdk/bus}/lifecycle-commands.ts +2 -0
- package/src/{notifications → sdk/bus}/lifecycle-control-runtime.ts +225 -196
- package/src/{notifications → sdk/bus}/lifecycle-orchestrator.ts +161 -74
- package/src/{notifications → sdk/bus}/managed-daemon.ts +2 -2
- package/src/sdk/bus/native-runtime-compatibility.ts +42 -0
- package/src/sdk/bus/natives-augment.d.ts +38 -0
- package/src/sdk/bus/notification-orchestration.ts +535 -0
- package/src/{notifications → sdk/bus}/notification-service.ts +106 -12
- package/src/sdk/bus/session-control.ts +333 -0
- package/src/sdk/bus/slack-conversation.ts +74 -0
- package/src/sdk/bus/slack-daemon.ts +2144 -0
- package/src/sdk/bus/slack-live-provider.ts +380 -0
- package/src/sdk/bus/slack-provider.ts +76 -0
- package/src/{notifications → sdk/bus}/telegram-cli.ts +8 -8
- package/src/sdk/bus/telegram-daemon-cli.ts +330 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +14 -0
- package/src/{notifications → sdk/bus}/telegram-daemon-control.ts +4 -4
- package/src/{notifications → sdk/bus}/telegram-daemon.ts +603 -63
- package/src/{notifications → sdk/bus}/telegram-reference.ts +2 -2
- package/src/sdk/bus/telegram-setup.ts +663 -0
- package/src/{notifications → sdk/bus}/topic-registry.ts +32 -0
- package/src/sdk/cli/index.ts +1 -0
- package/src/sdk/cli/session-cli.ts +275 -0
- package/src/sdk/client/client.ts +1 -0
- package/src/sdk/client/discovery.ts +108 -0
- package/src/sdk/client/index.ts +2 -0
- package/src/sdk/host/control/dispatch.ts +367 -0
- package/src/sdk/host/control/index.ts +10 -0
- package/src/sdk/host/control/operations.ts +63 -0
- package/src/sdk/host/events.ts +91 -0
- package/src/sdk/host/host.ts +296 -0
- package/src/sdk/host/index.ts +4 -0
- package/src/sdk/host/query/cursor.ts +189 -0
- package/src/sdk/host/query/handlers.ts +533 -0
- package/src/sdk/host/query/index.ts +3 -0
- package/src/sdk/host/query/revision-store.ts +1126 -0
- package/src/sdk/host/reverse-leases.ts +325 -0
- package/src/sdk/host/types.ts +29 -0
- package/src/sdk/index.ts +13 -0
- package/src/sdk/lifecycle-session.ts +39 -0
- package/src/sdk/mcp/index.ts +1 -0
- package/src/sdk/mcp/server.ts +332 -0
- package/src/sdk/models.ts +168 -0
- package/src/sdk/protocol/adapter-validation.ts +48 -0
- package/src/sdk/protocol/operation-inventory.generated.json +5654 -0
- package/src/sdk/protocol/operation-registry.ts +312 -0
- package/src/{sdk.ts → sdk/session.ts} +339 -212
- package/src/sdk/startup-capability.ts +189 -0
- package/src/session/agent-session.ts +2650 -944
- package/src/session/default-model-selection.ts +93 -0
- package/src/session/fallback-chain-controller.ts +136 -0
- package/src/session/session-manager.ts +438 -11
- package/src/session/session-storage.ts +8 -1
- package/src/setup/credential-auto-import.ts +290 -35
- package/src/setup/hermes/templates/operator-instructions.v1.md +3 -5
- package/src/slash-commands/builtin-registry.ts +2 -1
- package/src/task/executor.ts +62 -13
- package/src/task/index.ts +26 -16
- package/src/tools/ask-answer-registry.ts +1 -1
- package/src/tools/ask.ts +13 -8
- package/src/tools/browser/STEALTH-THIRD-PARTY-NOTICES.md +26 -0
- package/src/tools/browser/benchmark/detector-report.ts +157 -0
- package/src/tools/browser/benchmark/run-suite.ts +39 -0
- package/src/tools/browser/launch.ts +124 -19
- package/src/tools/browser/profile-discovery.ts +63 -0
- package/src/tools/browser/profile-posture.ts +59 -0
- package/src/tools/browser/profile-reuse.ts +102 -0
- package/src/tools/browser/profile-warmup.ts +109 -0
- package/src/tools/browser/registry.ts +87 -11
- package/src/tools/browser/tab-protocol.ts +1 -0
- package/src/tools/browser/tab-supervisor.ts +1 -0
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/browser.ts +6 -0
- package/src/tools/index.ts +8 -8
- package/src/tools/puppeteer/14_stealth_permissions.txt +57 -0
- package/src/tools/puppeteer/15_stealth_webrtc.txt +60 -0
- package/src/tools/subagent-render.ts +19 -8
- package/src/tools/subagent.ts +84 -32
- package/src/tools/telegram-send.ts +2 -2
- package/vendor/insane-search/MANIFEST.json +2 -1
- package/vendor/insane-search/engine/bias_check.py +4 -1
- package/vendor/insane-search/engine/phase0.py +26 -1
- package/dist/types/harness-control-plane/rpc-adapter.d.ts +0 -69
- package/dist/types/modes/bridge/auth.d.ts +0 -12
- package/dist/types/modes/bridge/bridge-client-bridge.d.ts +0 -9
- package/dist/types/modes/bridge/bridge-mode.d.ts +0 -47
- package/dist/types/modes/bridge/bridge-ui-context.d.ts +0 -88
- package/dist/types/modes/bridge/event-stream.d.ts +0 -8
- package/dist/types/modes/rpc/host-tools.d.ts +0 -1
- package/dist/types/modes/rpc/host-uris.d.ts +0 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +0 -299
- package/dist/types/modes/rpc/rpc-mode.d.ts +0 -86
- package/dist/types/modes/rpc/rpc-socket-security.d.ts +0 -10
- package/dist/types/modes/rpc/rpc-types.d.ts +0 -810
- package/dist/types/modes/shared/agent-wire/command-contract.d.ts +0 -18
- package/dist/types/modes/shared/agent-wire/command-dispatch.d.ts +0 -37
- package/dist/types/modes/shared/agent-wire/command-validation.d.ts +0 -2
- package/dist/types/modes/shared/agent-wire/handshake.d.ts +0 -56
- package/dist/types/modes/shared/agent-wire/host-tool-bridge.d.ts +0 -16
- package/dist/types/modes/shared/agent-wire/host-uri-bridge.d.ts +0 -17
- package/dist/types/modes/shared/agent-wire/protocol.d.ts +0 -25
- package/dist/types/modes/shared/agent-wire/responses.d.ts +0 -4
- package/dist/types/modes/shared/agent-wire/scopes.d.ts +0 -18
- package/dist/types/modes/shared/agent-wire/session-registry.d.ts +0 -25
- package/dist/types/modes/shared/agent-wire/ui-request-broker.d.ts +0 -42
- package/dist/types/modes/shared/agent-wire/ui-result.d.ts +0 -27
- package/dist/types/modes/shared/agent-wire/unattended-action-policy.d.ts +0 -29
- package/dist/types/modes/shared/agent-wire/unattended-audit.d.ts +0 -68
- package/dist/types/modes/shared/agent-wire/unattended-run-controller.d.ts +0 -166
- package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +0 -127
- package/dist/types/notifications/config.d.ts +0 -132
- package/src/harness-control-plane/rpc-adapter.ts +0 -290
- package/src/modes/bridge/auth.ts +0 -41
- package/src/modes/bridge/bridge-client-bridge.ts +0 -47
- package/src/modes/bridge/bridge-mode.ts +0 -675
- package/src/modes/bridge/bridge-ui-context.ts +0 -200
- package/src/modes/bridge/event-stream.ts +0 -70
- package/src/modes/rpc/host-tools.ts +0 -1
- package/src/modes/rpc/host-uris.ts +0 -1
- package/src/modes/rpc/rpc-client.ts +0 -1166
- package/src/modes/rpc/rpc-mode.ts +0 -1029
- package/src/modes/rpc/rpc-socket-security.ts +0 -115
- package/src/modes/rpc/rpc-types.ts +0 -637
- package/src/modes/shared/agent-wire/command-contract.ts +0 -18
- package/src/modes/shared/agent-wire/command-dispatch.ts +0 -498
- package/src/modes/shared/agent-wire/command-validation.ts +0 -181
- package/src/modes/shared/agent-wire/handshake.ts +0 -157
- package/src/modes/shared/agent-wire/host-tool-bridge.ts +0 -197
- package/src/modes/shared/agent-wire/host-uri-bridge.ts +0 -236
- package/src/modes/shared/agent-wire/protocol.ts +0 -46
- package/src/modes/shared/agent-wire/responses.ts +0 -17
- package/src/modes/shared/agent-wire/scopes.ts +0 -94
- package/src/modes/shared/agent-wire/session-registry.ts +0 -109
- package/src/modes/shared/agent-wire/ui-request-broker.ts +0 -150
- package/src/modes/shared/agent-wire/ui-result.ts +0 -48
- package/src/modes/shared/agent-wire/unattended-action-policy.ts +0 -365
- package/src/modes/shared/agent-wire/unattended-audit.ts +0 -176
- package/src/modes/shared/agent-wire/unattended-run-controller.ts +0 -426
- package/src/modes/shared/agent-wire/unattended-session.ts +0 -528
- package/src/notifications/config.ts +0 -219
- package/src/notifications/index.ts +0 -1835
- package/src/notifications/telegram-daemon-cli.ts +0 -243
- package/src/tools/puppeteer/09_stealth_locale.txt +0 -46
- /package/dist/types/{notifications → sdk/bus}/attachment-registry.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/chat-adapters.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/daemon-paths.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/engine.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/helpers.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/lifecycle-commands.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/operator-runtime.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rate-limit-pool.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/recent-activity.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/reply-sent-store.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rich-draft.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/rich-render.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/threaded-inbound.d.ts +0 -0
- /package/dist/types/{notifications → sdk/bus}/threaded-render.d.ts +0 -0
- /package/src/{notifications → sdk/bus}/attachment-registry.ts +0 -0
- /package/src/{notifications → sdk/bus}/chat-adapters.ts +0 -0
- /package/src/{notifications → sdk/bus}/daemon-paths.ts +0 -0
- /package/src/{notifications → sdk/bus}/engine.ts +0 -0
- /package/src/{notifications → sdk/bus}/helpers.ts +0 -0
- /package/src/{notifications → sdk/bus}/operator-runtime.ts +0 -0
- /package/src/{notifications → sdk/bus}/rate-limit-pool.ts +0 -0
- /package/src/{notifications → sdk/bus}/recent-activity.ts +0 -0
- /package/src/{notifications → sdk/bus}/reply-sent-store.ts +0 -0
- /package/src/{notifications → sdk/bus}/rich-draft.ts +0 -0
- /package/src/{notifications → sdk/bus}/rich-render.ts +0 -0
- /package/src/{notifications → sdk/bus}/threaded-inbound.ts +0 -0
- /package/src/{notifications → sdk/bus}/threaded-render.ts +0 -0
|
@@ -14,25 +14,27 @@
|
|
|
14
14
|
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
15
|
import * as path from "node:path";
|
|
16
16
|
import type { AskSelectedAckOutcome } from "../../../tools";
|
|
17
|
-
import type {
|
|
18
|
-
RpcWorkflowGate,
|
|
19
|
-
RpcWorkflowGateContext,
|
|
20
|
-
RpcWorkflowGateKind,
|
|
21
|
-
RpcWorkflowGateOption,
|
|
22
|
-
RpcWorkflowGateResolution,
|
|
23
|
-
RpcWorkflowGateResponse,
|
|
24
|
-
RpcWorkflowGateValidationError,
|
|
25
|
-
RpcWorkflowStage,
|
|
26
|
-
} from "../../rpc/rpc-types";
|
|
27
|
-
import { RESERVED_WORKFLOW_STAGES } from "../../rpc/rpc-types";
|
|
28
17
|
import { classifyAskGateDisposition } from "./deep-interview-gate";
|
|
29
18
|
import { answerHashOf, canonicalJson, compileGateSchema, schemaHash, validateGateAnswer } from "./workflow-gate-schema";
|
|
19
|
+
import type {
|
|
20
|
+
WorkflowGate,
|
|
21
|
+
WorkflowGateContext,
|
|
22
|
+
WorkflowGateDiagnostic,
|
|
23
|
+
WorkflowGateKind,
|
|
24
|
+
WorkflowGateOption,
|
|
25
|
+
WorkflowGateQueryRecord,
|
|
26
|
+
WorkflowGateResolution,
|
|
27
|
+
WorkflowGateResponse,
|
|
28
|
+
WorkflowGateValidationError,
|
|
29
|
+
WorkflowStage,
|
|
30
|
+
} from "./workflow-gate-types";
|
|
31
|
+
import { RESERVED_WORKFLOW_STAGES } from "./workflow-gate-types";
|
|
30
32
|
|
|
31
33
|
export type PersistedSemanticDisposition = "commit" | "resolve_without_commit";
|
|
32
34
|
export type PersistedResolutionOrigin =
|
|
33
|
-
| { kind: "generic"; channel: "
|
|
34
|
-
| { kind: "telegram_notification"; interactionActionId: string }
|
|
35
|
-
|
|
35
|
+
| { kind: "generic"; channel: "sdk" | "other" }
|
|
36
|
+
| { kind: "telegram_notification"; interactionActionId: string };
|
|
37
|
+
|
|
36
38
|
export type PersistedAckPolicy =
|
|
37
39
|
| { kind: "none"; reason: "non_telegram" | "semantic_noncommit" | "legacy_unproven" }
|
|
38
40
|
| {
|
|
@@ -51,35 +53,457 @@ export interface GateResolutionOptions {
|
|
|
51
53
|
beforeAdvance?: () => Promise<void>;
|
|
52
54
|
}
|
|
53
55
|
|
|
54
|
-
|
|
56
|
+
export type WorkflowGateTerminalProof = "retired" | "already_terminal" | "not_published";
|
|
57
|
+
|
|
58
|
+
export interface WorkflowGateTerminalController {
|
|
59
|
+
completeGateInteractions(gateId: string): WorkflowGateTerminalProof | Promise<WorkflowGateTerminalProof>;
|
|
60
|
+
cancelGateInteractions(gateId: string, reason: string): void | Promise<void>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface NotificationGateResolutionOptions {
|
|
64
|
+
interactionActionId: string;
|
|
65
|
+
replyReceiptId: string;
|
|
66
|
+
answerJson: string;
|
|
67
|
+
idempotencyKey?: string;
|
|
68
|
+
requestSelectedAck(input: {
|
|
69
|
+
replyReceiptId: string;
|
|
70
|
+
actionId: string;
|
|
71
|
+
commitKey: string;
|
|
72
|
+
daemonDeadlineAt: number;
|
|
73
|
+
hostTimeoutMs: number;
|
|
74
|
+
}): Promise<AskSelectedAckOutcome>;
|
|
75
|
+
resolveClaim(): void;
|
|
76
|
+
closeClaimInvalid(reason: string): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface AskSelectedAckRecoveryParticipant {
|
|
80
|
+
requestRecoveredAskSelectedAck(input: {
|
|
81
|
+
sessionId: string;
|
|
82
|
+
actionId: string;
|
|
83
|
+
commitKey: string;
|
|
84
|
+
deadlineAt: number;
|
|
85
|
+
hostTimeoutMs: number;
|
|
86
|
+
}): Promise<AskSelectedAckOutcome>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** SDK-native surface for emitting a workflow gate and awaiting its answer. */
|
|
90
|
+
export interface WorkflowGateEmitter {
|
|
91
|
+
isUnattended(): boolean;
|
|
92
|
+
emitGate(input: OpenGateInput): Promise<unknown>;
|
|
93
|
+
onGateEmitted?(listener: (gate: WorkflowGate) => void): () => void;
|
|
94
|
+
resolveGate?(response: WorkflowGateResponse): Promise<WorkflowGateResolution>;
|
|
95
|
+
resolveGateFromNotification?(
|
|
96
|
+
response: WorkflowGateResponse,
|
|
97
|
+
options: NotificationGateResolutionOptions,
|
|
98
|
+
): Promise<WorkflowGateResolution>;
|
|
99
|
+
registerGateTerminalController?(controller: WorkflowGateTerminalController): () => void;
|
|
100
|
+
/** Records the exact terminalization proof established by direct control preparation. */
|
|
101
|
+
prepareTerminalization?(gateId: string, proof: WorkflowGateTerminalProof): boolean;
|
|
102
|
+
/** Discards a prepared proof when the gate remains pending after a rejected direct response. */
|
|
103
|
+
clearPreparedTerminalization?(gateId: string): void;
|
|
104
|
+
listPendingGates?(): WorkflowGate[];
|
|
105
|
+
listGateDiagnostics?(): WorkflowGateDiagnostic[];
|
|
106
|
+
listWorkflowGateQueryRecords?(): WorkflowGateQueryRecord[];
|
|
107
|
+
quarantineGate?(gateId: string): void;
|
|
108
|
+
setAckRecoveryParticipant?(participant: AskSelectedAckRecoveryParticipant | null): void;
|
|
109
|
+
/** Explicit host hook for same-process accepted-but-unadvanced recovery. */
|
|
110
|
+
recoverAcceptedGates?(): Promise<string[]>;
|
|
111
|
+
/** Permanently revoke all in-process continuation authority and reject their waiters. */
|
|
112
|
+
fence?(): void;
|
|
113
|
+
/** Temporarily make this emitter unavailable while a session transition is reversible. */
|
|
114
|
+
suspend?(): void;
|
|
115
|
+
resume?(): void;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* SDK-native workflow-gate emitter backed by a durable broker/store pair.
|
|
120
|
+
*
|
|
121
|
+
* Answers are resolved by the broker, so validation and idempotency have one
|
|
122
|
+
* authority. Listeners replay durable pending gates when attached, allowing an
|
|
123
|
+
* SDK host that starts after a gate was opened to discover it.
|
|
124
|
+
*/
|
|
125
|
+
export class BrokerWorkflowGateEmitter implements WorkflowGateEmitter {
|
|
126
|
+
readonly #listeners = new Set<(gate: WorkflowGate) => void>();
|
|
127
|
+
readonly #waiters = new Map<string, { resolve(answer: unknown): void; reject(error: Error): void }>();
|
|
128
|
+
readonly #broker: WorkflowGateBroker;
|
|
129
|
+
#terminalController: WorkflowGateTerminalController | undefined;
|
|
130
|
+
#recoveryParticipant: AskSelectedAckRecoveryParticipant | undefined;
|
|
131
|
+
#recoveryPromise: Promise<void> | undefined;
|
|
132
|
+
readonly #participantReady = Promise.withResolvers<void>();
|
|
133
|
+
#recoveryGraceTimer: NodeJS.Timeout | undefined;
|
|
134
|
+
#recoveryGraceWake: (() => void) | undefined;
|
|
135
|
+
#recoveryTimer: NodeJS.Timeout | undefined;
|
|
136
|
+
#recoveryAttempts = 0;
|
|
137
|
+
#fenced = false;
|
|
138
|
+
#suspended = false;
|
|
139
|
+
readonly #runId: string;
|
|
140
|
+
readonly #store: GateStore;
|
|
141
|
+
readonly #emitterHooks: Pick<BrokerHooks, "advance">;
|
|
142
|
+
|
|
143
|
+
constructor(runId: string, store: GateStore, emitterHooks: Pick<BrokerHooks, "advance"> = {}) {
|
|
144
|
+
this.#runId = runId;
|
|
145
|
+
this.#store = store;
|
|
146
|
+
this.#emitterHooks = emitterHooks;
|
|
147
|
+
this.#broker = new WorkflowGateBroker(runId, store, {
|
|
148
|
+
emit: gate => {
|
|
149
|
+
for (const listener of this.#listeners) listener(gate);
|
|
150
|
+
},
|
|
151
|
+
advance: (gate, answer) => this.#emitterHooks.advance?.(gate, answer),
|
|
152
|
+
completeAccepted: record => this.#completeAccepted(record),
|
|
153
|
+
finalizeAccepted: record => this.#finalizeAccepted(record),
|
|
154
|
+
terminalizeAccepted: record => this.#terminalizeAccepted(record),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
isUnattended(): boolean {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
emitGate(input: OpenGateInput): Promise<unknown> {
|
|
163
|
+
if (this.#fenced || this.#suspended) return Promise.reject(new Error("workflow gate emitter is unavailable"));
|
|
164
|
+
const waiter = Promise.withResolvers<unknown>();
|
|
165
|
+
const gate = this.#broker.openGate(input, {
|
|
166
|
+
activate: opened => this.#waiters.set(opened.gate_id, waiter),
|
|
167
|
+
isLive: gateId => this.#waiters.has(gateId),
|
|
168
|
+
release: gateId => {
|
|
169
|
+
const activeWaiter = this.#waiters.get(gateId);
|
|
170
|
+
if (!activeWaiter) return;
|
|
171
|
+
this.#waiters.delete(gateId);
|
|
172
|
+
activeWaiter.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
// A listener may answer synchronously while the broker emits. The durable
|
|
176
|
+
// record is the source of truth in that race; never strand its promise.
|
|
177
|
+
const persisted = this.#store.get(gate.gate_id);
|
|
178
|
+
return persisted?.status === "accepted" ? Promise.resolve(persisted.answer) : waiter.promise;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
onGateEmitted(listener: (gate: WorkflowGate) => void): () => void {
|
|
182
|
+
this.#listeners.add(listener);
|
|
183
|
+
for (const gate of this.listPendingGates()) listener(gate);
|
|
184
|
+
return () => this.#listeners.delete(listener);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
resolveGate(response: WorkflowGateResponse): Promise<WorkflowGateResolution> {
|
|
188
|
+
if (this.#fenced || this.#suspended)
|
|
189
|
+
return Promise.reject(new WorkflowGateBrokerError("unknown_gate", `workflow gate emitter is unavailable`));
|
|
190
|
+
return this.#broker.resolve(response).catch(error => {
|
|
191
|
+
if (!this.#fenced && !this.#suspended) this.#scheduleRecovery();
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
prepareTerminalization(gateId: string, proof: WorkflowGateTerminalProof): boolean {
|
|
196
|
+
return this.#broker.prepareTerminalization(gateId, proof);
|
|
197
|
+
}
|
|
198
|
+
clearPreparedTerminalization(gateId: string): void {
|
|
199
|
+
this.#broker.clearPreparedTerminalization(gateId);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
listPendingGates(): WorkflowGate[] {
|
|
203
|
+
return this.#suspended ? [] : this.#broker.listPendingGates();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
listGateDiagnostics(): WorkflowGateDiagnostic[] {
|
|
207
|
+
return this.#broker.listGateDiagnostics();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
listWorkflowGateQueryRecords(): WorkflowGateQueryRecord[] {
|
|
211
|
+
return this.#broker.listWorkflowGateQueryRecords();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
suspend(): void {
|
|
215
|
+
if (!this.#fenced) this.#suspended = true;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
resume(): void {
|
|
219
|
+
if (!this.#fenced) this.#suspended = false;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
fence(): void {
|
|
223
|
+
if (this.#fenced) return;
|
|
224
|
+
this.#fenced = true;
|
|
225
|
+
this.#suspended = false;
|
|
226
|
+
if (this.#recoveryTimer) clearTimeout(this.#recoveryTimer);
|
|
227
|
+
this.#recoveryTimer = undefined;
|
|
228
|
+
if (this.#recoveryGraceTimer) clearTimeout(this.#recoveryGraceTimer);
|
|
229
|
+
this.#recoveryGraceTimer = undefined;
|
|
230
|
+
this.#recoveryGraceWake?.();
|
|
231
|
+
this.#recoveryGraceWake = undefined;
|
|
232
|
+
for (const [gateId, waiter] of this.#waiters) {
|
|
233
|
+
this.#waiters.delete(gateId);
|
|
234
|
+
waiter.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
|
|
235
|
+
}
|
|
236
|
+
this.#broker.fenceContinuations();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
quarantineGate(gateId: string): void {
|
|
240
|
+
const waiter = this.#waiters.get(gateId);
|
|
241
|
+
if (waiter) {
|
|
242
|
+
this.#waiters.delete(gateId);
|
|
243
|
+
waiter.reject(new Error(`workflow gate ${gateId} continuation was fenced`));
|
|
244
|
+
}
|
|
245
|
+
this.#broker.quarantineGate(gateId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
registerGateTerminalController(controller: WorkflowGateTerminalController): () => void {
|
|
249
|
+
if (this.#terminalController && this.#terminalController !== controller)
|
|
250
|
+
throw new Error("a workflow gate terminal controller is already registered");
|
|
251
|
+
this.#terminalController = controller;
|
|
252
|
+
return () => {
|
|
253
|
+
if (this.#terminalController === controller) this.#terminalController = undefined;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
setAckRecoveryParticipant(participant: AskSelectedAckRecoveryParticipant | null): void {
|
|
258
|
+
this.#recoveryParticipant = participant ?? undefined;
|
|
259
|
+
if (participant) this.#participantReady.resolve();
|
|
260
|
+
void this.#startRecoveryOnce();
|
|
261
|
+
}
|
|
262
|
+
async recoverAcceptedGates(): Promise<string[]> {
|
|
263
|
+
const recovered = await this.#broker.recover();
|
|
264
|
+
if (recovered.length > 0 || this.#broker.hasRecoverableAcceptedGate()) {
|
|
265
|
+
this.#scheduleRecovery();
|
|
266
|
+
} else {
|
|
267
|
+
this.#recoveryAttempts = 0;
|
|
268
|
+
}
|
|
269
|
+
return recovered;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async resolveGateFromNotification(
|
|
273
|
+
response: WorkflowGateResponse,
|
|
274
|
+
options: NotificationGateResolutionOptions,
|
|
275
|
+
): Promise<WorkflowGateResolution> {
|
|
276
|
+
let claimSettled = false;
|
|
277
|
+
const resolveClaim = () => {
|
|
278
|
+
if (claimSettled) return;
|
|
279
|
+
claimSettled = true;
|
|
280
|
+
options.resolveClaim();
|
|
281
|
+
};
|
|
282
|
+
const closeClaimInvalid = (reason: string) => {
|
|
283
|
+
if (claimSettled) return;
|
|
284
|
+
claimSettled = true;
|
|
285
|
+
options.closeClaimInvalid(reason);
|
|
286
|
+
};
|
|
287
|
+
try {
|
|
288
|
+
JSON.parse(options.answerJson);
|
|
289
|
+
} catch {
|
|
290
|
+
closeClaimInvalid("invalid_structured_answer");
|
|
291
|
+
return {
|
|
292
|
+
gate_id: response.gate_id,
|
|
293
|
+
status: "rejected",
|
|
294
|
+
answer_hash: "",
|
|
295
|
+
resolved_at: new Date().toISOString(),
|
|
296
|
+
error: this.#broker.validationError(response.gate_id, "json_parse", "invalid structured answer"),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
let semanticDisposition: PersistedSemanticDisposition;
|
|
300
|
+
try {
|
|
301
|
+
semanticDisposition = this.#broker.classifyDisposition(response.gate_id, response.answer);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
const message = error instanceof Error ? error.message : "invalid answer";
|
|
304
|
+
closeClaimInvalid(message);
|
|
305
|
+
return {
|
|
306
|
+
gate_id: response.gate_id,
|
|
307
|
+
status: "rejected",
|
|
308
|
+
answer_hash: "",
|
|
309
|
+
resolved_at: new Date().toISOString(),
|
|
310
|
+
error: this.#broker.validationError(response.gate_id, "semantic_disposition", message),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const commitKey = `${response.gate_id}:${options.idempotencyKey ?? options.replyReceiptId}`;
|
|
314
|
+
const ackPolicy: PersistedAckPolicy =
|
|
315
|
+
semanticDisposition === "commit"
|
|
316
|
+
? {
|
|
317
|
+
kind: "telegram_selected_v1",
|
|
318
|
+
commitKey,
|
|
319
|
+
actionId: options.interactionActionId,
|
|
320
|
+
state: "pending",
|
|
321
|
+
updatedAt: new Date().toISOString(),
|
|
322
|
+
}
|
|
323
|
+
: { kind: "none", reason: "semantic_noncommit" };
|
|
324
|
+
try {
|
|
325
|
+
const resolution = await this.#broker.resolve(response, {
|
|
326
|
+
resolutionOrigin: { kind: "telegram_notification", interactionActionId: options.interactionActionId },
|
|
327
|
+
ackPolicy,
|
|
328
|
+
semanticDisposition,
|
|
329
|
+
beforeAdvance: async () => {
|
|
330
|
+
if (ackPolicy.kind === "telegram_selected_v1") {
|
|
331
|
+
this.#broker.updateAckPolicy(response.gate_id, {
|
|
332
|
+
...ackPolicy,
|
|
333
|
+
state: "attempt_started",
|
|
334
|
+
updatedAt: new Date().toISOString(),
|
|
335
|
+
});
|
|
336
|
+
let outcome: AskSelectedAckOutcome;
|
|
337
|
+
try {
|
|
338
|
+
outcome = await options.requestSelectedAck({
|
|
339
|
+
replyReceiptId: options.replyReceiptId,
|
|
340
|
+
actionId: options.interactionActionId,
|
|
341
|
+
commitKey,
|
|
342
|
+
daemonDeadlineAt: Date.now() + 8_000,
|
|
343
|
+
hostTimeoutMs: 10_000,
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
outcome = { status: "unknown", reason: "host_timeout" };
|
|
347
|
+
}
|
|
348
|
+
this.#broker.updateAckPolicy(response.gate_id, {
|
|
349
|
+
...ackPolicy,
|
|
350
|
+
state: outcome.status,
|
|
351
|
+
outcome,
|
|
352
|
+
updatedAt: new Date().toISOString(),
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
resolveClaim();
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
if (resolution.status === "accepted") resolveClaim();
|
|
359
|
+
else closeClaimInvalid(resolution.error?.code ?? "invalid_answer");
|
|
360
|
+
return resolution;
|
|
361
|
+
} catch (error) {
|
|
362
|
+
closeClaimInvalid(error instanceof Error ? error.message : "invalid_answer");
|
|
363
|
+
this.#scheduleRecovery();
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async #completeAccepted(record: PersistedGate): Promise<void> {
|
|
369
|
+
const waiter = this.#waiters.get(record.gate.gate_id);
|
|
370
|
+
if (!waiter) throw new Error(`workflow gate ${record.gate.gate_id} lost its continuation owner`);
|
|
371
|
+
waiter.resolve(record.answer);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async #terminalizeAccepted(record: PersistedGate): Promise<WorkflowGateTerminalProof> {
|
|
375
|
+
const controller = this.#terminalController;
|
|
376
|
+
if (!controller) {
|
|
377
|
+
this.quarantineGate(record.gate.gate_id);
|
|
378
|
+
throw new Error(`workflow gate ${record.gate.gate_id} has no terminal controller`);
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const proof = await controller.completeGateInteractions(record.gate.gate_id);
|
|
382
|
+
if (proof !== "retired" && proof !== "already_terminal" && proof !== "not_published")
|
|
383
|
+
throw new Error(`workflow gate ${record.gate.gate_id} terminalization returned no exact proof`);
|
|
384
|
+
return proof;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
try {
|
|
387
|
+
await controller.cancelGateInteractions(
|
|
388
|
+
record.gate.gate_id,
|
|
389
|
+
"workflow gate presentation terminalization failed",
|
|
390
|
+
);
|
|
391
|
+
} finally {
|
|
392
|
+
this.quarantineGate(record.gate.gate_id);
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async #finalizeAccepted(record: PersistedGate): Promise<void> {
|
|
399
|
+
const policy = record.ackPolicy;
|
|
400
|
+
if (policy?.kind === "telegram_selected_v1" && policy.state === "pending") {
|
|
401
|
+
let outcome: AskSelectedAckOutcome;
|
|
402
|
+
const participant = this.#recoveryParticipant;
|
|
403
|
+
if (!participant) outcome = { status: "failed", reason: "no_participant" };
|
|
404
|
+
else {
|
|
405
|
+
this.#broker.updateAckPolicy(record.gate.gate_id, {
|
|
406
|
+
...policy,
|
|
407
|
+
state: "attempt_started",
|
|
408
|
+
updatedAt: new Date().toISOString(),
|
|
409
|
+
});
|
|
410
|
+
try {
|
|
411
|
+
outcome = await participant.requestRecoveredAskSelectedAck({
|
|
412
|
+
sessionId: this.#runId,
|
|
413
|
+
actionId: policy.actionId,
|
|
414
|
+
commitKey: policy.commitKey,
|
|
415
|
+
deadlineAt: Date.now() + 8_000,
|
|
416
|
+
hostTimeoutMs: 10_000,
|
|
417
|
+
});
|
|
418
|
+
} catch {
|
|
419
|
+
outcome = { status: "unknown", reason: "host_timeout" };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
this.#broker.updateAckPolicy(record.gate.gate_id, {
|
|
423
|
+
...policy,
|
|
424
|
+
state: outcome.status,
|
|
425
|
+
outcome,
|
|
426
|
+
updatedAt: new Date().toISOString(),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
if (policy?.kind === "telegram_selected_v1" && policy.state === "attempt_started") {
|
|
430
|
+
this.#broker.updateAckPolicy(record.gate.gate_id, {
|
|
431
|
+
...policy,
|
|
432
|
+
state: "unknown",
|
|
433
|
+
outcome: { status: "unknown", reason: "shutdown" },
|
|
434
|
+
updatedAt: new Date().toISOString(),
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
#scheduleRecovery(): void {
|
|
440
|
+
if (this.#recoveryTimer || this.#recoveryAttempts >= 3) return;
|
|
441
|
+
const delay = 50 * 2 ** this.#recoveryAttempts++;
|
|
442
|
+
this.#recoveryTimer = setTimeout(() => {
|
|
443
|
+
this.#recoveryTimer = undefined;
|
|
444
|
+
void this.recoverAcceptedGates().catch(() => {
|
|
445
|
+
this.#scheduleRecovery();
|
|
446
|
+
});
|
|
447
|
+
}, delay);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async #startRecoveryOnce(options: { participantGraceMs?: number } = {}): Promise<void> {
|
|
451
|
+
if (this.#fenced) return;
|
|
452
|
+
if (this.#recoveryPromise) return this.#recoveryPromise;
|
|
453
|
+
this.#recoveryPromise = (async () => {
|
|
454
|
+
if (!this.#recoveryParticipant) {
|
|
455
|
+
const grace = Promise.withResolvers<void>();
|
|
456
|
+
this.#recoveryGraceWake = grace.resolve;
|
|
457
|
+
const graceMs = options.participantGraceMs ?? 2_000;
|
|
458
|
+
this.#recoveryGraceTimer = setTimeout(grace.resolve, graceMs);
|
|
459
|
+
await Promise.race([this.#participantReady.promise, grace.promise]);
|
|
460
|
+
if (this.#recoveryGraceTimer) clearTimeout(this.#recoveryGraceTimer);
|
|
461
|
+
this.#recoveryGraceTimer = undefined;
|
|
462
|
+
this.#recoveryGraceWake = undefined;
|
|
463
|
+
}
|
|
464
|
+
if (!this.#fenced) await this.recoverAcceptedGates();
|
|
465
|
+
})();
|
|
466
|
+
return this.#recoveryPromise;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const V1_STAGES: readonly WorkflowStage[] = ["deep-interview", "ralplan", "ultragoal"];
|
|
55
471
|
|
|
56
472
|
export interface PersistedGate {
|
|
57
|
-
gate:
|
|
58
|
-
status: "pending" | "accepted";
|
|
473
|
+
gate: WorkflowGate;
|
|
474
|
+
status: "pending" | "accepted" | "quarantined";
|
|
475
|
+
ownerInstanceId?: string;
|
|
59
476
|
idempotencyKey?: string;
|
|
60
477
|
responseHash?: string;
|
|
61
|
-
/** Raw accepted answer, retained so a
|
|
478
|
+
/** Raw accepted answer, retained so a same-process advance can be replayed. */
|
|
62
479
|
answer?: unknown;
|
|
63
|
-
resolution?:
|
|
480
|
+
resolution?: WorkflowGateResolution;
|
|
64
481
|
advanced: boolean;
|
|
482
|
+
/** Exact presentation terminalization completed before workflow advancement. */
|
|
483
|
+
terminalized?: boolean;
|
|
484
|
+
/** Exact presentation terminalization proof recorded before workflow advancement. */
|
|
485
|
+
terminalProof?: WorkflowGateTerminalProof;
|
|
65
486
|
semanticDisposition?: PersistedSemanticDisposition;
|
|
66
487
|
resolutionOrigin?: PersistedResolutionOrigin;
|
|
67
488
|
ackPolicy?: PersistedAckPolicy;
|
|
489
|
+
lifecycle?: WorkflowGateDiagnostic["lifecycle"];
|
|
68
490
|
}
|
|
69
491
|
|
|
70
492
|
export interface GateStore {
|
|
71
|
-
nextSeq(stage:
|
|
493
|
+
nextSeq(stage: WorkflowStage): number;
|
|
72
494
|
put(record: PersistedGate): void;
|
|
73
495
|
get(gateId: string): PersistedGate | undefined;
|
|
74
|
-
/**
|
|
496
|
+
/** Atomically quarantines records owned by a prior runtime before exposure. */
|
|
497
|
+
beginRuntimeInstance(instanceId: string): void;
|
|
498
|
+
/** All persisted gate records (used for same-process recovery and Q12). */
|
|
75
499
|
list(): PersistedGate[];
|
|
76
500
|
}
|
|
77
501
|
|
|
78
502
|
export class MemoryGateStore implements GateStore {
|
|
79
|
-
private counters = new Map<
|
|
503
|
+
private counters = new Map<WorkflowStage, number>();
|
|
80
504
|
private gates = new Map<string, PersistedGate>();
|
|
81
|
-
|
|
82
|
-
nextSeq(stage:
|
|
505
|
+
private runtimeInstanceId: string | undefined;
|
|
506
|
+
nextSeq(stage: WorkflowStage): number {
|
|
83
507
|
const next = (this.counters.get(stage) ?? 0) + 1;
|
|
84
508
|
this.counters.set(stage, next);
|
|
85
509
|
return next;
|
|
@@ -91,21 +515,459 @@ export class MemoryGateStore implements GateStore {
|
|
|
91
515
|
const r = this.gates.get(gateId);
|
|
92
516
|
return r ? structuredClone(r) : undefined;
|
|
93
517
|
}
|
|
518
|
+
beginRuntimeInstance(instanceId: string): void {
|
|
519
|
+
if (this.runtimeInstanceId === instanceId) return;
|
|
520
|
+
const priorInstanceId = this.runtimeInstanceId;
|
|
521
|
+
const now = new Date().toISOString();
|
|
522
|
+
for (const [gateId, record] of this.gates) {
|
|
523
|
+
if (record.ownerInstanceId !== priorInstanceId) continue;
|
|
524
|
+
if (record.status === "pending") {
|
|
525
|
+
this.gates.set(gateId, {
|
|
526
|
+
...record,
|
|
527
|
+
status: "quarantined",
|
|
528
|
+
lifecycle: { state: "quarantined", reason: "orphaned_after_process_restart", quarantinedAt: now },
|
|
529
|
+
});
|
|
530
|
+
} else if (record.status === "accepted" && !record.advanced) {
|
|
531
|
+
this.gates.set(gateId, {
|
|
532
|
+
...record,
|
|
533
|
+
status: "quarantined",
|
|
534
|
+
lifecycle: {
|
|
535
|
+
state: "quarantined",
|
|
536
|
+
reason: "accepted_unadvanced_after_process_restart",
|
|
537
|
+
quarantinedAt: now,
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
this.runtimeInstanceId = instanceId;
|
|
543
|
+
}
|
|
94
544
|
list(): PersistedGate[] {
|
|
95
545
|
return [...this.gates.values()].map(r => structuredClone(r));
|
|
96
546
|
}
|
|
97
547
|
}
|
|
98
548
|
|
|
99
549
|
interface FileState {
|
|
100
|
-
|
|
550
|
+
version: 1;
|
|
551
|
+
counters: Partial<Record<WorkflowStage, number>>;
|
|
101
552
|
gates: Record<string, PersistedGate>;
|
|
553
|
+
runtimeInstanceId?: string;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const FILE_GATE_STORE_VERSION = 1;
|
|
557
|
+
|
|
558
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
559
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
563
|
+
return typeof value === "string" && value.length > 0;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function isIsoDate(value: unknown): value is string {
|
|
567
|
+
return typeof value === "string" && Number.isFinite(Date.parse(value));
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function invalidFileState(message: string): never {
|
|
571
|
+
throw new Error(`invalid gate store at ${message}`);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function migrateVersionlessV0SemanticDisposition(
|
|
575
|
+
value: unknown,
|
|
576
|
+
filePath: string,
|
|
577
|
+
): PersistedSemanticDisposition | undefined {
|
|
578
|
+
if (value === undefined) return undefined;
|
|
579
|
+
if (value === "commit" || value === "resolve_without_commit") return value;
|
|
580
|
+
invalidFileState(filePath);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function migrateVersionlessV0ResolutionOrigin(value: unknown, filePath: string): PersistedResolutionOrigin | undefined {
|
|
584
|
+
if (value === undefined) return undefined;
|
|
585
|
+
if (!isObject(value)) invalidFileState(filePath);
|
|
586
|
+
if (value.kind === "telegram_notification" && isNonEmptyString(value.interactionActionId))
|
|
587
|
+
return { kind: "telegram_notification", interactionActionId: value.interactionActionId };
|
|
588
|
+
if (value.kind === "legacy_unknown") return { kind: "generic", channel: "other" };
|
|
589
|
+
if (value.kind !== "generic" || typeof value.channel !== "string") invalidFileState(filePath);
|
|
590
|
+
if (value.channel === "sdk" || value.channel === "other") return { kind: "generic", channel: value.channel };
|
|
591
|
+
if (value.channel === "rpc") return { kind: "generic", channel: "sdk" };
|
|
592
|
+
if (value.channel === "bridge") return { kind: "generic", channel: "other" };
|
|
593
|
+
invalidFileState(filePath);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function migrateVersionlessV0AckPolicy(value: unknown, filePath: string): PersistedAckPolicy | undefined {
|
|
597
|
+
if (value === undefined) return undefined;
|
|
598
|
+
if (!isObject(value)) invalidFileState(filePath);
|
|
599
|
+
if (
|
|
600
|
+
value.kind === "none" &&
|
|
601
|
+
(value.reason === "non_telegram" || value.reason === "semantic_noncommit" || value.reason === "legacy_unproven")
|
|
602
|
+
)
|
|
603
|
+
return { kind: "none", reason: value.reason };
|
|
604
|
+
if (
|
|
605
|
+
value.kind !== "telegram_selected_v1" ||
|
|
606
|
+
!isNonEmptyString(value.commitKey) ||
|
|
607
|
+
!isNonEmptyString(value.actionId) ||
|
|
608
|
+
!isIsoDate(value.updatedAt) ||
|
|
609
|
+
!(["pending", "attempt_started", "delivered", "failed", "unknown"] as const).includes(value.state as never)
|
|
610
|
+
)
|
|
611
|
+
invalidFileState(filePath);
|
|
612
|
+
if (value.outcome === undefined)
|
|
613
|
+
return {
|
|
614
|
+
kind: "telegram_selected_v1",
|
|
615
|
+
commitKey: value.commitKey,
|
|
616
|
+
actionId: value.actionId,
|
|
617
|
+
state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
|
|
618
|
+
updatedAt: value.updatedAt,
|
|
619
|
+
};
|
|
620
|
+
if (!isObject(value.outcome)) invalidFileState(filePath);
|
|
621
|
+
if (
|
|
622
|
+
value.outcome.status === "delivered" &&
|
|
623
|
+
typeof value.outcome.messageId === "number" &&
|
|
624
|
+
Number.isSafeInteger(value.outcome.messageId)
|
|
625
|
+
)
|
|
626
|
+
return {
|
|
627
|
+
kind: "telegram_selected_v1",
|
|
628
|
+
commitKey: value.commitKey,
|
|
629
|
+
actionId: value.actionId,
|
|
630
|
+
state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
|
|
631
|
+
outcome: { status: "delivered", messageId: value.outcome.messageId },
|
|
632
|
+
updatedAt: value.updatedAt,
|
|
633
|
+
};
|
|
634
|
+
if (
|
|
635
|
+
value.outcome.status === "failed" &&
|
|
636
|
+
(
|
|
637
|
+
[
|
|
638
|
+
"unsupported",
|
|
639
|
+
"no_participant",
|
|
640
|
+
"ambiguous_participant",
|
|
641
|
+
"route_missing",
|
|
642
|
+
"expired",
|
|
643
|
+
"cancelled",
|
|
644
|
+
"telegram_rejected",
|
|
645
|
+
"session_closed",
|
|
646
|
+
] as const
|
|
647
|
+
).includes(value.outcome.reason as never)
|
|
648
|
+
)
|
|
649
|
+
return {
|
|
650
|
+
kind: "telegram_selected_v1",
|
|
651
|
+
commitKey: value.commitKey,
|
|
652
|
+
actionId: value.actionId,
|
|
653
|
+
state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
|
|
654
|
+
outcome: {
|
|
655
|
+
status: "failed",
|
|
656
|
+
reason: value.outcome.reason as
|
|
657
|
+
| "unsupported"
|
|
658
|
+
| "no_participant"
|
|
659
|
+
| "ambiguous_participant"
|
|
660
|
+
| "route_missing"
|
|
661
|
+
| "expired"
|
|
662
|
+
| "cancelled"
|
|
663
|
+
| "telegram_rejected"
|
|
664
|
+
| "session_closed",
|
|
665
|
+
},
|
|
666
|
+
updatedAt: value.updatedAt,
|
|
667
|
+
};
|
|
668
|
+
if (
|
|
669
|
+
value.outcome.status === "unknown" &&
|
|
670
|
+
(["transport_ambiguous", "origin_disconnected", "host_timeout", "shutdown"] as const).includes(
|
|
671
|
+
value.outcome.reason as never,
|
|
672
|
+
)
|
|
673
|
+
)
|
|
674
|
+
return {
|
|
675
|
+
kind: "telegram_selected_v1",
|
|
676
|
+
commitKey: value.commitKey,
|
|
677
|
+
actionId: value.actionId,
|
|
678
|
+
state: value.state as "pending" | "attempt_started" | "delivered" | "failed" | "unknown",
|
|
679
|
+
outcome: {
|
|
680
|
+
status: "unknown",
|
|
681
|
+
reason: value.outcome.reason as "transport_ambiguous" | "origin_disconnected" | "host_timeout" | "shutdown",
|
|
682
|
+
},
|
|
683
|
+
updatedAt: value.updatedAt,
|
|
684
|
+
};
|
|
685
|
+
invalidFileState(filePath);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function migrateVersionlessV0AcceptedRecord(legacy: Record<string, unknown>, filePath: string): PersistedGate {
|
|
689
|
+
if (typeof legacy.advanced !== "boolean") invalidFileState(filePath);
|
|
690
|
+
const semanticDisposition = migrateVersionlessV0SemanticDisposition(legacy.semanticDisposition, filePath);
|
|
691
|
+
const resolutionOrigin = migrateVersionlessV0ResolutionOrigin(legacy.resolutionOrigin, filePath);
|
|
692
|
+
const ackPolicy = migrateVersionlessV0AckPolicy(legacy.ackPolicy, filePath);
|
|
693
|
+
return {
|
|
694
|
+
...(legacy as unknown as PersistedGate),
|
|
695
|
+
...(semanticDisposition === undefined ? {} : { semanticDisposition }),
|
|
696
|
+
...(resolutionOrigin === undefined ? {} : { resolutionOrigin }),
|
|
697
|
+
...(ackPolicy === undefined ? {} : { ackPolicy }),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function migrateVersionlessV0(value: unknown, filePath: string): FileState {
|
|
702
|
+
if (!isObject(value) || Object.hasOwn(value, "version")) invalidFileState(filePath);
|
|
703
|
+
if (Object.keys(value).some(key => key !== "counters" && key !== "gates" && key !== "runtimeInstanceId"))
|
|
704
|
+
invalidFileState(filePath);
|
|
705
|
+
if (!isObject(value.counters) || !isObject(value.gates)) invalidFileState(filePath);
|
|
706
|
+
const gates: Record<string, PersistedGate> = {};
|
|
707
|
+
for (const [gateId, legacy] of Object.entries(value.gates)) {
|
|
708
|
+
if (!isObject(legacy) || !isObject(legacy.gate) || legacy.ownerInstanceId !== undefined)
|
|
709
|
+
invalidFileState(filePath);
|
|
710
|
+
const status = legacy.status ?? "pending";
|
|
711
|
+
if (status !== "pending" && status !== "accepted") invalidFileState(filePath);
|
|
712
|
+
if (status === "pending") {
|
|
713
|
+
gates[gateId] = {
|
|
714
|
+
gate: legacy.gate as unknown as WorkflowGate,
|
|
715
|
+
status: "quarantined",
|
|
716
|
+
ownerInstanceId: "legacy-v0",
|
|
717
|
+
advanced: false,
|
|
718
|
+
lifecycle: {
|
|
719
|
+
state: "quarantined",
|
|
720
|
+
reason: "orphaned_after_process_restart",
|
|
721
|
+
quarantinedAt: new Date().toISOString(),
|
|
722
|
+
},
|
|
723
|
+
};
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
const accepted = migrateVersionlessV0AcceptedRecord(legacy, filePath);
|
|
727
|
+
if (accepted.advanced) {
|
|
728
|
+
gates[gateId] = { ...accepted, ownerInstanceId: "legacy-v0" };
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
gates[gateId] = {
|
|
732
|
+
...accepted,
|
|
733
|
+
status: "quarantined",
|
|
734
|
+
ownerInstanceId: "legacy-v0",
|
|
735
|
+
advanced: false,
|
|
736
|
+
lifecycle: {
|
|
737
|
+
state: "quarantined",
|
|
738
|
+
reason: "accepted_unadvanced_after_process_restart",
|
|
739
|
+
quarantinedAt: new Date().toISOString(),
|
|
740
|
+
},
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
const migrated: FileState = {
|
|
744
|
+
version: FILE_GATE_STORE_VERSION,
|
|
745
|
+
counters: value.counters as Partial<Record<WorkflowStage, number>>,
|
|
746
|
+
gates,
|
|
747
|
+
...(value.runtimeInstanceId === undefined ? {} : { runtimeInstanceId: value.runtimeInstanceId as string }),
|
|
748
|
+
};
|
|
749
|
+
assertFileState(migrated, filePath);
|
|
750
|
+
return migrated;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function assertFileState(value: unknown, filePath: string): asserts value is FileState {
|
|
754
|
+
if (
|
|
755
|
+
!isObject(value) ||
|
|
756
|
+
value.version !== FILE_GATE_STORE_VERSION ||
|
|
757
|
+
!isObject(value.counters) ||
|
|
758
|
+
!isObject(value.gates)
|
|
759
|
+
)
|
|
760
|
+
invalidFileState(filePath);
|
|
761
|
+
for (const [stage, counter] of Object.entries(value.counters)) {
|
|
762
|
+
if (
|
|
763
|
+
!V1_STAGES.includes(stage as WorkflowStage) ||
|
|
764
|
+
typeof counter !== "number" ||
|
|
765
|
+
!Number.isSafeInteger(counter) ||
|
|
766
|
+
counter < 0
|
|
767
|
+
)
|
|
768
|
+
invalidFileState(filePath);
|
|
769
|
+
}
|
|
770
|
+
if (value.runtimeInstanceId !== undefined && !isNonEmptyString(value.runtimeInstanceId)) invalidFileState(filePath);
|
|
771
|
+
for (const [gateId, record] of Object.entries(value.gates))
|
|
772
|
+
assertPersistedGate(record, gateId, value.counters, filePath);
|
|
773
|
+
for (const [gateId, record] of Object.entries(value.gates)) {
|
|
774
|
+
if (
|
|
775
|
+
!isObject(record) ||
|
|
776
|
+
record.status !== "quarantined" ||
|
|
777
|
+
!isObject(record.lifecycle) ||
|
|
778
|
+
!isNonEmptyString(record.lifecycle.supersededByGateId)
|
|
779
|
+
)
|
|
780
|
+
continue;
|
|
781
|
+
if (
|
|
782
|
+
record.lifecycle.supersededByGateId === gateId ||
|
|
783
|
+
!Object.hasOwn(value.gates, record.lifecycle.supersededByGateId)
|
|
784
|
+
)
|
|
785
|
+
invalidFileState(filePath);
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function assertPersistedGate(
|
|
790
|
+
value: unknown,
|
|
791
|
+
gateId: string,
|
|
792
|
+
counters: Record<string, unknown>,
|
|
793
|
+
filePath: string,
|
|
794
|
+
): asserts value is PersistedGate {
|
|
795
|
+
if (
|
|
796
|
+
!isObject(value) ||
|
|
797
|
+
!isObject(value.gate) ||
|
|
798
|
+
value.gate.gate_id !== gateId ||
|
|
799
|
+
!isNonEmptyString(value.ownerInstanceId)
|
|
800
|
+
)
|
|
801
|
+
invalidFileState(filePath);
|
|
802
|
+
const gate = value.gate;
|
|
803
|
+
const stage = gate.stage as WorkflowStage;
|
|
804
|
+
const schema = gate.schema as WorkflowGate["schema"];
|
|
805
|
+
if (
|
|
806
|
+
gate.type !== "workflow_gate" ||
|
|
807
|
+
!isNonEmptyString(gate.gate_id) ||
|
|
808
|
+
!V1_STAGES.includes(stage) ||
|
|
809
|
+
!["question", "approval", "execution"].includes(gate.kind as string) ||
|
|
810
|
+
!isObject(gate.schema) ||
|
|
811
|
+
!isNonEmptyString(gate.schema_hash) ||
|
|
812
|
+
!isObject(gate.context) ||
|
|
813
|
+
!isIsoDate(gate.created_at) ||
|
|
814
|
+
gate.required !== true
|
|
815
|
+
)
|
|
816
|
+
invalidFileState(filePath);
|
|
817
|
+
const id = new RegExp(`^wg_[A-Za-z0-9]+_${gate.stage}_(\\d{6,})$`).exec(gate.gate_id);
|
|
818
|
+
if (
|
|
819
|
+
!id ||
|
|
820
|
+
typeof counters[stage] !== "number" ||
|
|
821
|
+
Number(id[1]) > counters[stage] ||
|
|
822
|
+
gate.schema_hash !== schemaHash(schema)
|
|
823
|
+
)
|
|
824
|
+
invalidFileState(filePath);
|
|
825
|
+
try {
|
|
826
|
+
compileGateSchema(schema);
|
|
827
|
+
} catch {
|
|
828
|
+
invalidFileState(filePath);
|
|
829
|
+
}
|
|
830
|
+
if (
|
|
831
|
+
gate.options !== undefined &&
|
|
832
|
+
(!Array.isArray(gate.options) ||
|
|
833
|
+
gate.options.some(option => !isObject(option) || !isNonEmptyString(option.label)))
|
|
834
|
+
)
|
|
835
|
+
invalidFileState(filePath);
|
|
836
|
+
if (value.status === "pending") {
|
|
837
|
+
if (
|
|
838
|
+
value.advanced !== false ||
|
|
839
|
+
value.terminalized !== undefined ||
|
|
840
|
+
value.terminalProof !== undefined ||
|
|
841
|
+
value.lifecycle !== undefined ||
|
|
842
|
+
value.resolution !== undefined ||
|
|
843
|
+
value.answer !== undefined ||
|
|
844
|
+
value.idempotencyKey !== undefined ||
|
|
845
|
+
value.responseHash !== undefined ||
|
|
846
|
+
value.semanticDisposition !== undefined ||
|
|
847
|
+
value.resolutionOrigin !== undefined ||
|
|
848
|
+
value.ackPolicy !== undefined
|
|
849
|
+
)
|
|
850
|
+
invalidFileState(filePath);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (value.status === "accepted") {
|
|
854
|
+
if (
|
|
855
|
+
typeof value.advanced !== "boolean" ||
|
|
856
|
+
(value.terminalized !== undefined && typeof value.terminalized !== "boolean") ||
|
|
857
|
+
(value.terminalProof !== undefined &&
|
|
858
|
+
value.terminalProof !== "retired" &&
|
|
859
|
+
value.terminalProof !== "already_terminal" &&
|
|
860
|
+
value.terminalProof !== "not_published") ||
|
|
861
|
+
(value.terminalized === true && value.terminalProof === undefined) ||
|
|
862
|
+
(value.terminalized !== true && value.terminalProof !== undefined) ||
|
|
863
|
+
!Object.hasOwn(value, "answer") ||
|
|
864
|
+
(value.idempotencyKey !== undefined && !isNonEmptyString(value.idempotencyKey)) ||
|
|
865
|
+
!isNonEmptyString(value.responseHash) ||
|
|
866
|
+
value.responseHash !== answerHashOf({ gate_id: gateId, answer: value.answer }) ||
|
|
867
|
+
!isObject(value.resolution) ||
|
|
868
|
+
value.resolution.gate_id !== gateId ||
|
|
869
|
+
value.resolution.status !== "accepted" ||
|
|
870
|
+
value.resolution.answer_hash !== answerHashOf(value.answer) ||
|
|
871
|
+
!isIsoDate(value.resolution.resolved_at) ||
|
|
872
|
+
value.resolution.error !== undefined ||
|
|
873
|
+
value.lifecycle !== undefined
|
|
874
|
+
)
|
|
875
|
+
invalidFileState(filePath);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
if (
|
|
879
|
+
value.status !== "quarantined" ||
|
|
880
|
+
value.advanced !== false ||
|
|
881
|
+
!isObject(value.lifecycle) ||
|
|
882
|
+
Object.keys(value.lifecycle).some(
|
|
883
|
+
key => key !== "state" && key !== "reason" && key !== "quarantinedAt" && key !== "supersededByGateId",
|
|
884
|
+
) ||
|
|
885
|
+
value.lifecycle.state !== "quarantined" ||
|
|
886
|
+
![
|
|
887
|
+
"orphaned_after_process_restart",
|
|
888
|
+
"accepted_unadvanced_after_process_restart",
|
|
889
|
+
"continuation_owner_lost",
|
|
890
|
+
"opened_without_continuation",
|
|
891
|
+
"finalization_failed",
|
|
892
|
+
"advance_failed",
|
|
893
|
+
].includes(value.lifecycle.reason as string) ||
|
|
894
|
+
!isIsoDate(value.lifecycle.quarantinedAt) ||
|
|
895
|
+
(value.lifecycle.supersededByGateId !== undefined && !isNonEmptyString(value.lifecycle.supersededByGateId))
|
|
896
|
+
)
|
|
897
|
+
invalidFileState(filePath);
|
|
898
|
+
const hasAcceptedState =
|
|
899
|
+
Object.hasOwn(value, "answer") ||
|
|
900
|
+
value.terminalized !== undefined ||
|
|
901
|
+
value.terminalProof !== undefined ||
|
|
902
|
+
value.idempotencyKey !== undefined ||
|
|
903
|
+
value.responseHash !== undefined ||
|
|
904
|
+
value.resolution !== undefined ||
|
|
905
|
+
value.semanticDisposition !== undefined ||
|
|
906
|
+
value.resolutionOrigin !== undefined ||
|
|
907
|
+
value.ackPolicy !== undefined;
|
|
908
|
+
if (value.lifecycle.reason === "accepted_unadvanced_after_process_restart" || hasAcceptedState) {
|
|
909
|
+
if (
|
|
910
|
+
!Object.hasOwn(value, "answer") ||
|
|
911
|
+
(value.terminalized !== undefined && typeof value.terminalized !== "boolean") ||
|
|
912
|
+
(value.terminalProof !== undefined &&
|
|
913
|
+
value.terminalProof !== "retired" &&
|
|
914
|
+
value.terminalProof !== "already_terminal" &&
|
|
915
|
+
value.terminalProof !== "not_published") ||
|
|
916
|
+
(value.terminalized === true && value.terminalProof === undefined) ||
|
|
917
|
+
(value.terminalized !== true && value.terminalProof !== undefined) ||
|
|
918
|
+
(value.idempotencyKey !== undefined && !isNonEmptyString(value.idempotencyKey)) ||
|
|
919
|
+
!isNonEmptyString(value.responseHash) ||
|
|
920
|
+
value.responseHash !== answerHashOf({ gate_id: gateId, answer: value.answer }) ||
|
|
921
|
+
!isObject(value.resolution) ||
|
|
922
|
+
value.resolution.gate_id !== gateId ||
|
|
923
|
+
value.resolution.status !== "accepted" ||
|
|
924
|
+
value.resolution.answer_hash !== answerHashOf(value.answer) ||
|
|
925
|
+
!isIsoDate(value.resolution.resolved_at) ||
|
|
926
|
+
value.resolution.error !== undefined
|
|
927
|
+
)
|
|
928
|
+
invalidFileState(filePath);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (
|
|
932
|
+
value.lifecycle.reason === "accepted_unadvanced_after_process_restart" ||
|
|
933
|
+
value.lifecycle.reason === "finalization_failed" ||
|
|
934
|
+
value.lifecycle.reason === "advance_failed"
|
|
935
|
+
)
|
|
936
|
+
invalidFileState(filePath);
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
export function isUnsupportedWindowsDirectorySyncError(
|
|
940
|
+
error: unknown,
|
|
941
|
+
platform: NodeJS.Platform = process.platform,
|
|
942
|
+
): boolean {
|
|
943
|
+
if (platform !== "win32") return false;
|
|
944
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
945
|
+
// Bun reports EPERM when fsyncSync is applied to a Windows directory handle.
|
|
946
|
+
return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM";
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/** A write may have reached `rename` but not the directory fsync durability barrier. */
|
|
950
|
+
export class GateStoreWriteError extends Error {
|
|
951
|
+
constructor(
|
|
952
|
+
readonly certainty: "not_committed" | "uncertain",
|
|
953
|
+
cause: unknown,
|
|
954
|
+
) {
|
|
955
|
+
super(`gate store write ${certainty}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
956
|
+
this.name = "GateStoreWriteError";
|
|
957
|
+
}
|
|
102
958
|
}
|
|
103
959
|
|
|
104
960
|
/** Crash-durable JSON-file backed store. Writes the full state on every mutation. */
|
|
105
961
|
export class FileGateStore implements GateStore {
|
|
106
962
|
private state: FileState;
|
|
107
|
-
|
|
108
|
-
|
|
963
|
+
private certaintyUnknown = false;
|
|
964
|
+
constructor(
|
|
965
|
+
private readonly filePath: string,
|
|
966
|
+
private readonly sync: (fd: number) => void = fsyncSync,
|
|
967
|
+
) {
|
|
968
|
+
// Load eagerly so a corrupt store fails closed at construction, but defer
|
|
969
|
+
// directory creation to the first write so constructing a store under a
|
|
970
|
+
// non-writable cwd (e.g. a session that never emits a gate) never throws.
|
|
109
971
|
this.state = this.load();
|
|
110
972
|
}
|
|
111
973
|
private load(): FileState {
|
|
@@ -113,14 +975,26 @@ export class FileGateStore implements GateStore {
|
|
|
113
975
|
try {
|
|
114
976
|
raw = readFileSync(this.filePath, "utf8");
|
|
115
977
|
} catch (err) {
|
|
116
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT")
|
|
978
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT")
|
|
979
|
+
return { version: FILE_GATE_STORE_VERSION, counters: {}, gates: {} };
|
|
117
980
|
throw err;
|
|
118
981
|
}
|
|
982
|
+
let parsed: unknown;
|
|
119
983
|
try {
|
|
120
|
-
|
|
984
|
+
parsed = JSON.parse(raw);
|
|
985
|
+
if (isObject(parsed) && !Object.hasOwn(parsed, "version")) {
|
|
986
|
+
const migrated = migrateVersionlessV0(parsed, this.filePath);
|
|
987
|
+
// Persist the v1 envelope before beginRuntimeInstance can quarantine or
|
|
988
|
+
// expose any record. This preserves all v0 records atomically.
|
|
989
|
+
this.flushState(migrated);
|
|
990
|
+
return migrated;
|
|
991
|
+
}
|
|
992
|
+
assertFileState(parsed, this.filePath);
|
|
993
|
+
return parsed;
|
|
121
994
|
} catch (err) {
|
|
122
|
-
|
|
123
|
-
//
|
|
995
|
+
if (err instanceof GateStoreWriteError) throw err;
|
|
996
|
+
// Fail closed: invalid state must not be exposed or mutated because it can
|
|
997
|
+
// drop counters/gates and risk gate-id reuse.
|
|
124
998
|
const quarantine = `${this.filePath}.corrupt-${Date.now()}`;
|
|
125
999
|
try {
|
|
126
1000
|
renameSync(this.filePath, quarantine);
|
|
@@ -128,43 +1002,105 @@ export class FileGateStore implements GateStore {
|
|
|
128
1002
|
/* best-effort quarantine */
|
|
129
1003
|
}
|
|
130
1004
|
throw new Error(
|
|
131
|
-
`corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${
|
|
1005
|
+
`corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${err instanceof Error ? err.message : String(err)}`,
|
|
132
1006
|
);
|
|
133
1007
|
}
|
|
134
1008
|
}
|
|
135
|
-
private
|
|
136
|
-
|
|
1009
|
+
private flushState(next: FileState): void {
|
|
1010
|
+
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
137
1011
|
const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
138
|
-
|
|
1012
|
+
let renamed = false;
|
|
139
1013
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1014
|
+
const fd = openSync(tmp, "w");
|
|
1015
|
+
try {
|
|
1016
|
+
writeFileSync(fd, JSON.stringify(next, null, 2));
|
|
1017
|
+
this.sync(fd);
|
|
1018
|
+
} finally {
|
|
1019
|
+
closeSync(fd);
|
|
1020
|
+
}
|
|
1021
|
+
renameSync(tmp, this.filePath);
|
|
1022
|
+
renamed = true;
|
|
1023
|
+
const parentFd = openSync(path.dirname(this.filePath), "r");
|
|
1024
|
+
try {
|
|
1025
|
+
try {
|
|
1026
|
+
this.sync(parentFd);
|
|
1027
|
+
} catch (error) {
|
|
1028
|
+
if (!isUnsupportedWindowsDirectorySyncError(error)) throw error;
|
|
1029
|
+
}
|
|
1030
|
+
} finally {
|
|
1031
|
+
closeSync(parentFd);
|
|
1032
|
+
}
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
if (renamed) {
|
|
1035
|
+
this.certaintyUnknown = true;
|
|
1036
|
+
try {
|
|
1037
|
+
this.state = this.load();
|
|
1038
|
+
} catch {
|
|
1039
|
+
// The original uncertain error is the authority; later operations fail closed.
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
throw new GateStoreWriteError(renamed ? "uncertain" : "not_committed", error);
|
|
144
1043
|
}
|
|
145
|
-
renameSync(tmp, this.filePath);
|
|
146
1044
|
}
|
|
147
|
-
|
|
1045
|
+
private commit(next: FileState): void {
|
|
1046
|
+
this.flushState(next);
|
|
1047
|
+
this.state = next;
|
|
1048
|
+
}
|
|
1049
|
+
private reconcileCertainty(): void {
|
|
1050
|
+
if (!this.certaintyUnknown) return;
|
|
1051
|
+
this.state = this.load();
|
|
1052
|
+
this.certaintyUnknown = false;
|
|
1053
|
+
}
|
|
1054
|
+
nextSeq(stage: WorkflowStage): number {
|
|
1055
|
+
this.reconcileCertainty();
|
|
148
1056
|
const next = (this.state.counters[stage] ?? 0) + 1;
|
|
149
|
-
this.state
|
|
150
|
-
|
|
1057
|
+
const candidate = structuredClone(this.state);
|
|
1058
|
+
candidate.counters[stage] = next;
|
|
1059
|
+
this.commit(candidate);
|
|
151
1060
|
return next;
|
|
152
1061
|
}
|
|
153
1062
|
put(record: PersistedGate): void {
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
1063
|
+
this.reconcileCertainty();
|
|
1064
|
+
const candidate = structuredClone(this.state);
|
|
1065
|
+
candidate.gates[record.gate.gate_id] = structuredClone(record);
|
|
1066
|
+
this.commit(candidate);
|
|
156
1067
|
}
|
|
157
1068
|
get(gateId: string): PersistedGate | undefined {
|
|
1069
|
+
this.reconcileCertainty();
|
|
158
1070
|
const r = this.state.gates[gateId];
|
|
159
|
-
return r ? (
|
|
1071
|
+
return r ? structuredClone(r) : undefined;
|
|
1072
|
+
}
|
|
1073
|
+
beginRuntimeInstance(instanceId: string): void {
|
|
1074
|
+
this.reconcileCertainty();
|
|
1075
|
+
if (this.state.runtimeInstanceId === instanceId) return;
|
|
1076
|
+
const priorInstanceId = this.state.runtimeInstanceId;
|
|
1077
|
+
const next = structuredClone(this.state);
|
|
1078
|
+
const now = new Date().toISOString();
|
|
1079
|
+
for (const record of Object.values(next.gates)) {
|
|
1080
|
+
if (record.ownerInstanceId !== priorInstanceId) continue;
|
|
1081
|
+
if (record.status === "pending") {
|
|
1082
|
+
record.status = "quarantined";
|
|
1083
|
+
record.lifecycle = { state: "quarantined", reason: "orphaned_after_process_restart", quarantinedAt: now };
|
|
1084
|
+
} else if (record.status === "accepted" && !record.advanced) {
|
|
1085
|
+
record.status = "quarantined";
|
|
1086
|
+
record.lifecycle = {
|
|
1087
|
+
state: "quarantined",
|
|
1088
|
+
reason: "accepted_unadvanced_after_process_restart",
|
|
1089
|
+
quarantinedAt: now,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
next.runtimeInstanceId = instanceId;
|
|
1094
|
+
this.commit(next);
|
|
160
1095
|
}
|
|
161
1096
|
list(): PersistedGate[] {
|
|
162
|
-
|
|
1097
|
+
this.reconcileCertainty();
|
|
1098
|
+
return Object.values(this.state.gates).map(r => structuredClone(r));
|
|
163
1099
|
}
|
|
164
1100
|
}
|
|
165
1101
|
|
|
166
1102
|
export type GateAuditEvent =
|
|
167
|
-
| { event: "gate_emitted"; gate_id: string; stage:
|
|
1103
|
+
| { event: "gate_emitted"; gate_id: string; stage: WorkflowStage; kind: WorkflowGateKind }
|
|
168
1104
|
| { event: "gate_response_accepted"; gate_id: string; answer_hash: string }
|
|
169
1105
|
| { event: "gate_response_rejected"; gate_id: string; answer_hash: string }
|
|
170
1106
|
| { event: "gate_response_idempotent_replay"; gate_id: string }
|
|
@@ -175,26 +1111,44 @@ export type GateAuditEvent =
|
|
|
175
1111
|
|
|
176
1112
|
export interface BrokerHooks {
|
|
177
1113
|
/** Called once when a pending gate has been persisted and should be emitted. */
|
|
178
|
-
emit?(gate:
|
|
1114
|
+
emit?(gate: WorkflowGate): void;
|
|
179
1115
|
/**
|
|
180
1116
|
* Invoked to advance the workflow after an accepted resolution is durably
|
|
181
1117
|
* committed. MUST be idempotent keyed by `gate.gate_id`: `recover()` replays
|
|
182
1118
|
* it for any gate left `accepted` but not `advanced` by a crash.
|
|
183
1119
|
*/
|
|
184
|
-
advance?(gate:
|
|
185
|
-
/** Runs after durable acceptance and before
|
|
1120
|
+
advance?(gate: WorkflowGate, answer: unknown): void | Promise<void>;
|
|
1121
|
+
/** Runs after durable acceptance and before presentation terminalization, including crash recovery. */
|
|
186
1122
|
finalizeAccepted?(record: PersistedGate): Promise<void>;
|
|
187
|
-
|
|
1123
|
+
/**
|
|
1124
|
+
* Terminalizes the exact presentation before `advance` can run and returns
|
|
1125
|
+
* proof for the durable record. `not_published` explicitly proves that no
|
|
1126
|
+
* presentation existed. On failure it MUST revoke authority or throw.
|
|
1127
|
+
*/
|
|
1128
|
+
terminalizeAccepted?(record: PersistedGate): WorkflowGateTerminalProof | Promise<WorkflowGateTerminalProof>;
|
|
1129
|
+
/** Runs only after a successful advance has been durably marked advanced. */
|
|
1130
|
+
completeAccepted?(record: PersistedGate): void | Promise<void>;
|
|
188
1131
|
/** Append-only audit sink. */
|
|
189
1132
|
audit?(event: GateAuditEvent): void;
|
|
190
1133
|
}
|
|
191
1134
|
|
|
1135
|
+
/** Live in-process continuation authority for one emitted gate. */
|
|
1136
|
+
export interface GateContinuation {
|
|
1137
|
+
activate(gate: WorkflowGate): void;
|
|
1138
|
+
isLive(gateId: string): boolean;
|
|
1139
|
+
release?(gateId: string): void;
|
|
1140
|
+
/** Explicitly declares that this direct continuation owns no presentation. */
|
|
1141
|
+
terminalProof?: "not_published";
|
|
1142
|
+
}
|
|
1143
|
+
|
|
192
1144
|
export interface OpenGateInput {
|
|
193
|
-
stage:
|
|
194
|
-
kind:
|
|
195
|
-
schema:
|
|
196
|
-
options?:
|
|
197
|
-
context?:
|
|
1145
|
+
stage: WorkflowStage;
|
|
1146
|
+
kind: WorkflowGateKind;
|
|
1147
|
+
schema: WorkflowGate["schema"];
|
|
1148
|
+
options?: WorkflowGateOption[];
|
|
1149
|
+
context?: WorkflowGateContext;
|
|
1150
|
+
/** Optional diagnostic gate replaced by this newly persisted gate. */
|
|
1151
|
+
supersedesGateId?: string;
|
|
198
1152
|
}
|
|
199
1153
|
|
|
200
1154
|
export class WorkflowGateBrokerError extends Error {
|
|
@@ -210,6 +1164,8 @@ export class WorkflowGateBrokerError extends Error {
|
|
|
210
1164
|
export class WorkflowGateBroker {
|
|
211
1165
|
private readonly recovering = new Set<string>();
|
|
212
1166
|
private readonly gateLocks = new Map<string, Promise<void>>();
|
|
1167
|
+
private readonly continuations = new Map<string, GateContinuation>();
|
|
1168
|
+
readonly #terminalProofs = new Map<string, WorkflowGateTerminalProof>();
|
|
213
1169
|
|
|
214
1170
|
private async withGateLock<T>(gateId: string, operation: () => Promise<T>): Promise<T> {
|
|
215
1171
|
const previous = this.gateLocks.get(gateId) ?? Promise.resolve();
|
|
@@ -229,14 +1185,17 @@ export class WorkflowGateBroker {
|
|
|
229
1185
|
private readonly runId: string,
|
|
230
1186
|
private readonly store: GateStore,
|
|
231
1187
|
private readonly hooks: BrokerHooks = {},
|
|
232
|
-
|
|
1188
|
+
private readonly instanceId = crypto.randomUUID(),
|
|
1189
|
+
) {
|
|
1190
|
+
this.store.beginRuntimeInstance(this.instanceId);
|
|
1191
|
+
}
|
|
233
1192
|
|
|
234
1193
|
classifyDisposition(gateId: string, answer: unknown): PersistedSemanticDisposition {
|
|
235
1194
|
const record = this.store.get(gateId);
|
|
236
1195
|
if (!record) throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${gateId}`);
|
|
237
1196
|
return classifyAskGateDisposition(record.gate, answer);
|
|
238
1197
|
}
|
|
239
|
-
validationError(gateId: string, keyword: string, message: string):
|
|
1198
|
+
validationError(gateId: string, keyword: string, message: string): WorkflowGateValidationError {
|
|
240
1199
|
const record = this.store.get(gateId);
|
|
241
1200
|
return {
|
|
242
1201
|
code: "invalid_workflow_gate_answer",
|
|
@@ -253,12 +1212,144 @@ export class WorkflowGateBroker {
|
|
|
253
1212
|
this.store.put({ ...record, ackPolicy });
|
|
254
1213
|
}
|
|
255
1214
|
|
|
1215
|
+
async #terminalize(record: PersistedGate): Promise<void> {
|
|
1216
|
+
const proof = this.#terminalProofs.get(record.gate.gate_id) ?? (await this.hooks.terminalizeAccepted?.(record));
|
|
1217
|
+
this.#terminalProofs.delete(record.gate.gate_id);
|
|
1218
|
+
if (proof !== "retired" && proof !== "already_terminal" && proof !== "not_published")
|
|
1219
|
+
throw new Error(`workflow gate ${record.gate.gate_id} has no terminalization proof`);
|
|
1220
|
+
const terminalized = this.store.get(record.gate.gate_id);
|
|
1221
|
+
if (terminalized?.status !== "accepted" || terminalized.advanced)
|
|
1222
|
+
throw new WorkflowGateBrokerError(
|
|
1223
|
+
"unknown_gate",
|
|
1224
|
+
`accepted gate ${record.gate.gate_id} disappeared before advance`,
|
|
1225
|
+
);
|
|
1226
|
+
this.store.put({ ...terminalized, terminalized: true, terminalProof: proof });
|
|
1227
|
+
}
|
|
1228
|
+
|
|
256
1229
|
private runShort(): string {
|
|
257
1230
|
return this.runId.replace(/[^a-zA-Z0-9]/g, "").slice(-8) || "run";
|
|
258
1231
|
}
|
|
259
1232
|
|
|
1233
|
+
private hasLiveContinuation(gateId: string): boolean {
|
|
1234
|
+
return this.continuations.get(gateId)?.isLive(gateId) === true;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
hasRecoverableAcceptedGate(): boolean {
|
|
1238
|
+
return this.store
|
|
1239
|
+
.list()
|
|
1240
|
+
.some(
|
|
1241
|
+
record =>
|
|
1242
|
+
record.status === "accepted" &&
|
|
1243
|
+
!record.advanced &&
|
|
1244
|
+
record.ownerInstanceId === this.instanceId &&
|
|
1245
|
+
this.hasLiveContinuation(record.gate.gate_id),
|
|
1246
|
+
);
|
|
1247
|
+
}
|
|
1248
|
+
prepareTerminalization(gateId: string, proof: WorkflowGateTerminalProof): boolean {
|
|
1249
|
+
const record = this.store.get(gateId);
|
|
1250
|
+
if (
|
|
1251
|
+
record?.status !== "pending" ||
|
|
1252
|
+
record.ownerInstanceId !== this.instanceId ||
|
|
1253
|
+
!this.hasLiveContinuation(gateId)
|
|
1254
|
+
)
|
|
1255
|
+
return false;
|
|
1256
|
+
this.#terminalProofs.set(gateId, proof);
|
|
1257
|
+
return true;
|
|
1258
|
+
}
|
|
1259
|
+
clearPreparedTerminalization(gateId: string): void {
|
|
1260
|
+
this.#terminalProofs.delete(gateId);
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Fences a gate after an uncertain control outcome by deliberately releasing
|
|
1265
|
+
* this runtime's continuation authority. The durable diagnostic is retained
|
|
1266
|
+
* for recovery; it is never republished as a pending gate.
|
|
1267
|
+
*/
|
|
1268
|
+
quarantineGate(gateId: string): void {
|
|
1269
|
+
this.loseContinuation(gateId);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** Revoke every live continuation, including accepted-but-unadvanced recovery authority. */
|
|
1273
|
+
fenceContinuations(): void {
|
|
1274
|
+
for (const gateId of [...this.continuations.keys()]) this.loseContinuation(gateId);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** Fence a cancelled/lost continuation before it can be exposed or advanced. */
|
|
1278
|
+
loseContinuation(gateId: string): void {
|
|
1279
|
+
const continuation = this.continuations.get(gateId);
|
|
1280
|
+
continuation?.release?.(gateId);
|
|
1281
|
+
this.continuations.delete(gateId);
|
|
1282
|
+
this.#terminalProofs.delete(gateId);
|
|
1283
|
+
const record = this.store.get(gateId);
|
|
1284
|
+
if (
|
|
1285
|
+
record?.ownerInstanceId !== this.instanceId ||
|
|
1286
|
+
record.status === "quarantined" ||
|
|
1287
|
+
(record.status === "accepted" && record.advanced)
|
|
1288
|
+
)
|
|
1289
|
+
return;
|
|
1290
|
+
this.store.put({
|
|
1291
|
+
...record,
|
|
1292
|
+
status: "quarantined",
|
|
1293
|
+
advanced: false,
|
|
1294
|
+
lifecycle: {
|
|
1295
|
+
state: "quarantined",
|
|
1296
|
+
reason: "continuation_owner_lost",
|
|
1297
|
+
quarantinedAt: new Date().toISOString(),
|
|
1298
|
+
},
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
listPendingGates(): WorkflowGate[] {
|
|
1303
|
+
return this.store
|
|
1304
|
+
.list()
|
|
1305
|
+
.filter(
|
|
1306
|
+
record =>
|
|
1307
|
+
record.status === "pending" &&
|
|
1308
|
+
record.ownerInstanceId === this.instanceId &&
|
|
1309
|
+
this.hasLiveContinuation(record.gate.gate_id),
|
|
1310
|
+
)
|
|
1311
|
+
.sort(compareGates)
|
|
1312
|
+
.map(record => record.gate);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
listGateDiagnostics(): WorkflowGateDiagnostic[] {
|
|
1316
|
+
return this.store
|
|
1317
|
+
.list()
|
|
1318
|
+
.filter(
|
|
1319
|
+
(
|
|
1320
|
+
record,
|
|
1321
|
+
): record is PersistedGate & { status: "quarantined"; lifecycle: WorkflowGateDiagnostic["lifecycle"] } =>
|
|
1322
|
+
record.status === "quarantined" && record.lifecycle !== undefined,
|
|
1323
|
+
)
|
|
1324
|
+
.sort(
|
|
1325
|
+
(left, right) =>
|
|
1326
|
+
left.lifecycle.quarantinedAt.localeCompare(right.lifecycle.quarantinedAt) ||
|
|
1327
|
+
left.gate.gate_id.localeCompare(right.gate.gate_id),
|
|
1328
|
+
)
|
|
1329
|
+
.map(record => ({
|
|
1330
|
+
...record.gate,
|
|
1331
|
+
id: `diagnostic:${record.gate.gate_id}`,
|
|
1332
|
+
tag: "quarantined",
|
|
1333
|
+
lifecycle: record.lifecycle,
|
|
1334
|
+
}));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
listWorkflowGateQueryRecords(): WorkflowGateQueryRecord[] {
|
|
1338
|
+
const pending: WorkflowGateQueryRecord[] = this.store
|
|
1339
|
+
.list()
|
|
1340
|
+
.filter(
|
|
1341
|
+
record =>
|
|
1342
|
+
record.status === "pending" &&
|
|
1343
|
+
record.ownerInstanceId === this.instanceId &&
|
|
1344
|
+
this.hasLiveContinuation(record.gate.gate_id),
|
|
1345
|
+
)
|
|
1346
|
+
.sort(compareGates)
|
|
1347
|
+
.map(record => ({ ...record.gate, id: `pending:${record.gate.gate_id}`, tag: "pending" }));
|
|
1348
|
+
return [...pending, ...this.listGateDiagnostics()];
|
|
1349
|
+
}
|
|
1350
|
+
|
|
260
1351
|
/** Open and emit a gate. The pending record is persisted BEFORE emission. */
|
|
261
|
-
openGate(input: OpenGateInput):
|
|
1352
|
+
openGate(input: OpenGateInput, continuation?: GateContinuation): WorkflowGate {
|
|
262
1353
|
if (RESERVED_WORKFLOW_STAGES.includes(input.stage) || !V1_STAGES.includes(input.stage)) {
|
|
263
1354
|
throw new WorkflowGateBrokerError(
|
|
264
1355
|
"invalid_workflow_stage",
|
|
@@ -269,7 +1360,7 @@ export class WorkflowGateBroker {
|
|
|
269
1360
|
compileGateSchema(input.schema);
|
|
270
1361
|
const seq = this.store.nextSeq(input.stage).toString().padStart(6, "0");
|
|
271
1362
|
const gateId = `wg_${this.runShort()}_${input.stage}_${seq}`;
|
|
272
|
-
const gate:
|
|
1363
|
+
const gate: WorkflowGate = {
|
|
273
1364
|
type: "workflow_gate",
|
|
274
1365
|
gate_id: gateId,
|
|
275
1366
|
stage: input.stage,
|
|
@@ -281,8 +1372,30 @@ export class WorkflowGateBroker {
|
|
|
281
1372
|
created_at: new Date().toISOString(),
|
|
282
1373
|
required: true,
|
|
283
1374
|
};
|
|
284
|
-
|
|
285
|
-
|
|
1375
|
+
if (!continuation) {
|
|
1376
|
+
this.store.put({
|
|
1377
|
+
gate,
|
|
1378
|
+
status: "quarantined",
|
|
1379
|
+
ownerInstanceId: this.instanceId,
|
|
1380
|
+
advanced: false,
|
|
1381
|
+
lifecycle: {
|
|
1382
|
+
state: "quarantined",
|
|
1383
|
+
reason: "opened_without_continuation",
|
|
1384
|
+
quarantinedAt: new Date().toISOString(),
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
return gate;
|
|
1388
|
+
}
|
|
1389
|
+
this.store.put({ gate, status: "pending", ownerInstanceId: this.instanceId, advanced: false });
|
|
1390
|
+
continuation.activate(gate);
|
|
1391
|
+
if (continuation.terminalProof === "not_published") this.#terminalProofs.set(gateId, "not_published");
|
|
1392
|
+
this.continuations.set(gateId, continuation);
|
|
1393
|
+
if (input.supersedesGateId) {
|
|
1394
|
+
const superseded = this.store.get(input.supersedesGateId);
|
|
1395
|
+
if (superseded?.status === "quarantined" && superseded.lifecycle) {
|
|
1396
|
+
this.store.put({ ...superseded, lifecycle: { ...superseded.lifecycle, supersededByGateId: gateId } });
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
286
1399
|
this.hooks.emit?.(gate);
|
|
287
1400
|
this.hooks.audit?.({ event: "gate_emitted", gate_id: gateId, stage: gate.stage, kind: gate.kind });
|
|
288
1401
|
return gate;
|
|
@@ -290,33 +1403,41 @@ export class WorkflowGateBroker {
|
|
|
290
1403
|
|
|
291
1404
|
/**
|
|
292
1405
|
* Resolve a gate with an answer. Validates against the advertised schema.
|
|
293
|
-
*
|
|
294
|
-
*
|
|
1406
|
+
* Accepted answers are persisted first, then their presentation is terminalized,
|
|
1407
|
+
* then the workflow advances and commits `advanced:true` exactly once. Invalid
|
|
1408
|
+
* answers leave the gate pending (per #315 acceptance).
|
|
295
1409
|
*/
|
|
296
|
-
async resolve(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
1410
|
+
async resolve(response: WorkflowGateResponse, options: GateResolutionOptions = {}): Promise<WorkflowGateResolution> {
|
|
1411
|
+
try {
|
|
1412
|
+
return await this.withGateLock(response.gate_id, () => this.resolveUnlocked(response, options));
|
|
1413
|
+
} catch (error) {
|
|
1414
|
+
if (error instanceof GateStoreWriteError && error.certainty === "uncertain") {
|
|
1415
|
+
try {
|
|
1416
|
+
this.quarantineGate(response.gate_id);
|
|
1417
|
+
} catch {
|
|
1418
|
+
// Continuation authority was still released; preserve the uncertain write error.
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
throw error;
|
|
1422
|
+
}
|
|
301
1423
|
}
|
|
302
1424
|
|
|
303
1425
|
private async resolveUnlocked(
|
|
304
|
-
response:
|
|
1426
|
+
response: WorkflowGateResponse,
|
|
305
1427
|
options: GateResolutionOptions = {},
|
|
306
|
-
): Promise<
|
|
1428
|
+
): Promise<WorkflowGateResolution> {
|
|
307
1429
|
const record = this.store.get(response.gate_id);
|
|
308
|
-
if (!record) {
|
|
1430
|
+
if (!record || record.status === "quarantined") {
|
|
309
1431
|
this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
|
|
310
|
-
throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${response.gate_id}`);
|
|
1432
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no live pending gate ${response.gate_id}`);
|
|
311
1433
|
}
|
|
312
1434
|
const responseHash = answerHashOf({ gate_id: response.gate_id, answer: response.answer });
|
|
313
|
-
|
|
314
1435
|
if (record.status === "accepted") {
|
|
315
1436
|
const sameBody = record.responseHash === responseHash;
|
|
316
1437
|
const sameKey = record.idempotencyKey === response.idempotency_key;
|
|
317
1438
|
if (response.idempotency_key !== undefined && sameKey && sameBody) {
|
|
318
1439
|
this.hooks.audit?.({ event: "gate_response_idempotent_replay", gate_id: response.gate_id });
|
|
319
|
-
return record.resolution as
|
|
1440
|
+
return record.resolution as WorkflowGateResolution;
|
|
320
1441
|
}
|
|
321
1442
|
if (response.idempotency_key !== undefined && sameKey && !sameBody) {
|
|
322
1443
|
this.hooks.audit?.({ event: "gate_response_idempotency_conflict", gate_id: response.gate_id });
|
|
@@ -328,6 +1449,10 @@ export class WorkflowGateBroker {
|
|
|
328
1449
|
this.hooks.audit?.({ event: "gate_response_already_resolved", gate_id: response.gate_id });
|
|
329
1450
|
throw new WorkflowGateBrokerError("already_resolved", `already_resolved: gate ${response.gate_id}`);
|
|
330
1451
|
}
|
|
1452
|
+
if (record.ownerInstanceId !== this.instanceId || !this.hasLiveContinuation(response.gate_id)) {
|
|
1453
|
+
this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
|
|
1454
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no live pending gate ${response.gate_id}`);
|
|
1455
|
+
}
|
|
331
1456
|
|
|
332
1457
|
const compiled = compileGateSchema(record.gate.schema);
|
|
333
1458
|
const validationError = validateGateAnswer(compiled, response.gate_id, response.answer);
|
|
@@ -346,12 +1471,12 @@ export class WorkflowGateBroker {
|
|
|
346
1471
|
|
|
347
1472
|
const semanticDisposition =
|
|
348
1473
|
options.semanticDisposition ?? classifyAskGateDisposition(record.gate, response.answer);
|
|
349
|
-
const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "
|
|
1474
|
+
const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "sdk" as const };
|
|
350
1475
|
const ackPolicy =
|
|
351
1476
|
options.ackPolicy ??
|
|
352
1477
|
({ kind: "none", reason: semanticDisposition === "commit" ? "non_telegram" : "semantic_noncommit" } as const);
|
|
353
1478
|
|
|
354
|
-
const resolution:
|
|
1479
|
+
const resolution: WorkflowGateResolution = {
|
|
355
1480
|
gate_id: response.gate_id,
|
|
356
1481
|
status: "accepted",
|
|
357
1482
|
answer_hash: answerHash,
|
|
@@ -363,11 +1488,13 @@ export class WorkflowGateBroker {
|
|
|
363
1488
|
this.store.put({
|
|
364
1489
|
gate: record.gate,
|
|
365
1490
|
status: "accepted",
|
|
1491
|
+
ownerInstanceId: record.ownerInstanceId,
|
|
366
1492
|
idempotencyKey: response.idempotency_key,
|
|
367
1493
|
responseHash,
|
|
368
1494
|
answer: response.answer,
|
|
369
1495
|
resolution,
|
|
370
1496
|
advanced: false,
|
|
1497
|
+
terminalized: false,
|
|
371
1498
|
semanticDisposition,
|
|
372
1499
|
resolutionOrigin,
|
|
373
1500
|
ackPolicy,
|
|
@@ -379,19 +1506,32 @@ export class WorkflowGateBroker {
|
|
|
379
1506
|
if (finalized?.status !== "accepted") {
|
|
380
1507
|
throw new WorkflowGateBrokerError(
|
|
381
1508
|
"unknown_gate",
|
|
382
|
-
`accepted gate ${response.gate_id} disappeared before
|
|
1509
|
+
`accepted gate ${response.gate_id} disappeared before terminalization`,
|
|
383
1510
|
);
|
|
384
1511
|
}
|
|
385
1512
|
if (finalized.advanced) return finalized.resolution ?? resolution;
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
1513
|
+
if (!finalized.terminalized) await this.#terminalize(finalized);
|
|
1514
|
+
const terminalized = this.store.get(response.gate_id);
|
|
1515
|
+
if (terminalized?.status !== "accepted" || terminalized.advanced) {
|
|
389
1516
|
throw new WorkflowGateBrokerError(
|
|
390
1517
|
"unknown_gate",
|
|
391
|
-
`accepted gate ${response.gate_id} disappeared
|
|
1518
|
+
`accepted gate ${response.gate_id} disappeared before advance`,
|
|
392
1519
|
);
|
|
393
1520
|
}
|
|
1521
|
+
if (!this.hooks.advance || !this.hasLiveContinuation(response.gate_id)) {
|
|
1522
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no live continuation for gate ${response.gate_id}`);
|
|
1523
|
+
}
|
|
1524
|
+
await this.hooks.advance(terminalized.gate, terminalized.answer);
|
|
1525
|
+
const latest = this.store.get(response.gate_id);
|
|
1526
|
+
if (latest?.status !== "accepted" || !this.hasLiveContinuation(response.gate_id))
|
|
1527
|
+
throw new WorkflowGateBrokerError(
|
|
1528
|
+
"unknown_gate",
|
|
1529
|
+
`accepted gate ${response.gate_id} lost its continuation before advance completed`,
|
|
1530
|
+
);
|
|
394
1531
|
this.store.put({ ...latest, advanced: true });
|
|
1532
|
+
await this.hooks.completeAccepted?.(this.store.get(response.gate_id) as PersistedGate);
|
|
1533
|
+
this.continuations.get(response.gate_id)?.release?.(response.gate_id);
|
|
1534
|
+
this.continuations.delete(response.gate_id);
|
|
395
1535
|
return resolution;
|
|
396
1536
|
}
|
|
397
1537
|
|
|
@@ -404,7 +1544,14 @@ export class WorkflowGateBroker {
|
|
|
404
1544
|
async recover(): Promise<string[]> {
|
|
405
1545
|
const recovered: string[] = [];
|
|
406
1546
|
for (const listed of this.store.list()) {
|
|
407
|
-
if (
|
|
1547
|
+
if (
|
|
1548
|
+
listed.status !== "accepted" ||
|
|
1549
|
+
listed.advanced ||
|
|
1550
|
+
listed.ownerInstanceId !== this.instanceId ||
|
|
1551
|
+
!this.hasLiveContinuation(listed.gate.gate_id) ||
|
|
1552
|
+
this.recovering.has(listed.gate.gate_id)
|
|
1553
|
+
)
|
|
1554
|
+
continue;
|
|
408
1555
|
this.recovering.add(listed.gate.gate_id);
|
|
409
1556
|
try {
|
|
410
1557
|
await this.withGateLock(listed.gate.gate_id, async () => {
|
|
@@ -412,11 +1559,29 @@ export class WorkflowGateBroker {
|
|
|
412
1559
|
if (rec?.status !== "accepted" || rec.advanced) return;
|
|
413
1560
|
await this.hooks.finalizeAccepted?.(rec);
|
|
414
1561
|
const finalized = this.store.get(listed.gate.gate_id);
|
|
415
|
-
if (
|
|
416
|
-
|
|
1562
|
+
if (
|
|
1563
|
+
finalized?.status !== "accepted" ||
|
|
1564
|
+
finalized.advanced ||
|
|
1565
|
+
!this.hasLiveContinuation(listed.gate.gate_id)
|
|
1566
|
+
)
|
|
1567
|
+
return;
|
|
1568
|
+
if (!finalized.terminalized) await this.#terminalize(finalized);
|
|
1569
|
+
const terminalized = this.store.get(listed.gate.gate_id);
|
|
1570
|
+
if (
|
|
1571
|
+
terminalized?.status !== "accepted" ||
|
|
1572
|
+
terminalized.advanced ||
|
|
1573
|
+
!this.hooks.advance ||
|
|
1574
|
+
!this.hasLiveContinuation(listed.gate.gate_id)
|
|
1575
|
+
)
|
|
1576
|
+
return;
|
|
1577
|
+
await this.hooks.advance(terminalized.gate, terminalized.answer);
|
|
417
1578
|
const latest = this.store.get(listed.gate.gate_id);
|
|
418
|
-
if (latest?.status !== "accepted" || latest.advanced)
|
|
1579
|
+
if (latest?.status !== "accepted" || latest.advanced || !this.hasLiveContinuation(listed.gate.gate_id))
|
|
1580
|
+
return;
|
|
419
1581
|
this.store.put({ ...latest, advanced: true });
|
|
1582
|
+
await this.hooks.completeAccepted?.(this.store.get(latest.gate.gate_id) as PersistedGate);
|
|
1583
|
+
this.continuations.get(latest.gate.gate_id)?.release?.(latest.gate.gate_id);
|
|
1584
|
+
this.continuations.delete(latest.gate.gate_id);
|
|
420
1585
|
this.hooks.audit?.({ event: "gate_advance_recovered", gate_id: latest.gate.gate_id });
|
|
421
1586
|
recovered.push(latest.gate.gate_id);
|
|
422
1587
|
});
|
|
@@ -432,3 +1597,9 @@ export class WorkflowGateBroker {
|
|
|
432
1597
|
return canonicalJson(value);
|
|
433
1598
|
}
|
|
434
1599
|
}
|
|
1600
|
+
|
|
1601
|
+
function compareGates(left: PersistedGate, right: PersistedGate): number {
|
|
1602
|
+
return (
|
|
1603
|
+
left.gate.created_at.localeCompare(right.gate.created_at) || left.gate.gate_id.localeCompare(right.gate.gate_id)
|
|
1604
|
+
);
|
|
1605
|
+
}
|