@gajae-code/coding-agent 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -2
- package/dist/types/append-only-mode.d.ts +1 -1
- 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/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/gajae-pet-widget.d.ts +1 -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/pet-capability.d.ts +37 -0
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/settings-selector.d.ts +15 -4
- package/dist/types/modes/components/welcome.d.ts +1 -1
- 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 -1
- 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 +169 -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 +16 -1
- 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 +33 -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 +45 -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 +55 -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/package.json +34 -13
- package/scripts/build-sdk-package-smoke.ts +94 -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/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 +37 -34
- 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/extensibility/slash-commands.ts +3 -3
- 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 +5 -2
- package/src/gjc-runtime/tmux-sessions.ts +19 -13
- package/src/gjc-runtime/ultragoal-guard.ts +53 -6
- package/src/gjc-runtime/ultragoal-runtime.ts +155 -3
- 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 +36 -37
- 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/custom-editor.ts +1 -0
- package/src/modes/components/gajae-pet-widget.ts +169 -20
- package/src/modes/components/model-selector.ts +93 -35
- package/src/modes/components/notifications-settings-editor.ts +1379 -0
- package/src/modes/components/pet-capability.ts +115 -0
- package/src/modes/components/pet-selector.ts +4 -6
- package/src/modes/components/settings-selector.ts +86 -11
- package/src/modes/components/welcome.ts +4 -4
- package/src/modes/controllers/command-controller.ts +1 -2
- package/src/modes/controllers/event-controller.ts +11 -16
- package/src/modes/controllers/extension-ui-controller.ts +357 -12
- package/src/modes/controllers/input-controller.ts +43 -13
- package/src/modes/controllers/selector-controller.ts +671 -44
- package/src/modes/index.ts +0 -22
- package/src/modes/interactive-mode.ts +108 -24
- 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 +1254 -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 +16 -1
- 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 +94 -0
- package/src/sdk/broker/ensure.ts +249 -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 +353 -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 +2275 -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 +3943 -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 +502 -54
- 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 +2470 -866
- package/src/session/default-model-selection.ts +93 -0
- package/src/session/fallback-chain-controller.ts +136 -0
- package/src/session/session-manager.ts +447 -13
- 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 +33 -30
- package/src/task/executor.ts +62 -13
- 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/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,455 @@ 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
|
+
function isUnsupportedWindowsDirectorySyncError(error: unknown): boolean {
|
|
940
|
+
if (process.platform !== "win32") return false;
|
|
941
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
942
|
+
return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP";
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
/** A write may have reached `rename` but not the directory fsync durability barrier. */
|
|
946
|
+
export class GateStoreWriteError extends Error {
|
|
947
|
+
constructor(
|
|
948
|
+
readonly certainty: "not_committed" | "uncertain",
|
|
949
|
+
cause: unknown,
|
|
950
|
+
) {
|
|
951
|
+
super(`gate store write ${certainty}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
952
|
+
this.name = "GateStoreWriteError";
|
|
953
|
+
}
|
|
102
954
|
}
|
|
103
955
|
|
|
104
956
|
/** Crash-durable JSON-file backed store. Writes the full state on every mutation. */
|
|
105
957
|
export class FileGateStore implements GateStore {
|
|
106
958
|
private state: FileState;
|
|
107
|
-
|
|
108
|
-
|
|
959
|
+
private certaintyUnknown = false;
|
|
960
|
+
constructor(
|
|
961
|
+
private readonly filePath: string,
|
|
962
|
+
private readonly sync: (fd: number) => void = fsyncSync,
|
|
963
|
+
) {
|
|
964
|
+
// Load eagerly so a corrupt store fails closed at construction, but defer
|
|
965
|
+
// directory creation to the first write so constructing a store under a
|
|
966
|
+
// non-writable cwd (e.g. a session that never emits a gate) never throws.
|
|
109
967
|
this.state = this.load();
|
|
110
968
|
}
|
|
111
969
|
private load(): FileState {
|
|
@@ -113,14 +971,26 @@ export class FileGateStore implements GateStore {
|
|
|
113
971
|
try {
|
|
114
972
|
raw = readFileSync(this.filePath, "utf8");
|
|
115
973
|
} catch (err) {
|
|
116
|
-
if ((err as NodeJS.ErrnoException).code === "ENOENT")
|
|
974
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT")
|
|
975
|
+
return { version: FILE_GATE_STORE_VERSION, counters: {}, gates: {} };
|
|
117
976
|
throw err;
|
|
118
977
|
}
|
|
978
|
+
let parsed: unknown;
|
|
119
979
|
try {
|
|
120
|
-
|
|
980
|
+
parsed = JSON.parse(raw);
|
|
981
|
+
if (isObject(parsed) && !Object.hasOwn(parsed, "version")) {
|
|
982
|
+
const migrated = migrateVersionlessV0(parsed, this.filePath);
|
|
983
|
+
// Persist the v1 envelope before beginRuntimeInstance can quarantine or
|
|
984
|
+
// expose any record. This preserves all v0 records atomically.
|
|
985
|
+
this.flushState(migrated);
|
|
986
|
+
return migrated;
|
|
987
|
+
}
|
|
988
|
+
assertFileState(parsed, this.filePath);
|
|
989
|
+
return parsed;
|
|
121
990
|
} catch (err) {
|
|
122
|
-
|
|
123
|
-
//
|
|
991
|
+
if (err instanceof GateStoreWriteError) throw err;
|
|
992
|
+
// Fail closed: invalid state must not be exposed or mutated because it can
|
|
993
|
+
// drop counters/gates and risk gate-id reuse.
|
|
124
994
|
const quarantine = `${this.filePath}.corrupt-${Date.now()}`;
|
|
125
995
|
try {
|
|
126
996
|
renameSync(this.filePath, quarantine);
|
|
@@ -128,43 +998,105 @@ export class FileGateStore implements GateStore {
|
|
|
128
998
|
/* best-effort quarantine */
|
|
129
999
|
}
|
|
130
1000
|
throw new Error(
|
|
131
|
-
`corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${
|
|
1001
|
+
`corrupt gate store at ${this.filePath} (quarantined to ${quarantine}): ${err instanceof Error ? err.message : String(err)}`,
|
|
132
1002
|
);
|
|
133
1003
|
}
|
|
134
1004
|
}
|
|
135
|
-
private
|
|
136
|
-
|
|
1005
|
+
private flushState(next: FileState): void {
|
|
1006
|
+
mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
137
1007
|
const tmp = `${this.filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
138
|
-
|
|
1008
|
+
let renamed = false;
|
|
139
1009
|
try {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
1010
|
+
const fd = openSync(tmp, "w");
|
|
1011
|
+
try {
|
|
1012
|
+
writeFileSync(fd, JSON.stringify(next, null, 2));
|
|
1013
|
+
this.sync(fd);
|
|
1014
|
+
} finally {
|
|
1015
|
+
closeSync(fd);
|
|
1016
|
+
}
|
|
1017
|
+
renameSync(tmp, this.filePath);
|
|
1018
|
+
renamed = true;
|
|
1019
|
+
const parentFd = openSync(path.dirname(this.filePath), "r");
|
|
1020
|
+
try {
|
|
1021
|
+
try {
|
|
1022
|
+
this.sync(parentFd);
|
|
1023
|
+
} catch (error) {
|
|
1024
|
+
if (!isUnsupportedWindowsDirectorySyncError(error)) throw error;
|
|
1025
|
+
}
|
|
1026
|
+
} finally {
|
|
1027
|
+
closeSync(parentFd);
|
|
1028
|
+
}
|
|
1029
|
+
} catch (error) {
|
|
1030
|
+
if (renamed) {
|
|
1031
|
+
this.certaintyUnknown = true;
|
|
1032
|
+
try {
|
|
1033
|
+
this.state = this.load();
|
|
1034
|
+
} catch {
|
|
1035
|
+
// The original uncertain error is the authority; later operations fail closed.
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
throw new GateStoreWriteError(renamed ? "uncertain" : "not_committed", error);
|
|
144
1039
|
}
|
|
145
|
-
renameSync(tmp, this.filePath);
|
|
146
1040
|
}
|
|
147
|
-
|
|
1041
|
+
private commit(next: FileState): void {
|
|
1042
|
+
this.flushState(next);
|
|
1043
|
+
this.state = next;
|
|
1044
|
+
}
|
|
1045
|
+
private reconcileCertainty(): void {
|
|
1046
|
+
if (!this.certaintyUnknown) return;
|
|
1047
|
+
this.state = this.load();
|
|
1048
|
+
this.certaintyUnknown = false;
|
|
1049
|
+
}
|
|
1050
|
+
nextSeq(stage: WorkflowStage): number {
|
|
1051
|
+
this.reconcileCertainty();
|
|
148
1052
|
const next = (this.state.counters[stage] ?? 0) + 1;
|
|
149
|
-
this.state
|
|
150
|
-
|
|
1053
|
+
const candidate = structuredClone(this.state);
|
|
1054
|
+
candidate.counters[stage] = next;
|
|
1055
|
+
this.commit(candidate);
|
|
151
1056
|
return next;
|
|
152
1057
|
}
|
|
153
1058
|
put(record: PersistedGate): void {
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
1059
|
+
this.reconcileCertainty();
|
|
1060
|
+
const candidate = structuredClone(this.state);
|
|
1061
|
+
candidate.gates[record.gate.gate_id] = structuredClone(record);
|
|
1062
|
+
this.commit(candidate);
|
|
156
1063
|
}
|
|
157
1064
|
get(gateId: string): PersistedGate | undefined {
|
|
1065
|
+
this.reconcileCertainty();
|
|
158
1066
|
const r = this.state.gates[gateId];
|
|
159
|
-
return r ? (
|
|
1067
|
+
return r ? structuredClone(r) : undefined;
|
|
1068
|
+
}
|
|
1069
|
+
beginRuntimeInstance(instanceId: string): void {
|
|
1070
|
+
this.reconcileCertainty();
|
|
1071
|
+
if (this.state.runtimeInstanceId === instanceId) return;
|
|
1072
|
+
const priorInstanceId = this.state.runtimeInstanceId;
|
|
1073
|
+
const next = structuredClone(this.state);
|
|
1074
|
+
const now = new Date().toISOString();
|
|
1075
|
+
for (const record of Object.values(next.gates)) {
|
|
1076
|
+
if (record.ownerInstanceId !== priorInstanceId) continue;
|
|
1077
|
+
if (record.status === "pending") {
|
|
1078
|
+
record.status = "quarantined";
|
|
1079
|
+
record.lifecycle = { state: "quarantined", reason: "orphaned_after_process_restart", quarantinedAt: now };
|
|
1080
|
+
} else if (record.status === "accepted" && !record.advanced) {
|
|
1081
|
+
record.status = "quarantined";
|
|
1082
|
+
record.lifecycle = {
|
|
1083
|
+
state: "quarantined",
|
|
1084
|
+
reason: "accepted_unadvanced_after_process_restart",
|
|
1085
|
+
quarantinedAt: now,
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
next.runtimeInstanceId = instanceId;
|
|
1090
|
+
this.commit(next);
|
|
160
1091
|
}
|
|
161
1092
|
list(): PersistedGate[] {
|
|
162
|
-
|
|
1093
|
+
this.reconcileCertainty();
|
|
1094
|
+
return Object.values(this.state.gates).map(r => structuredClone(r));
|
|
163
1095
|
}
|
|
164
1096
|
}
|
|
165
1097
|
|
|
166
1098
|
export type GateAuditEvent =
|
|
167
|
-
| { event: "gate_emitted"; gate_id: string; stage:
|
|
1099
|
+
| { event: "gate_emitted"; gate_id: string; stage: WorkflowStage; kind: WorkflowGateKind }
|
|
168
1100
|
| { event: "gate_response_accepted"; gate_id: string; answer_hash: string }
|
|
169
1101
|
| { event: "gate_response_rejected"; gate_id: string; answer_hash: string }
|
|
170
1102
|
| { event: "gate_response_idempotent_replay"; gate_id: string }
|
|
@@ -175,26 +1107,44 @@ export type GateAuditEvent =
|
|
|
175
1107
|
|
|
176
1108
|
export interface BrokerHooks {
|
|
177
1109
|
/** Called once when a pending gate has been persisted and should be emitted. */
|
|
178
|
-
emit?(gate:
|
|
1110
|
+
emit?(gate: WorkflowGate): void;
|
|
179
1111
|
/**
|
|
180
1112
|
* Invoked to advance the workflow after an accepted resolution is durably
|
|
181
1113
|
* committed. MUST be idempotent keyed by `gate.gate_id`: `recover()` replays
|
|
182
1114
|
* it for any gate left `accepted` but not `advanced` by a crash.
|
|
183
1115
|
*/
|
|
184
|
-
advance?(gate:
|
|
185
|
-
/** Runs after durable acceptance and before
|
|
1116
|
+
advance?(gate: WorkflowGate, answer: unknown): void | Promise<void>;
|
|
1117
|
+
/** Runs after durable acceptance and before presentation terminalization, including crash recovery. */
|
|
186
1118
|
finalizeAccepted?(record: PersistedGate): Promise<void>;
|
|
187
|
-
|
|
1119
|
+
/**
|
|
1120
|
+
* Terminalizes the exact presentation before `advance` can run and returns
|
|
1121
|
+
* proof for the durable record. `not_published` explicitly proves that no
|
|
1122
|
+
* presentation existed. On failure it MUST revoke authority or throw.
|
|
1123
|
+
*/
|
|
1124
|
+
terminalizeAccepted?(record: PersistedGate): WorkflowGateTerminalProof | Promise<WorkflowGateTerminalProof>;
|
|
1125
|
+
/** Runs only after a successful advance has been durably marked advanced. */
|
|
1126
|
+
completeAccepted?(record: PersistedGate): void | Promise<void>;
|
|
188
1127
|
/** Append-only audit sink. */
|
|
189
1128
|
audit?(event: GateAuditEvent): void;
|
|
190
1129
|
}
|
|
191
1130
|
|
|
1131
|
+
/** Live in-process continuation authority for one emitted gate. */
|
|
1132
|
+
export interface GateContinuation {
|
|
1133
|
+
activate(gate: WorkflowGate): void;
|
|
1134
|
+
isLive(gateId: string): boolean;
|
|
1135
|
+
release?(gateId: string): void;
|
|
1136
|
+
/** Explicitly declares that this direct continuation owns no presentation. */
|
|
1137
|
+
terminalProof?: "not_published";
|
|
1138
|
+
}
|
|
1139
|
+
|
|
192
1140
|
export interface OpenGateInput {
|
|
193
|
-
stage:
|
|
194
|
-
kind:
|
|
195
|
-
schema:
|
|
196
|
-
options?:
|
|
197
|
-
context?:
|
|
1141
|
+
stage: WorkflowStage;
|
|
1142
|
+
kind: WorkflowGateKind;
|
|
1143
|
+
schema: WorkflowGate["schema"];
|
|
1144
|
+
options?: WorkflowGateOption[];
|
|
1145
|
+
context?: WorkflowGateContext;
|
|
1146
|
+
/** Optional diagnostic gate replaced by this newly persisted gate. */
|
|
1147
|
+
supersedesGateId?: string;
|
|
198
1148
|
}
|
|
199
1149
|
|
|
200
1150
|
export class WorkflowGateBrokerError extends Error {
|
|
@@ -210,6 +1160,8 @@ export class WorkflowGateBrokerError extends Error {
|
|
|
210
1160
|
export class WorkflowGateBroker {
|
|
211
1161
|
private readonly recovering = new Set<string>();
|
|
212
1162
|
private readonly gateLocks = new Map<string, Promise<void>>();
|
|
1163
|
+
private readonly continuations = new Map<string, GateContinuation>();
|
|
1164
|
+
readonly #terminalProofs = new Map<string, WorkflowGateTerminalProof>();
|
|
213
1165
|
|
|
214
1166
|
private async withGateLock<T>(gateId: string, operation: () => Promise<T>): Promise<T> {
|
|
215
1167
|
const previous = this.gateLocks.get(gateId) ?? Promise.resolve();
|
|
@@ -229,14 +1181,17 @@ export class WorkflowGateBroker {
|
|
|
229
1181
|
private readonly runId: string,
|
|
230
1182
|
private readonly store: GateStore,
|
|
231
1183
|
private readonly hooks: BrokerHooks = {},
|
|
232
|
-
|
|
1184
|
+
private readonly instanceId = crypto.randomUUID(),
|
|
1185
|
+
) {
|
|
1186
|
+
this.store.beginRuntimeInstance(this.instanceId);
|
|
1187
|
+
}
|
|
233
1188
|
|
|
234
1189
|
classifyDisposition(gateId: string, answer: unknown): PersistedSemanticDisposition {
|
|
235
1190
|
const record = this.store.get(gateId);
|
|
236
1191
|
if (!record) throw new WorkflowGateBrokerError("unknown_gate", `no pending gate ${gateId}`);
|
|
237
1192
|
return classifyAskGateDisposition(record.gate, answer);
|
|
238
1193
|
}
|
|
239
|
-
validationError(gateId: string, keyword: string, message: string):
|
|
1194
|
+
validationError(gateId: string, keyword: string, message: string): WorkflowGateValidationError {
|
|
240
1195
|
const record = this.store.get(gateId);
|
|
241
1196
|
return {
|
|
242
1197
|
code: "invalid_workflow_gate_answer",
|
|
@@ -253,12 +1208,144 @@ export class WorkflowGateBroker {
|
|
|
253
1208
|
this.store.put({ ...record, ackPolicy });
|
|
254
1209
|
}
|
|
255
1210
|
|
|
1211
|
+
async #terminalize(record: PersistedGate): Promise<void> {
|
|
1212
|
+
const proof = this.#terminalProofs.get(record.gate.gate_id) ?? (await this.hooks.terminalizeAccepted?.(record));
|
|
1213
|
+
this.#terminalProofs.delete(record.gate.gate_id);
|
|
1214
|
+
if (proof !== "retired" && proof !== "already_terminal" && proof !== "not_published")
|
|
1215
|
+
throw new Error(`workflow gate ${record.gate.gate_id} has no terminalization proof`);
|
|
1216
|
+
const terminalized = this.store.get(record.gate.gate_id);
|
|
1217
|
+
if (terminalized?.status !== "accepted" || terminalized.advanced)
|
|
1218
|
+
throw new WorkflowGateBrokerError(
|
|
1219
|
+
"unknown_gate",
|
|
1220
|
+
`accepted gate ${record.gate.gate_id} disappeared before advance`,
|
|
1221
|
+
);
|
|
1222
|
+
this.store.put({ ...terminalized, terminalized: true, terminalProof: proof });
|
|
1223
|
+
}
|
|
1224
|
+
|
|
256
1225
|
private runShort(): string {
|
|
257
1226
|
return this.runId.replace(/[^a-zA-Z0-9]/g, "").slice(-8) || "run";
|
|
258
1227
|
}
|
|
259
1228
|
|
|
1229
|
+
private hasLiveContinuation(gateId: string): boolean {
|
|
1230
|
+
return this.continuations.get(gateId)?.isLive(gateId) === true;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
hasRecoverableAcceptedGate(): boolean {
|
|
1234
|
+
return this.store
|
|
1235
|
+
.list()
|
|
1236
|
+
.some(
|
|
1237
|
+
record =>
|
|
1238
|
+
record.status === "accepted" &&
|
|
1239
|
+
!record.advanced &&
|
|
1240
|
+
record.ownerInstanceId === this.instanceId &&
|
|
1241
|
+
this.hasLiveContinuation(record.gate.gate_id),
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
prepareTerminalization(gateId: string, proof: WorkflowGateTerminalProof): boolean {
|
|
1245
|
+
const record = this.store.get(gateId);
|
|
1246
|
+
if (
|
|
1247
|
+
record?.status !== "pending" ||
|
|
1248
|
+
record.ownerInstanceId !== this.instanceId ||
|
|
1249
|
+
!this.hasLiveContinuation(gateId)
|
|
1250
|
+
)
|
|
1251
|
+
return false;
|
|
1252
|
+
this.#terminalProofs.set(gateId, proof);
|
|
1253
|
+
return true;
|
|
1254
|
+
}
|
|
1255
|
+
clearPreparedTerminalization(gateId: string): void {
|
|
1256
|
+
this.#terminalProofs.delete(gateId);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/**
|
|
1260
|
+
* Fences a gate after an uncertain control outcome by deliberately releasing
|
|
1261
|
+
* this runtime's continuation authority. The durable diagnostic is retained
|
|
1262
|
+
* for recovery; it is never republished as a pending gate.
|
|
1263
|
+
*/
|
|
1264
|
+
quarantineGate(gateId: string): void {
|
|
1265
|
+
this.loseContinuation(gateId);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
/** Revoke every live continuation, including accepted-but-unadvanced recovery authority. */
|
|
1269
|
+
fenceContinuations(): void {
|
|
1270
|
+
for (const gateId of [...this.continuations.keys()]) this.loseContinuation(gateId);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/** Fence a cancelled/lost continuation before it can be exposed or advanced. */
|
|
1274
|
+
loseContinuation(gateId: string): void {
|
|
1275
|
+
const continuation = this.continuations.get(gateId);
|
|
1276
|
+
continuation?.release?.(gateId);
|
|
1277
|
+
this.continuations.delete(gateId);
|
|
1278
|
+
this.#terminalProofs.delete(gateId);
|
|
1279
|
+
const record = this.store.get(gateId);
|
|
1280
|
+
if (
|
|
1281
|
+
record?.ownerInstanceId !== this.instanceId ||
|
|
1282
|
+
record.status === "quarantined" ||
|
|
1283
|
+
(record.status === "accepted" && record.advanced)
|
|
1284
|
+
)
|
|
1285
|
+
return;
|
|
1286
|
+
this.store.put({
|
|
1287
|
+
...record,
|
|
1288
|
+
status: "quarantined",
|
|
1289
|
+
advanced: false,
|
|
1290
|
+
lifecycle: {
|
|
1291
|
+
state: "quarantined",
|
|
1292
|
+
reason: "continuation_owner_lost",
|
|
1293
|
+
quarantinedAt: new Date().toISOString(),
|
|
1294
|
+
},
|
|
1295
|
+
});
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
listPendingGates(): WorkflowGate[] {
|
|
1299
|
+
return this.store
|
|
1300
|
+
.list()
|
|
1301
|
+
.filter(
|
|
1302
|
+
record =>
|
|
1303
|
+
record.status === "pending" &&
|
|
1304
|
+
record.ownerInstanceId === this.instanceId &&
|
|
1305
|
+
this.hasLiveContinuation(record.gate.gate_id),
|
|
1306
|
+
)
|
|
1307
|
+
.sort(compareGates)
|
|
1308
|
+
.map(record => record.gate);
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
listGateDiagnostics(): WorkflowGateDiagnostic[] {
|
|
1312
|
+
return this.store
|
|
1313
|
+
.list()
|
|
1314
|
+
.filter(
|
|
1315
|
+
(
|
|
1316
|
+
record,
|
|
1317
|
+
): record is PersistedGate & { status: "quarantined"; lifecycle: WorkflowGateDiagnostic["lifecycle"] } =>
|
|
1318
|
+
record.status === "quarantined" && record.lifecycle !== undefined,
|
|
1319
|
+
)
|
|
1320
|
+
.sort(
|
|
1321
|
+
(left, right) =>
|
|
1322
|
+
left.lifecycle.quarantinedAt.localeCompare(right.lifecycle.quarantinedAt) ||
|
|
1323
|
+
left.gate.gate_id.localeCompare(right.gate.gate_id),
|
|
1324
|
+
)
|
|
1325
|
+
.map(record => ({
|
|
1326
|
+
...record.gate,
|
|
1327
|
+
id: `diagnostic:${record.gate.gate_id}`,
|
|
1328
|
+
tag: "quarantined",
|
|
1329
|
+
lifecycle: record.lifecycle,
|
|
1330
|
+
}));
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
listWorkflowGateQueryRecords(): WorkflowGateQueryRecord[] {
|
|
1334
|
+
const pending: WorkflowGateQueryRecord[] = this.store
|
|
1335
|
+
.list()
|
|
1336
|
+
.filter(
|
|
1337
|
+
record =>
|
|
1338
|
+
record.status === "pending" &&
|
|
1339
|
+
record.ownerInstanceId === this.instanceId &&
|
|
1340
|
+
this.hasLiveContinuation(record.gate.gate_id),
|
|
1341
|
+
)
|
|
1342
|
+
.sort(compareGates)
|
|
1343
|
+
.map(record => ({ ...record.gate, id: `pending:${record.gate.gate_id}`, tag: "pending" }));
|
|
1344
|
+
return [...pending, ...this.listGateDiagnostics()];
|
|
1345
|
+
}
|
|
1346
|
+
|
|
260
1347
|
/** Open and emit a gate. The pending record is persisted BEFORE emission. */
|
|
261
|
-
openGate(input: OpenGateInput):
|
|
1348
|
+
openGate(input: OpenGateInput, continuation?: GateContinuation): WorkflowGate {
|
|
262
1349
|
if (RESERVED_WORKFLOW_STAGES.includes(input.stage) || !V1_STAGES.includes(input.stage)) {
|
|
263
1350
|
throw new WorkflowGateBrokerError(
|
|
264
1351
|
"invalid_workflow_stage",
|
|
@@ -269,7 +1356,7 @@ export class WorkflowGateBroker {
|
|
|
269
1356
|
compileGateSchema(input.schema);
|
|
270
1357
|
const seq = this.store.nextSeq(input.stage).toString().padStart(6, "0");
|
|
271
1358
|
const gateId = `wg_${this.runShort()}_${input.stage}_${seq}`;
|
|
272
|
-
const gate:
|
|
1359
|
+
const gate: WorkflowGate = {
|
|
273
1360
|
type: "workflow_gate",
|
|
274
1361
|
gate_id: gateId,
|
|
275
1362
|
stage: input.stage,
|
|
@@ -281,8 +1368,30 @@ export class WorkflowGateBroker {
|
|
|
281
1368
|
created_at: new Date().toISOString(),
|
|
282
1369
|
required: true,
|
|
283
1370
|
};
|
|
284
|
-
|
|
285
|
-
|
|
1371
|
+
if (!continuation) {
|
|
1372
|
+
this.store.put({
|
|
1373
|
+
gate,
|
|
1374
|
+
status: "quarantined",
|
|
1375
|
+
ownerInstanceId: this.instanceId,
|
|
1376
|
+
advanced: false,
|
|
1377
|
+
lifecycle: {
|
|
1378
|
+
state: "quarantined",
|
|
1379
|
+
reason: "opened_without_continuation",
|
|
1380
|
+
quarantinedAt: new Date().toISOString(),
|
|
1381
|
+
},
|
|
1382
|
+
});
|
|
1383
|
+
return gate;
|
|
1384
|
+
}
|
|
1385
|
+
this.store.put({ gate, status: "pending", ownerInstanceId: this.instanceId, advanced: false });
|
|
1386
|
+
continuation.activate(gate);
|
|
1387
|
+
if (continuation.terminalProof === "not_published") this.#terminalProofs.set(gateId, "not_published");
|
|
1388
|
+
this.continuations.set(gateId, continuation);
|
|
1389
|
+
if (input.supersedesGateId) {
|
|
1390
|
+
const superseded = this.store.get(input.supersedesGateId);
|
|
1391
|
+
if (superseded?.status === "quarantined" && superseded.lifecycle) {
|
|
1392
|
+
this.store.put({ ...superseded, lifecycle: { ...superseded.lifecycle, supersededByGateId: gateId } });
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
286
1395
|
this.hooks.emit?.(gate);
|
|
287
1396
|
this.hooks.audit?.({ event: "gate_emitted", gate_id: gateId, stage: gate.stage, kind: gate.kind });
|
|
288
1397
|
return gate;
|
|
@@ -290,33 +1399,41 @@ export class WorkflowGateBroker {
|
|
|
290
1399
|
|
|
291
1400
|
/**
|
|
292
1401
|
* Resolve a gate with an answer. Validates against the advertised schema.
|
|
293
|
-
*
|
|
294
|
-
*
|
|
1402
|
+
* Accepted answers are persisted first, then their presentation is terminalized,
|
|
1403
|
+
* then the workflow advances and commits `advanced:true` exactly once. Invalid
|
|
1404
|
+
* answers leave the gate pending (per #315 acceptance).
|
|
295
1405
|
*/
|
|
296
|
-
async resolve(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
1406
|
+
async resolve(response: WorkflowGateResponse, options: GateResolutionOptions = {}): Promise<WorkflowGateResolution> {
|
|
1407
|
+
try {
|
|
1408
|
+
return await this.withGateLock(response.gate_id, () => this.resolveUnlocked(response, options));
|
|
1409
|
+
} catch (error) {
|
|
1410
|
+
if (error instanceof GateStoreWriteError && error.certainty === "uncertain") {
|
|
1411
|
+
try {
|
|
1412
|
+
this.quarantineGate(response.gate_id);
|
|
1413
|
+
} catch {
|
|
1414
|
+
// Continuation authority was still released; preserve the uncertain write error.
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
throw error;
|
|
1418
|
+
}
|
|
301
1419
|
}
|
|
302
1420
|
|
|
303
1421
|
private async resolveUnlocked(
|
|
304
|
-
response:
|
|
1422
|
+
response: WorkflowGateResponse,
|
|
305
1423
|
options: GateResolutionOptions = {},
|
|
306
|
-
): Promise<
|
|
1424
|
+
): Promise<WorkflowGateResolution> {
|
|
307
1425
|
const record = this.store.get(response.gate_id);
|
|
308
|
-
if (!record) {
|
|
1426
|
+
if (!record || record.status === "quarantined") {
|
|
309
1427
|
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}`);
|
|
1428
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no live pending gate ${response.gate_id}`);
|
|
311
1429
|
}
|
|
312
1430
|
const responseHash = answerHashOf({ gate_id: response.gate_id, answer: response.answer });
|
|
313
|
-
|
|
314
1431
|
if (record.status === "accepted") {
|
|
315
1432
|
const sameBody = record.responseHash === responseHash;
|
|
316
1433
|
const sameKey = record.idempotencyKey === response.idempotency_key;
|
|
317
1434
|
if (response.idempotency_key !== undefined && sameKey && sameBody) {
|
|
318
1435
|
this.hooks.audit?.({ event: "gate_response_idempotent_replay", gate_id: response.gate_id });
|
|
319
|
-
return record.resolution as
|
|
1436
|
+
return record.resolution as WorkflowGateResolution;
|
|
320
1437
|
}
|
|
321
1438
|
if (response.idempotency_key !== undefined && sameKey && !sameBody) {
|
|
322
1439
|
this.hooks.audit?.({ event: "gate_response_idempotency_conflict", gate_id: response.gate_id });
|
|
@@ -328,6 +1445,10 @@ export class WorkflowGateBroker {
|
|
|
328
1445
|
this.hooks.audit?.({ event: "gate_response_already_resolved", gate_id: response.gate_id });
|
|
329
1446
|
throw new WorkflowGateBrokerError("already_resolved", `already_resolved: gate ${response.gate_id}`);
|
|
330
1447
|
}
|
|
1448
|
+
if (record.ownerInstanceId !== this.instanceId || !this.hasLiveContinuation(response.gate_id)) {
|
|
1449
|
+
this.hooks.audit?.({ event: "gate_response_unknown_gate", gate_id: response.gate_id });
|
|
1450
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no live pending gate ${response.gate_id}`);
|
|
1451
|
+
}
|
|
331
1452
|
|
|
332
1453
|
const compiled = compileGateSchema(record.gate.schema);
|
|
333
1454
|
const validationError = validateGateAnswer(compiled, response.gate_id, response.answer);
|
|
@@ -346,12 +1467,12 @@ export class WorkflowGateBroker {
|
|
|
346
1467
|
|
|
347
1468
|
const semanticDisposition =
|
|
348
1469
|
options.semanticDisposition ?? classifyAskGateDisposition(record.gate, response.answer);
|
|
349
|
-
const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "
|
|
1470
|
+
const resolutionOrigin = options.resolutionOrigin ?? { kind: "generic", channel: "sdk" as const };
|
|
350
1471
|
const ackPolicy =
|
|
351
1472
|
options.ackPolicy ??
|
|
352
1473
|
({ kind: "none", reason: semanticDisposition === "commit" ? "non_telegram" : "semantic_noncommit" } as const);
|
|
353
1474
|
|
|
354
|
-
const resolution:
|
|
1475
|
+
const resolution: WorkflowGateResolution = {
|
|
355
1476
|
gate_id: response.gate_id,
|
|
356
1477
|
status: "accepted",
|
|
357
1478
|
answer_hash: answerHash,
|
|
@@ -363,11 +1484,13 @@ export class WorkflowGateBroker {
|
|
|
363
1484
|
this.store.put({
|
|
364
1485
|
gate: record.gate,
|
|
365
1486
|
status: "accepted",
|
|
1487
|
+
ownerInstanceId: record.ownerInstanceId,
|
|
366
1488
|
idempotencyKey: response.idempotency_key,
|
|
367
1489
|
responseHash,
|
|
368
1490
|
answer: response.answer,
|
|
369
1491
|
resolution,
|
|
370
1492
|
advanced: false,
|
|
1493
|
+
terminalized: false,
|
|
371
1494
|
semanticDisposition,
|
|
372
1495
|
resolutionOrigin,
|
|
373
1496
|
ackPolicy,
|
|
@@ -379,19 +1502,32 @@ export class WorkflowGateBroker {
|
|
|
379
1502
|
if (finalized?.status !== "accepted") {
|
|
380
1503
|
throw new WorkflowGateBrokerError(
|
|
381
1504
|
"unknown_gate",
|
|
382
|
-
`accepted gate ${response.gate_id} disappeared before
|
|
1505
|
+
`accepted gate ${response.gate_id} disappeared before terminalization`,
|
|
383
1506
|
);
|
|
384
1507
|
}
|
|
385
1508
|
if (finalized.advanced) return finalized.resolution ?? resolution;
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
1509
|
+
if (!finalized.terminalized) await this.#terminalize(finalized);
|
|
1510
|
+
const terminalized = this.store.get(response.gate_id);
|
|
1511
|
+
if (terminalized?.status !== "accepted" || terminalized.advanced) {
|
|
389
1512
|
throw new WorkflowGateBrokerError(
|
|
390
1513
|
"unknown_gate",
|
|
391
|
-
`accepted gate ${response.gate_id} disappeared
|
|
1514
|
+
`accepted gate ${response.gate_id} disappeared before advance`,
|
|
392
1515
|
);
|
|
393
1516
|
}
|
|
1517
|
+
if (!this.hooks.advance || !this.hasLiveContinuation(response.gate_id)) {
|
|
1518
|
+
throw new WorkflowGateBrokerError("unknown_gate", `no live continuation for gate ${response.gate_id}`);
|
|
1519
|
+
}
|
|
1520
|
+
await this.hooks.advance(terminalized.gate, terminalized.answer);
|
|
1521
|
+
const latest = this.store.get(response.gate_id);
|
|
1522
|
+
if (latest?.status !== "accepted" || !this.hasLiveContinuation(response.gate_id))
|
|
1523
|
+
throw new WorkflowGateBrokerError(
|
|
1524
|
+
"unknown_gate",
|
|
1525
|
+
`accepted gate ${response.gate_id} lost its continuation before advance completed`,
|
|
1526
|
+
);
|
|
394
1527
|
this.store.put({ ...latest, advanced: true });
|
|
1528
|
+
await this.hooks.completeAccepted?.(this.store.get(response.gate_id) as PersistedGate);
|
|
1529
|
+
this.continuations.get(response.gate_id)?.release?.(response.gate_id);
|
|
1530
|
+
this.continuations.delete(response.gate_id);
|
|
395
1531
|
return resolution;
|
|
396
1532
|
}
|
|
397
1533
|
|
|
@@ -404,7 +1540,14 @@ export class WorkflowGateBroker {
|
|
|
404
1540
|
async recover(): Promise<string[]> {
|
|
405
1541
|
const recovered: string[] = [];
|
|
406
1542
|
for (const listed of this.store.list()) {
|
|
407
|
-
if (
|
|
1543
|
+
if (
|
|
1544
|
+
listed.status !== "accepted" ||
|
|
1545
|
+
listed.advanced ||
|
|
1546
|
+
listed.ownerInstanceId !== this.instanceId ||
|
|
1547
|
+
!this.hasLiveContinuation(listed.gate.gate_id) ||
|
|
1548
|
+
this.recovering.has(listed.gate.gate_id)
|
|
1549
|
+
)
|
|
1550
|
+
continue;
|
|
408
1551
|
this.recovering.add(listed.gate.gate_id);
|
|
409
1552
|
try {
|
|
410
1553
|
await this.withGateLock(listed.gate.gate_id, async () => {
|
|
@@ -412,11 +1555,29 @@ export class WorkflowGateBroker {
|
|
|
412
1555
|
if (rec?.status !== "accepted" || rec.advanced) return;
|
|
413
1556
|
await this.hooks.finalizeAccepted?.(rec);
|
|
414
1557
|
const finalized = this.store.get(listed.gate.gate_id);
|
|
415
|
-
if (
|
|
416
|
-
|
|
1558
|
+
if (
|
|
1559
|
+
finalized?.status !== "accepted" ||
|
|
1560
|
+
finalized.advanced ||
|
|
1561
|
+
!this.hasLiveContinuation(listed.gate.gate_id)
|
|
1562
|
+
)
|
|
1563
|
+
return;
|
|
1564
|
+
if (!finalized.terminalized) await this.#terminalize(finalized);
|
|
1565
|
+
const terminalized = this.store.get(listed.gate.gate_id);
|
|
1566
|
+
if (
|
|
1567
|
+
terminalized?.status !== "accepted" ||
|
|
1568
|
+
terminalized.advanced ||
|
|
1569
|
+
!this.hooks.advance ||
|
|
1570
|
+
!this.hasLiveContinuation(listed.gate.gate_id)
|
|
1571
|
+
)
|
|
1572
|
+
return;
|
|
1573
|
+
await this.hooks.advance(terminalized.gate, terminalized.answer);
|
|
417
1574
|
const latest = this.store.get(listed.gate.gate_id);
|
|
418
|
-
if (latest?.status !== "accepted" || latest.advanced)
|
|
1575
|
+
if (latest?.status !== "accepted" || latest.advanced || !this.hasLiveContinuation(listed.gate.gate_id))
|
|
1576
|
+
return;
|
|
419
1577
|
this.store.put({ ...latest, advanced: true });
|
|
1578
|
+
await this.hooks.completeAccepted?.(this.store.get(latest.gate.gate_id) as PersistedGate);
|
|
1579
|
+
this.continuations.get(latest.gate.gate_id)?.release?.(latest.gate.gate_id);
|
|
1580
|
+
this.continuations.delete(latest.gate.gate_id);
|
|
420
1581
|
this.hooks.audit?.({ event: "gate_advance_recovered", gate_id: latest.gate.gate_id });
|
|
421
1582
|
recovered.push(latest.gate.gate_id);
|
|
422
1583
|
});
|
|
@@ -432,3 +1593,9 @@ export class WorkflowGateBroker {
|
|
|
432
1593
|
return canonicalJson(value);
|
|
433
1594
|
}
|
|
434
1595
|
}
|
|
1596
|
+
|
|
1597
|
+
function compareGates(left: PersistedGate, right: PersistedGate): number {
|
|
1598
|
+
return (
|
|
1599
|
+
left.gate.created_at.localeCompare(right.gate.created_at) || left.gate.gate_id.localeCompare(right.gate.gate_id)
|
|
1600
|
+
);
|
|
1601
|
+
}
|