@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
|
@@ -0,0 +1,2275 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { SdkClientError } from "../client/client";
|
|
3
|
+
import type { ChatDeliveryError } from "./chat-daemon-runtime";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type ChatEffect,
|
|
7
|
+
ChatEffectJournal,
|
|
8
|
+
type ChatEffectLease,
|
|
9
|
+
type ChatEffectReceipt,
|
|
10
|
+
} from "./chat-effect-journal";
|
|
11
|
+
import { ConversationStore } from "./conversation-store";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type DiscordConversation,
|
|
15
|
+
type DiscordInboundDispatchReceipt,
|
|
16
|
+
discordConversationKey,
|
|
17
|
+
normalizeDiscordConversation,
|
|
18
|
+
} from "./discord-conversation";
|
|
19
|
+
import type { DiscordInboundEvent, DiscordMessageComponent, DiscordProvider, DiscordThread } from "./discord-provider";
|
|
20
|
+
|
|
21
|
+
const FAILURE = "This conversation is no longer available.";
|
|
22
|
+
|
|
23
|
+
export class DiscordEndpointBindingError extends Error {
|
|
24
|
+
constructor(message = "Discord session endpoint changed before outbound publication.") {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "DiscordEndpointBindingError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type DiscordClosingIntent = Readonly<{ nonce: string; at: number }>;
|
|
31
|
+
|
|
32
|
+
function closingIntent(record: DiscordConversation | undefined): DiscordClosingIntent | undefined {
|
|
33
|
+
const candidate = record as { state?: unknown; closingNonce?: unknown; closingAt?: unknown } | undefined;
|
|
34
|
+
return candidate?.state === "closing" &&
|
|
35
|
+
typeof candidate.closingNonce === "string" &&
|
|
36
|
+
typeof candidate.closingAt === "number"
|
|
37
|
+
? { nonce: candidate.closingNonce, at: candidate.closingAt }
|
|
38
|
+
: undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withClosingIntent(record: DiscordConversation, nonce: string, at: number): DiscordConversation {
|
|
42
|
+
return {
|
|
43
|
+
...record,
|
|
44
|
+
generation: record.generation + 1,
|
|
45
|
+
state: "closing",
|
|
46
|
+
closingNonce: nonce,
|
|
47
|
+
closingAt: at,
|
|
48
|
+
pendingActionId: undefined,
|
|
49
|
+
pendingActionNonce: undefined,
|
|
50
|
+
pendingActionEffectId: undefined,
|
|
51
|
+
} as unknown as DiscordConversation;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function withoutClosingIntent(record: DiscordConversation, closedAt: number): DiscordConversation {
|
|
55
|
+
const {
|
|
56
|
+
closingNonce: _closingNonce,
|
|
57
|
+
closingAt: _closingAt,
|
|
58
|
+
...rest
|
|
59
|
+
} = record as DiscordConversation & {
|
|
60
|
+
closingNonce?: unknown;
|
|
61
|
+
closingAt?: unknown;
|
|
62
|
+
};
|
|
63
|
+
return {
|
|
64
|
+
...rest,
|
|
65
|
+
generation: record.generation + 1,
|
|
66
|
+
state: "closed",
|
|
67
|
+
closedAt,
|
|
68
|
+
pendingActionId: undefined,
|
|
69
|
+
pendingActionNonce: undefined,
|
|
70
|
+
pendingActionEffectId: undefined,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A captured live SDK transport. `isCurrent` must fail closed after replacement or removal. */
|
|
75
|
+
export interface DiscordEndpointBinding {
|
|
76
|
+
generation: number;
|
|
77
|
+
isCurrent(): boolean;
|
|
78
|
+
send(frame: Record<string, unknown>): void | Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface DiscordNotificationDaemonOptions {
|
|
82
|
+
agentDir: string;
|
|
83
|
+
repo: string;
|
|
84
|
+
guildId: string;
|
|
85
|
+
parentChannelId: string;
|
|
86
|
+
provider: DiscordProvider;
|
|
87
|
+
now?: () => number;
|
|
88
|
+
resolveEndpoint: (sessionId: string, expectedGeneration?: number) => Promise<DiscordEndpointBinding | null>;
|
|
89
|
+
onCommand?: (
|
|
90
|
+
sessionId: string,
|
|
91
|
+
content: string,
|
|
92
|
+
endpoint: DiscordEndpointBinding,
|
|
93
|
+
idempotencyKey: string,
|
|
94
|
+
) => Promise<boolean>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface DiscordNotificationInput {
|
|
98
|
+
sessionId: string;
|
|
99
|
+
endpointGeneration: number;
|
|
100
|
+
content: string;
|
|
101
|
+
threadName?: string;
|
|
102
|
+
actionId?: string;
|
|
103
|
+
options?: string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type DiscordInboundEffectPayload =
|
|
107
|
+
| {
|
|
108
|
+
type: "command";
|
|
109
|
+
content: string;
|
|
110
|
+
idempotencyKey: string;
|
|
111
|
+
routing: DiscordInboundRouting;
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
type: "reply";
|
|
115
|
+
id: string;
|
|
116
|
+
answer: string | number;
|
|
117
|
+
idempotencyKey: string;
|
|
118
|
+
routing: DiscordInboundRouting;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
type DiscordInboundRouting = {
|
|
122
|
+
guildId: string;
|
|
123
|
+
parentId: string;
|
|
124
|
+
threadId: string;
|
|
125
|
+
eventId: string;
|
|
126
|
+
interactionId?: string;
|
|
127
|
+
kind: "command" | "action";
|
|
128
|
+
actionId?: string;
|
|
129
|
+
actionNonce?: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
function backfilledEffectIncarnationId(record: DiscordConversation): string {
|
|
133
|
+
if (record.effectIncarnationId) return record.effectIncarnationId;
|
|
134
|
+
if (record.createNonce) return record.createNonce;
|
|
135
|
+
return `legacy-${createHash("sha256")
|
|
136
|
+
.update(
|
|
137
|
+
[record.appId, record.guildId, record.parentChannelId, record.threadId ?? "", record.sessionId ?? ""].join(
|
|
138
|
+
"\u0000",
|
|
139
|
+
),
|
|
140
|
+
)
|
|
141
|
+
.digest("hex")
|
|
142
|
+
.slice(0, 32)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type DiscordInboundClaim = {
|
|
146
|
+
receipt: DiscordInboundDispatchReceipt;
|
|
147
|
+
liveCallbackEffect?: ChatEffect<DiscordInboundEffectPayload>;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** SDK-only Discord threaded notification daemon. It owns no AgentSession and never retains endpoint credentials. */
|
|
151
|
+
export class DiscordNotificationDaemon {
|
|
152
|
+
readonly #store: ConversationStore<DiscordConversation>;
|
|
153
|
+
readonly #now: () => number;
|
|
154
|
+
readonly #creates = new Map<string, Promise<DiscordConversation>>();
|
|
155
|
+
readonly #resumes = new Map<string, Promise<DiscordConversation | undefined>>();
|
|
156
|
+
readonly #archives = new Map<string, Promise<void>>();
|
|
157
|
+
readonly #resolveEndpoint: (
|
|
158
|
+
sessionId: string,
|
|
159
|
+
expectedGeneration?: number,
|
|
160
|
+
) => Promise<DiscordEndpointBinding | null>;
|
|
161
|
+
readonly #effects: ChatEffectJournal;
|
|
162
|
+
readonly #activeWork = new Set<Promise<unknown>>();
|
|
163
|
+
readonly #inflightInbound = new Set<string>();
|
|
164
|
+
#started = false;
|
|
165
|
+
#lifecycleGeneration = 0;
|
|
166
|
+
#startTask: Promise<void> | undefined;
|
|
167
|
+
#stopTask: Promise<void> | undefined;
|
|
168
|
+
#providerStarting = false;
|
|
169
|
+
|
|
170
|
+
#leaseRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
171
|
+
#leaseRecoveryAt: number | undefined;
|
|
172
|
+
#leaseRecoveryFailures = 0;
|
|
173
|
+
|
|
174
|
+
readonly #dispatchOwner = randomUUID();
|
|
175
|
+
readonly #dispatchLeaseMs = 60_000;
|
|
176
|
+
readonly #providerOwner = randomUUID();
|
|
177
|
+
readonly #providerLeaseMs = 60_000;
|
|
178
|
+
constructor(private readonly options: DiscordNotificationDaemonOptions) {
|
|
179
|
+
this.#store = new ConversationStore({ agentDir: options.agentDir, kind: "discord", now: options.now });
|
|
180
|
+
this.#effects = new ChatEffectJournal({ agentDir: options.agentDir, transport: "discord", now: options.now });
|
|
181
|
+
this.#now = options.now ?? Date.now;
|
|
182
|
+
this.#resolveEndpoint = options.resolveEndpoint;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async start(): Promise<void> {
|
|
186
|
+
if (this.#started && !this.#stopTask) return;
|
|
187
|
+
if (this.#stopTask) {
|
|
188
|
+
await this.#stopTask;
|
|
189
|
+
return await this.start();
|
|
190
|
+
}
|
|
191
|
+
if (this.#startTask) return await this.#startTask;
|
|
192
|
+
|
|
193
|
+
const lifecycleGeneration = ++this.#lifecycleGeneration;
|
|
194
|
+
const task = this.#start(lifecycleGeneration);
|
|
195
|
+
this.#startTask = task;
|
|
196
|
+
try {
|
|
197
|
+
await task;
|
|
198
|
+
} finally {
|
|
199
|
+
if (this.#startTask === task) this.#startTask = undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async #start(lifecycleGeneration: number): Promise<void> {
|
|
204
|
+
try {
|
|
205
|
+
// Provider start is the delivery boundary; complete crash recovery first.
|
|
206
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
207
|
+
const closingBeforeProviderRecoveryFailed = await this.#recoverClosingConversations();
|
|
208
|
+
const providerRecoveryFailed = await this.#drainProviderEffects();
|
|
209
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
210
|
+
const closingAfterProviderRecoveryFailed = await this.#recoverClosingConversations();
|
|
211
|
+
const inboundRecoveryFailed = await this.#drainPendingDispatches();
|
|
212
|
+
const recoveryFailed =
|
|
213
|
+
closingBeforeProviderRecoveryFailed ||
|
|
214
|
+
providerRecoveryFailed ||
|
|
215
|
+
closingAfterProviderRecoveryFailed ||
|
|
216
|
+
inboundRecoveryFailed;
|
|
217
|
+
if (lifecycleGeneration !== this.#lifecycleGeneration) return;
|
|
218
|
+
|
|
219
|
+
this.#started = true;
|
|
220
|
+
await this.#scheduleLeaseRecovery(recoveryFailed);
|
|
221
|
+
// stop() invalidates this generation before awaiting recovery or start. This
|
|
222
|
+
// guard is immediately adjacent to the Gateway-open boundary.
|
|
223
|
+
if (lifecycleGeneration !== this.#lifecycleGeneration) {
|
|
224
|
+
this.#started = false;
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
this.#providerStarting = true;
|
|
228
|
+
try {
|
|
229
|
+
await this.options.provider.start(
|
|
230
|
+
event => this.#track(this.handleInbound(event)),
|
|
231
|
+
() => {},
|
|
232
|
+
);
|
|
233
|
+
} finally {
|
|
234
|
+
this.#providerStarting = false;
|
|
235
|
+
}
|
|
236
|
+
if (lifecycleGeneration !== this.#lifecycleGeneration) {
|
|
237
|
+
this.#started = false;
|
|
238
|
+
await this.options.provider.stop();
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (lifecycleGeneration === this.#lifecycleGeneration) {
|
|
242
|
+
this.#started = false;
|
|
243
|
+
if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
|
|
244
|
+
this.#leaseRecoveryTimer = undefined;
|
|
245
|
+
this.#leaseRecoveryAt = undefined;
|
|
246
|
+
}
|
|
247
|
+
throw error;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async stop(): Promise<void> {
|
|
252
|
+
if (this.#stopTask) return await this.#stopTask;
|
|
253
|
+
|
|
254
|
+
const starting = this.#startTask;
|
|
255
|
+
const stopProvider = this.#started || this.#providerStarting;
|
|
256
|
+
++this.#lifecycleGeneration;
|
|
257
|
+
this.#started = false;
|
|
258
|
+
if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
|
|
259
|
+
this.#leaseRecoveryTimer = undefined;
|
|
260
|
+
this.#leaseRecoveryAt = undefined;
|
|
261
|
+
|
|
262
|
+
const task = this.#stop(starting, stopProvider);
|
|
263
|
+
this.#stopTask = task;
|
|
264
|
+
try {
|
|
265
|
+
await task;
|
|
266
|
+
} finally {
|
|
267
|
+
if (this.#stopTask === task) this.#stopTask = undefined;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async #stop(starting: Promise<void> | undefined, stopProvider: boolean): Promise<void> {
|
|
272
|
+
// Calling provider.stop() before awaiting start lets a provider cancel a
|
|
273
|
+
// Gateway open that is already in flight. #start rechecks the generation
|
|
274
|
+
// after it resolves and closes any late open.
|
|
275
|
+
if (stopProvider) await this.options.provider.stop();
|
|
276
|
+
try {
|
|
277
|
+
await starting;
|
|
278
|
+
} catch {
|
|
279
|
+
// The caller of start() owns its recovery/start error; stop still drains it.
|
|
280
|
+
}
|
|
281
|
+
if (this.#providerStarting || this.#started) {
|
|
282
|
+
this.#started = false;
|
|
283
|
+
await this.options.provider.stop();
|
|
284
|
+
}
|
|
285
|
+
// Drain until quiescent: a tracked task can schedule further tracked work while
|
|
286
|
+
// we await, and any that outlives stop() would bleed timing pressure into the
|
|
287
|
+
// next daemon/test. #started is false, so no new recovery timers arm and the
|
|
288
|
+
// loop terminates.
|
|
289
|
+
while (this.#activeWork.size > 0) await Promise.all([...this.#activeWork]);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async notify(input: DiscordNotificationInput): Promise<DiscordConversation> {
|
|
293
|
+
await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
|
|
294
|
+
const conversation = await this.#ensureConversation(input);
|
|
295
|
+
await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
|
|
296
|
+
if (conversation.state !== "active" || !conversation.threadId) throw new Error("Discord thread is unavailable");
|
|
297
|
+
const pendingActionId = input.actionId;
|
|
298
|
+
const authoritative = pendingActionId
|
|
299
|
+
? await this.#ensureActionPublication(conversation, pendingActionId)
|
|
300
|
+
: conversation;
|
|
301
|
+
await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
|
|
302
|
+
const components =
|
|
303
|
+
pendingActionId && authoritative.pendingActionNonce && input.options && input.options.length > 0
|
|
304
|
+
? actionComponents(
|
|
305
|
+
authoritative.endpointGeneration!,
|
|
306
|
+
pendingActionId,
|
|
307
|
+
authoritative.pendingActionNonce,
|
|
308
|
+
input.options,
|
|
309
|
+
)
|
|
310
|
+
: undefined;
|
|
311
|
+
|
|
312
|
+
// The durable action publication intent, rather than this notification call,
|
|
313
|
+
// owns the provider-visible effect identity. This makes retries reconcile the
|
|
314
|
+
// original post and preserves the original component route.
|
|
315
|
+
const effectId = pendingActionId
|
|
316
|
+
? authoritative.pendingActionEffectId!
|
|
317
|
+
: `notification:${authoritative.threadId}:${randomUUID()}`;
|
|
318
|
+
await this.#postEffect(effectId, authoritative, input.content, components, pendingActionId !== undefined);
|
|
319
|
+
|
|
320
|
+
return authoritative;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Posts a safe command outcome to the active mapped conversation. */
|
|
324
|
+
async postCommandResult(sessionId: string, content: string): Promise<boolean> {
|
|
325
|
+
const record = await this.#bySession(sessionId);
|
|
326
|
+
if (record?.state !== "active" || !record.threadId) return false;
|
|
327
|
+
await this.#postEffect(`command-result:${record.threadId}:${randomUUID()}`, record, content);
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async close(sessionId: string): Promise<void> {
|
|
332
|
+
const closing = await this.#markClosing(sessionId);
|
|
333
|
+
if (closing) await this.#driveClose(closing);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async archive(sessionId: string): Promise<void> {
|
|
337
|
+
const running = this.#archives.get(sessionId);
|
|
338
|
+
if (running) return await running;
|
|
339
|
+
const task = this.#archive(sessionId);
|
|
340
|
+
this.#archives.set(sessionId, task);
|
|
341
|
+
try {
|
|
342
|
+
await task;
|
|
343
|
+
} finally {
|
|
344
|
+
this.#archives.delete(sessionId);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async #archive(sessionId: string): Promise<void> {
|
|
349
|
+
const record = await this.#bySession(sessionId);
|
|
350
|
+
if (!record?.threadId || record.state !== "active") return;
|
|
351
|
+
await this.#requireLiveBinding(record.sessionId!, record.endpointGeneration!);
|
|
352
|
+
const archiving = await this.#beginArchive(record);
|
|
353
|
+
const occurrenceId = archiving.archiveOccurrenceId;
|
|
354
|
+
if (!occurrenceId || !archiving.archiveEffectId) throw new Error("Discord archive occurrence is unavailable");
|
|
355
|
+
await this.#threadEffect(archiving.archiveEffectId, archiving, "archive", false, false, occurrenceId);
|
|
356
|
+
await this.#requireLiveBinding(archiving.sessionId!, archiving.endpointGeneration!);
|
|
357
|
+
await this.#completeArchive(archiving, occurrenceId);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async resume(sessionId: string, endpointGeneration: number): Promise<DiscordConversation | undefined> {
|
|
361
|
+
const running = this.#resumes.get(sessionId);
|
|
362
|
+
if (running) return await running;
|
|
363
|
+
const task = this.#resume(sessionId, endpointGeneration);
|
|
364
|
+
this.#resumes.set(sessionId, task);
|
|
365
|
+
try {
|
|
366
|
+
return await task;
|
|
367
|
+
} finally {
|
|
368
|
+
this.#resumes.delete(sessionId);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async #resume(sessionId: string, endpointGeneration: number): Promise<DiscordConversation | undefined> {
|
|
373
|
+
const record = await this.#bySession(sessionId);
|
|
374
|
+
if (!record?.threadId || record.state === "closed") return undefined;
|
|
375
|
+
if (closingIntent(record)) {
|
|
376
|
+
await this.#driveClose(record);
|
|
377
|
+
return undefined;
|
|
378
|
+
}
|
|
379
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
380
|
+
const resumeOccurrenceId = randomUUID();
|
|
381
|
+
const effectIncarnationId = backfilledEffectIncarnationId(record);
|
|
382
|
+
const resuming = await this.#replace(record, {
|
|
383
|
+
...record,
|
|
384
|
+
state: "resuming",
|
|
385
|
+
endpointGeneration,
|
|
386
|
+
effectIncarnationId,
|
|
387
|
+
resumeOccurrenceId,
|
|
388
|
+
resumeEffectId: `unarchive:${record.threadId}:${effectIncarnationId}:${resumeOccurrenceId}`,
|
|
389
|
+
pendingActionId: undefined,
|
|
390
|
+
pendingActionNonce: undefined,
|
|
391
|
+
pendingActionEffectId: undefined,
|
|
392
|
+
});
|
|
393
|
+
try {
|
|
394
|
+
const occurrenceId = resuming.resumeOccurrenceId;
|
|
395
|
+
if (!occurrenceId || !resuming.resumeEffectId) throw new Error("Discord resume occurrence is unavailable");
|
|
396
|
+
await this.#threadEffect(resuming.resumeEffectId, resuming, "unarchive", false, false, occurrenceId);
|
|
397
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
398
|
+
return await this.#completeResume(resuming, occurrenceId);
|
|
399
|
+
} catch {
|
|
400
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
401
|
+
const superseded = await this.#replace(resuming, {
|
|
402
|
+
...resuming,
|
|
403
|
+
state: "archived",
|
|
404
|
+
resumeOccurrenceId: undefined,
|
|
405
|
+
resumeEffectId: undefined,
|
|
406
|
+
pendingActionId: undefined,
|
|
407
|
+
pendingActionNonce: undefined,
|
|
408
|
+
pendingActionEffectId: undefined,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const replacement = await this.#create(sessionId, endpointGeneration, `resume-${randomUUID()}`);
|
|
412
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
413
|
+
await this.#replace(superseded, { ...superseded, supersededByThreadId: replacement.threadId });
|
|
414
|
+
return replacement;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async resolveAction(sessionId: string, actionId: string): Promise<void> {
|
|
419
|
+
const record = await this.#bySession(sessionId);
|
|
420
|
+
if (record?.pendingActionId === actionId && record.pendingActionNonce)
|
|
421
|
+
await this.#clearPending(record, actionId, record.pendingActionNonce);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async handleInbound(event: DiscordInboundEvent): Promise<void> {
|
|
425
|
+
if (event.bot || event.authorId === this.options.provider.botUserId) return;
|
|
426
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
427
|
+
const record = await this.#byThread(event.guildId, event.parentId, event.threadId);
|
|
428
|
+
if (!record?.sessionId) {
|
|
429
|
+
await this.#fail(event.threadId);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const claim = await this.#claimInbound(record, event);
|
|
433
|
+
if (claim === "invalid") {
|
|
434
|
+
await this.#fail(event.threadId);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (!claim || this.#inflightInbound.has(claim.receipt.effectId)) return;
|
|
438
|
+
this.#inflightInbound.add(claim.receipt.effectId);
|
|
439
|
+
try {
|
|
440
|
+
// Interaction callbacks have a short provider deadline. The mapping and
|
|
441
|
+
// journal claim are sufficient to acknowledge; endpoint discovery waits.
|
|
442
|
+
const endpoint = event.interaction
|
|
443
|
+
? null
|
|
444
|
+
: await this.#resolveEndpoint(record.sessionId, record.endpointGeneration);
|
|
445
|
+
if (!event.interaction && !this.#matches(record, endpoint)) {
|
|
446
|
+
await this.#fail(event.threadId);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await this.#dispatchInbound(record, endpoint, claim.receipt, event.interaction, claim.liveCallbackEffect);
|
|
450
|
+
} finally {
|
|
451
|
+
this.#inflightInbound.delete(claim.receipt.effectId);
|
|
452
|
+
await this.#scheduleLeaseRecovery();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
#track<T>(work: Promise<T>): Promise<T> {
|
|
457
|
+
this.#activeWork.add(work);
|
|
458
|
+
return work.finally(() => this.#activeWork.delete(work));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async #claimInbound(
|
|
462
|
+
record: DiscordConversation,
|
|
463
|
+
event: DiscordInboundEvent,
|
|
464
|
+
): Promise<DiscordInboundClaim | "invalid" | undefined> {
|
|
465
|
+
if (record.state !== "active" || closingIntent(record)) return "invalid";
|
|
466
|
+
const route = event.interaction ? decodeCustomId(event.interaction.customId) : undefined;
|
|
467
|
+
const command = !event.interaction && event.content?.startsWith("/sdk ");
|
|
468
|
+
if (!command && (!route || !event.interaction || route.generation !== record.endpointGeneration))
|
|
469
|
+
return "invalid";
|
|
470
|
+
const key = discordConversationKey({
|
|
471
|
+
appId: record.appId,
|
|
472
|
+
guildId: record.guildId,
|
|
473
|
+
parentChannelId: record.parentChannelId,
|
|
474
|
+
threadId: record.threadId!,
|
|
475
|
+
});
|
|
476
|
+
let receipt: DiscordInboundDispatchReceipt | undefined;
|
|
477
|
+
let valid = false;
|
|
478
|
+
const effectId = `discord:${record.appId}:${record.guildId}:${record.parentChannelId}:${record.threadId}:${event.id}`;
|
|
479
|
+
const idempotencyKey = effectId;
|
|
480
|
+
const routing: DiscordInboundRouting = {
|
|
481
|
+
guildId: event.guildId,
|
|
482
|
+
parentId: event.parentId,
|
|
483
|
+
threadId: event.threadId,
|
|
484
|
+
eventId: event.id,
|
|
485
|
+
...(event.interaction ? { interactionId: event.interaction.id } : {}),
|
|
486
|
+
kind: command ? "command" : "action",
|
|
487
|
+
...(!command ? { actionId: route!.actionId, actionNonce: route!.actionNonce } : {}),
|
|
488
|
+
};
|
|
489
|
+
const payload: DiscordInboundEffectPayload = command
|
|
490
|
+
? { type: "command", content: event.content!, idempotencyKey, routing }
|
|
491
|
+
: {
|
|
492
|
+
type: "reply",
|
|
493
|
+
id: route!.actionId,
|
|
494
|
+
answer: componentAnswer(event.interaction!.value ?? ""),
|
|
495
|
+
idempotencyKey,
|
|
496
|
+
routing,
|
|
497
|
+
};
|
|
498
|
+
let liveCallbackEffect: ChatEffect<DiscordInboundEffectPayload> | undefined;
|
|
499
|
+
if (event.interaction) {
|
|
500
|
+
liveCallbackEffect = await this.#rescheduleAfterEffectTransition(
|
|
501
|
+
this.#effects.enqueueAndClaim(
|
|
502
|
+
{
|
|
503
|
+
id: effectId,
|
|
504
|
+
kind: "discord.inbound.action",
|
|
505
|
+
transport: "discord",
|
|
506
|
+
sessionId: record.sessionId,
|
|
507
|
+
endpointGeneration: record.endpointGeneration!,
|
|
508
|
+
payload,
|
|
509
|
+
},
|
|
510
|
+
this.#dispatchOwner,
|
|
511
|
+
this.#dispatchLeaseMs,
|
|
512
|
+
),
|
|
513
|
+
);
|
|
514
|
+
} else {
|
|
515
|
+
await this.#rescheduleAfterEffectTransition(
|
|
516
|
+
this.#effects.enqueue({
|
|
517
|
+
id: effectId,
|
|
518
|
+
kind: "discord.inbound.command",
|
|
519
|
+
transport: "discord",
|
|
520
|
+
sessionId: record.sessionId,
|
|
521
|
+
endpointGeneration: record.endpointGeneration!,
|
|
522
|
+
payload,
|
|
523
|
+
}),
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
await this.#store.transact(key, current => {
|
|
528
|
+
if (current?.state !== "active" || current.endpointGeneration !== record.endpointGeneration) return current;
|
|
529
|
+
const interactionId = event.interaction?.id;
|
|
530
|
+
const existing = (current.inboundDispatches ?? []).find(
|
|
531
|
+
item => item.eventId === event.id || (interactionId !== undefined && item.interactionId === interactionId),
|
|
532
|
+
);
|
|
533
|
+
if (existing) {
|
|
534
|
+
valid = true;
|
|
535
|
+
receipt = existing;
|
|
536
|
+
return current;
|
|
537
|
+
}
|
|
538
|
+
if (
|
|
539
|
+
!command &&
|
|
540
|
+
(current.pendingActionId !== route!.actionId || current.pendingActionNonce !== route!.actionNonce)
|
|
541
|
+
)
|
|
542
|
+
return current;
|
|
543
|
+
if (
|
|
544
|
+
!command &&
|
|
545
|
+
(current.inboundDispatches ?? []).some(
|
|
546
|
+
item =>
|
|
547
|
+
item.kind === "action" &&
|
|
548
|
+
item.actionId === route!.actionId &&
|
|
549
|
+
item.actionNonce === route!.actionNonce,
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
return current;
|
|
553
|
+
valid = true;
|
|
554
|
+
receipt = command
|
|
555
|
+
? {
|
|
556
|
+
key: event.id,
|
|
557
|
+
eventId: event.id,
|
|
558
|
+
kind: "command",
|
|
559
|
+
endpointGeneration: record.endpointGeneration!,
|
|
560
|
+
effectId,
|
|
561
|
+
idempotencyKey,
|
|
562
|
+
}
|
|
563
|
+
: {
|
|
564
|
+
key: event.id,
|
|
565
|
+
eventId: event.id,
|
|
566
|
+
interactionId: interactionId!,
|
|
567
|
+
kind: "action",
|
|
568
|
+
actionId: route!.actionId,
|
|
569
|
+
actionNonce: route!.actionNonce,
|
|
570
|
+
endpointGeneration: record.endpointGeneration!,
|
|
571
|
+
effectId,
|
|
572
|
+
idempotencyKey,
|
|
573
|
+
};
|
|
574
|
+
return normalizeDiscordConversation({
|
|
575
|
+
...current,
|
|
576
|
+
generation: current.generation + 1,
|
|
577
|
+
updatedAt: this.#now(),
|
|
578
|
+
inboundDispatches: [...(current.inboundDispatches ?? []), receipt!],
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
if (!valid || !receipt) {
|
|
582
|
+
await this.#terminalizeRejectedInbound(
|
|
583
|
+
effectId,
|
|
584
|
+
liveCallbackEffect ? { owner: this.#dispatchOwner, epoch: liveCallbackEffect.epoch } : undefined,
|
|
585
|
+
);
|
|
586
|
+
return "invalid";
|
|
587
|
+
}
|
|
588
|
+
if (liveCallbackEffect && liveCallbackEffect.id !== receipt.effectId) {
|
|
589
|
+
await this.#terminalizeRejectedInbound(liveCallbackEffect.id, {
|
|
590
|
+
owner: this.#dispatchOwner,
|
|
591
|
+
epoch: liveCallbackEffect.epoch,
|
|
592
|
+
});
|
|
593
|
+
liveCallbackEffect = undefined;
|
|
594
|
+
}
|
|
595
|
+
return { receipt, liveCallbackEffect };
|
|
596
|
+
}
|
|
597
|
+
async #dispatchInbound(
|
|
598
|
+
record: DiscordConversation,
|
|
599
|
+
initialEndpoint: DiscordEndpointBinding | null,
|
|
600
|
+
receipt: DiscordInboundDispatchReceipt,
|
|
601
|
+
interaction?: DiscordInboundEvent["interaction"],
|
|
602
|
+
liveCallbackEffect?: ChatEffect<DiscordInboundEffectPayload>,
|
|
603
|
+
): Promise<void> {
|
|
604
|
+
const claimedEffect =
|
|
605
|
+
liveCallbackEffect ??
|
|
606
|
+
(await this.#rescheduleAfterEffectTransition(
|
|
607
|
+
this.#effects.claim<DiscordInboundEffectPayload>(
|
|
608
|
+
receipt.effectId,
|
|
609
|
+
this.#dispatchOwner,
|
|
610
|
+
this.#dispatchLeaseMs,
|
|
611
|
+
),
|
|
612
|
+
));
|
|
613
|
+
if (!claimedEffect) return;
|
|
614
|
+
let effect: ChatEffect<DiscordInboundEffectPayload> = claimedEffect;
|
|
615
|
+
let lease = { owner: this.#dispatchOwner, epoch: effect.epoch };
|
|
616
|
+
|
|
617
|
+
const current = await this.#currentInboundRecord(record, receipt);
|
|
618
|
+
if (!current) {
|
|
619
|
+
await this.#terminalizeInbound(record, receipt, "rejected", lease);
|
|
620
|
+
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
record = current;
|
|
624
|
+
if (receipt.kind === "action" && !this.#hasDurableDeferIntent(effect)) {
|
|
625
|
+
// Callback tokens cannot survive a restart. Persist the intent before remote
|
|
626
|
+
// I/O so recovery can safely resume SDK delivery whether defer reached Discord
|
|
627
|
+
// or the process stopped first.
|
|
628
|
+
if (!interaction) {
|
|
629
|
+
await this.#terminalizeInbound(record, receipt, "callback_token_unavailable", lease);
|
|
630
|
+
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
let callbackLeaseLost = false;
|
|
635
|
+
let callbackRenewal: Promise<boolean> | undefined;
|
|
636
|
+
const renewCallbackLease = async (): Promise<boolean> => {
|
|
637
|
+
if (callbackLeaseLost) return false;
|
|
638
|
+
if (callbackRenewal) return await callbackRenewal;
|
|
639
|
+
const renewal = (async (): Promise<boolean> => {
|
|
640
|
+
if (
|
|
641
|
+
!(await this.#rescheduleAfterEffectTransition(
|
|
642
|
+
this.#effects.renew(effect.id, lease, this.#dispatchLeaseMs),
|
|
643
|
+
))
|
|
644
|
+
)
|
|
645
|
+
callbackLeaseLost = true;
|
|
646
|
+
return !callbackLeaseLost;
|
|
647
|
+
})();
|
|
648
|
+
callbackRenewal = renewal;
|
|
649
|
+
try {
|
|
650
|
+
return await renewal;
|
|
651
|
+
} finally {
|
|
652
|
+
if (callbackRenewal === renewal) callbackRenewal = undefined;
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
const timer = setInterval(
|
|
656
|
+
() => {
|
|
657
|
+
void renewCallbackLease().catch(() => {});
|
|
658
|
+
},
|
|
659
|
+
Math.max(1, Math.floor(this.#dispatchLeaseMs / 3)),
|
|
660
|
+
);
|
|
661
|
+
try {
|
|
662
|
+
if (!(await renewCallbackLease())) return;
|
|
663
|
+
const prepared = await this.#rescheduleAfterEffectTransition(
|
|
664
|
+
this.#effects.recordReceipt<DiscordInboundEffectPayload>(effect.id, lease, { status: "defer_intent" }),
|
|
665
|
+
);
|
|
666
|
+
if (!prepared) return;
|
|
667
|
+
effect = prepared;
|
|
668
|
+
if (!(await renewCallbackLease())) return;
|
|
669
|
+
await this.options.provider.deferInteraction({ id: interaction.id, token: interaction.token });
|
|
670
|
+
if (!(await renewCallbackLease())) return;
|
|
671
|
+
const deferred = await this.#rescheduleAfterEffectTransition(
|
|
672
|
+
this.#effects.record(effect.id, lease, "accepted", { status: "deferred" }),
|
|
673
|
+
);
|
|
674
|
+
if (!deferred) return;
|
|
675
|
+
const reclaimed = await this.#rescheduleAfterEffectTransition(
|
|
676
|
+
this.#effects.claim<DiscordInboundEffectPayload>(effect.id, this.#dispatchOwner, this.#dispatchLeaseMs),
|
|
677
|
+
);
|
|
678
|
+
if (!reclaimed) return;
|
|
679
|
+
effect = reclaimed;
|
|
680
|
+
lease = { owner: this.#dispatchOwner, epoch: effect.epoch };
|
|
681
|
+
} catch (error) {
|
|
682
|
+
const definitelyUnsent = this.#isDefiniteCallbackPreSendFailure(error);
|
|
683
|
+
await this.#rescheduleAfterEffectTransition(
|
|
684
|
+
this.#effects.record(effect.id, lease, "accepted", {
|
|
685
|
+
status: definitelyUnsent ? "callback_pre_send_failure" : "defer_intent",
|
|
686
|
+
}),
|
|
687
|
+
);
|
|
688
|
+
throw new Error("Discord interaction callback failed");
|
|
689
|
+
} finally {
|
|
690
|
+
clearInterval(timer);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
const dispatchable = await this.#currentInboundRecord(record, receipt);
|
|
695
|
+
if (!dispatchable) {
|
|
696
|
+
await this.#terminalizeInbound(record, receipt, "stale_binding", lease);
|
|
697
|
+
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
record = dispatchable;
|
|
701
|
+
const endpoint = initialEndpoint ?? (await this.#resolveEndpoint(record.sessionId!, receipt.endpointGeneration));
|
|
702
|
+
if (!this.#matches(record, endpoint) || receipt.endpointGeneration !== endpoint.generation) {
|
|
703
|
+
await this.#rescheduleAfterEffectTransition(
|
|
704
|
+
this.#effects.record(effect.id, lease, "accepted", {
|
|
705
|
+
status: this.#inboundAcceptedStatus(effect, "pre_send_binding_changed"),
|
|
706
|
+
}),
|
|
707
|
+
);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let leaseLost = false;
|
|
712
|
+
let renewal: Promise<boolean> | undefined;
|
|
713
|
+
const renew = async (): Promise<boolean> => {
|
|
714
|
+
if (leaseLost) return false;
|
|
715
|
+
if (renewal) return await renewal;
|
|
716
|
+
const currentRenewal = (async (): Promise<boolean> => {
|
|
717
|
+
if (
|
|
718
|
+
!(await this.#rescheduleAfterEffectTransition(
|
|
719
|
+
this.#effects.renew(effect.id, lease, this.#dispatchLeaseMs),
|
|
720
|
+
))
|
|
721
|
+
)
|
|
722
|
+
leaseLost = true;
|
|
723
|
+
return !leaseLost;
|
|
724
|
+
})();
|
|
725
|
+
renewal = currentRenewal;
|
|
726
|
+
try {
|
|
727
|
+
return await currentRenewal;
|
|
728
|
+
} finally {
|
|
729
|
+
if (renewal === currentRenewal) renewal = undefined;
|
|
730
|
+
}
|
|
731
|
+
};
|
|
732
|
+
const timer = setInterval(
|
|
733
|
+
() => {
|
|
734
|
+
void renew().catch(() => {});
|
|
735
|
+
},
|
|
736
|
+
Math.max(1, Math.floor(this.#dispatchLeaseMs / 3)),
|
|
737
|
+
);
|
|
738
|
+
try {
|
|
739
|
+
if (!(await renew())) return;
|
|
740
|
+
if (!this.#matches(record, endpoint) || receipt.endpointGeneration !== endpoint.generation) {
|
|
741
|
+
await this.#rescheduleAfterEffectTransition(
|
|
742
|
+
this.#effects.record(effect.id, lease, "accepted", {
|
|
743
|
+
status: this.#inboundAcceptedStatus(effect, "pre_send_binding_changed"),
|
|
744
|
+
}),
|
|
745
|
+
);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (!(await renew())) return;
|
|
750
|
+
const beforeSend = await this.#currentInboundRecord(record, receipt);
|
|
751
|
+
if (!beforeSend) {
|
|
752
|
+
await this.#terminalizeInbound(record, receipt, "stale_binding", lease);
|
|
753
|
+
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
record = beforeSend;
|
|
757
|
+
if (!this.#matches(record, endpoint) || receipt.endpointGeneration !== endpoint.generation) {
|
|
758
|
+
await this.#rescheduleAfterEffectTransition(
|
|
759
|
+
this.#effects.record(effect.id, lease, "accepted", {
|
|
760
|
+
status: this.#inboundAcceptedStatus(effect, "pre_send_binding_changed"),
|
|
761
|
+
}),
|
|
762
|
+
);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (effect.payload.type === "command")
|
|
767
|
+
await this.options.onCommand?.(
|
|
768
|
+
record.sessionId!,
|
|
769
|
+
effect.payload.content,
|
|
770
|
+
endpoint,
|
|
771
|
+
effect.payload.idempotencyKey,
|
|
772
|
+
);
|
|
773
|
+
else await endpoint.send(effect.payload);
|
|
774
|
+
if (!leaseLost && (await renew()) && (await this.#effects.record(effect.id, lease, "terminal")))
|
|
775
|
+
await this.#finishInbound(record, receipt);
|
|
776
|
+
} catch (error) {
|
|
777
|
+
if (!leaseLost) {
|
|
778
|
+
const state = this.#isDefiniteSdkPreSendFailure(error) ? "accepted" : "uncertain";
|
|
779
|
+
await this.#rescheduleAfterEffectTransition(
|
|
780
|
+
this.#effects.record(effect.id, lease, state, {
|
|
781
|
+
status: state === "accepted" ? this.#inboundAcceptedStatus(effect, "pre_send_failure") : "uncertain",
|
|
782
|
+
}),
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
} finally {
|
|
786
|
+
clearInterval(timer);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
#hasDurableDeferIntent(effect: ChatEffect<DiscordInboundEffectPayload>): boolean {
|
|
790
|
+
return (
|
|
791
|
+
effect.kind === "discord.inbound.action" &&
|
|
792
|
+
(effect.receipt?.status === "defer_intent" || effect.receipt?.status === "deferred")
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
#inboundAcceptedStatus(effect: ChatEffect<DiscordInboundEffectPayload>, fallback: string): string {
|
|
796
|
+
return this.#hasDurableDeferIntent(effect) ? (effect.receipt?.status ?? fallback) : fallback;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
#hasLiveCallbackLease(effect: ChatEffect | undefined): boolean {
|
|
800
|
+
return (
|
|
801
|
+
effect?.kind === "discord.inbound.action" &&
|
|
802
|
+
effect.state === "leased" &&
|
|
803
|
+
typeof effect.owner === "string" &&
|
|
804
|
+
(effect.leaseExpiresAt ?? 0) > this.#now()
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async #drainPendingDispatches(): Promise<boolean> {
|
|
809
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
810
|
+
let recoveryFailed = false;
|
|
811
|
+
const dispatched = new Set<string>();
|
|
812
|
+
for (const record of Object.values((await this.#store.load()).conversations)) {
|
|
813
|
+
if (!record.threadId || !record.sessionId || record.state !== "active") continue;
|
|
814
|
+
let endpoint: DiscordEndpointBinding | null = null;
|
|
815
|
+
let endpointResolved = false;
|
|
816
|
+
for (const [index, receipt] of (record.inboundDispatches ?? []).entries()) {
|
|
817
|
+
// Never race a foreground handleInbound() dispatch: while an effect is
|
|
818
|
+
// in-flight it briefly unowns itself (record "accepted"/"deferred" before
|
|
819
|
+
// reclaiming), a window in which recovery could otherwise claim it and
|
|
820
|
+
// deliver its reply on the recovery timer instead of the awaited path.
|
|
821
|
+
if (this.#inflightInbound.has(receipt.effectId)) {
|
|
822
|
+
dispatched.add(receipt.effectId);
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (
|
|
826
|
+
receipt.kind === "action" &&
|
|
827
|
+
(record.inboundDispatches ?? [])
|
|
828
|
+
.slice(0, index)
|
|
829
|
+
.some(
|
|
830
|
+
candidate =>
|
|
831
|
+
candidate.kind === "action" &&
|
|
832
|
+
candidate.actionId === receipt.actionId &&
|
|
833
|
+
candidate.actionNonce === receipt.actionNonce,
|
|
834
|
+
)
|
|
835
|
+
) {
|
|
836
|
+
await this.#terminalizeInbound(record, receipt, "duplicate_action");
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
const effect = await this.#effects.read(receipt.effectId);
|
|
840
|
+
if (!effect) {
|
|
841
|
+
// Referenced effects are retained by terminal pruning. Preserve the
|
|
842
|
+
// receipt and retry recovery rather than acknowledging lost authority.
|
|
843
|
+
recoveryFailed = true;
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
if (effect?.state === "terminal") {
|
|
847
|
+
await this.#finishInbound(record, receipt);
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (this.#hasLiveCallbackLease(effect)) {
|
|
851
|
+
dispatched.add(receipt.effectId);
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (!endpointResolved) {
|
|
855
|
+
endpoint = await this.#resolveEndpoint(record.sessionId, receipt.endpointGeneration);
|
|
856
|
+
endpointResolved = true;
|
|
857
|
+
}
|
|
858
|
+
if (!this.#matches(record, endpoint) || endpoint.generation !== receipt.endpointGeneration) {
|
|
859
|
+
await this.#terminalizeInbound(record, receipt, "stale_binding");
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
dispatched.add(receipt.effectId);
|
|
863
|
+
await this.#dispatchInbound(record, endpoint, receipt);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Effects are the authority: a crash between enqueue and mapping receipt
|
|
868
|
+
// publication must not strand a command at restart. Adoption revalidates
|
|
869
|
+
// the current mapping before an orphan can reach the SDK.
|
|
870
|
+
for (const effect of await this.#effects.list()) {
|
|
871
|
+
if (
|
|
872
|
+
effect.transport !== "discord" ||
|
|
873
|
+
effect.state === "terminal" ||
|
|
874
|
+
!effect.kind.startsWith("discord.inbound.") ||
|
|
875
|
+
dispatched.has(effect.id) ||
|
|
876
|
+
this.#inflightInbound.has(effect.id) ||
|
|
877
|
+
this.#hasLiveCallbackLease(effect)
|
|
878
|
+
)
|
|
879
|
+
continue;
|
|
880
|
+
const payload = effect.payload as DiscordInboundEffectPayload;
|
|
881
|
+
const routing = payload.routing;
|
|
882
|
+
if (!routing) continue;
|
|
883
|
+
const adopted = await this.#adoptOrphanInbound(
|
|
884
|
+
effect.id,
|
|
885
|
+
effect.sessionId,
|
|
886
|
+
effect.endpointGeneration,
|
|
887
|
+
payload,
|
|
888
|
+
routing,
|
|
889
|
+
);
|
|
890
|
+
if (!adopted) continue;
|
|
891
|
+
const endpoint = await this.#resolveEndpoint(adopted.record.sessionId!, effect.endpointGeneration);
|
|
892
|
+
if (!this.#matches(adopted.record, endpoint) || endpoint.generation !== effect.endpointGeneration) {
|
|
893
|
+
await this.#terminalizeInbound(adopted.record, adopted.receipt, "stale_binding");
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
await this.#dispatchInbound(adopted.record, endpoint, adopted.receipt);
|
|
897
|
+
}
|
|
898
|
+
return recoveryFailed;
|
|
899
|
+
}
|
|
900
|
+
async #adoptOrphanInbound(
|
|
901
|
+
effectId: string,
|
|
902
|
+
sessionId: string | undefined,
|
|
903
|
+
endpointGeneration: number,
|
|
904
|
+
payload: DiscordInboundEffectPayload,
|
|
905
|
+
routing: DiscordInboundRouting,
|
|
906
|
+
): Promise<{ record: DiscordConversation; receipt: DiscordInboundDispatchReceipt } | undefined> {
|
|
907
|
+
const key = discordConversationKey({
|
|
908
|
+
appId: this.options.provider.applicationId,
|
|
909
|
+
guildId: routing.guildId,
|
|
910
|
+
parentChannelId: routing.parentId,
|
|
911
|
+
threadId: routing.threadId,
|
|
912
|
+
});
|
|
913
|
+
const expectedId = `discord:${this.options.provider.applicationId}:${routing.guildId}:${routing.parentId}:${routing.threadId}:${routing.eventId}`;
|
|
914
|
+
let record: DiscordConversation | undefined;
|
|
915
|
+
let receipt: DiscordInboundDispatchReceipt | undefined;
|
|
916
|
+
const structurallyValid =
|
|
917
|
+
effectId === expectedId &&
|
|
918
|
+
payload.idempotencyKey === effectId &&
|
|
919
|
+
(payload.type === "command"
|
|
920
|
+
? routing.kind === "command"
|
|
921
|
+
: routing.kind === "action" && payload.id === routing.actionId && typeof routing.actionNonce === "string");
|
|
922
|
+
await this.#store.transact(key, current => {
|
|
923
|
+
if (
|
|
924
|
+
!structurallyValid ||
|
|
925
|
+
!current ||
|
|
926
|
+
current.state !== "active" ||
|
|
927
|
+
current.sessionId !== sessionId ||
|
|
928
|
+
current.endpointGeneration !== endpointGeneration
|
|
929
|
+
)
|
|
930
|
+
return current;
|
|
931
|
+
const existing = (current.inboundDispatches ?? []).find(
|
|
932
|
+
candidate =>
|
|
933
|
+
candidate.eventId === routing.eventId ||
|
|
934
|
+
(routing.interactionId !== undefined && candidate.interactionId === routing.interactionId),
|
|
935
|
+
);
|
|
936
|
+
if (existing) {
|
|
937
|
+
if (
|
|
938
|
+
existing.effectId !== effectId ||
|
|
939
|
+
existing.idempotencyKey !== payload.idempotencyKey ||
|
|
940
|
+
existing.endpointGeneration !== endpointGeneration ||
|
|
941
|
+
existing.kind !== routing.kind ||
|
|
942
|
+
(existing.kind === "action" &&
|
|
943
|
+
(existing.actionId !== routing.actionId ||
|
|
944
|
+
existing.actionNonce !== routing.actionNonce ||
|
|
945
|
+
existing.interactionId !== routing.interactionId))
|
|
946
|
+
)
|
|
947
|
+
return current;
|
|
948
|
+
|
|
949
|
+
record = current;
|
|
950
|
+
receipt = existing;
|
|
951
|
+
return current;
|
|
952
|
+
}
|
|
953
|
+
if (
|
|
954
|
+
current.seenEventIds.includes(routing.eventId) ||
|
|
955
|
+
(routing.interactionId !== undefined && current.seenInteractionIds.includes(routing.interactionId)) ||
|
|
956
|
+
(routing.kind === "action" &&
|
|
957
|
+
(current.pendingActionId !== routing.actionId || current.pendingActionNonce !== routing.actionNonce))
|
|
958
|
+
)
|
|
959
|
+
return current;
|
|
960
|
+
if (
|
|
961
|
+
routing.kind === "action" &&
|
|
962
|
+
(current.inboundDispatches ?? []).some(
|
|
963
|
+
candidate =>
|
|
964
|
+
candidate.kind === "action" &&
|
|
965
|
+
candidate.actionId === routing.actionId &&
|
|
966
|
+
candidate.actionNonce === routing.actionNonce,
|
|
967
|
+
)
|
|
968
|
+
)
|
|
969
|
+
return current;
|
|
970
|
+
|
|
971
|
+
receipt = this.#receiptFromRouting(effectId, endpointGeneration, payload.idempotencyKey, routing);
|
|
972
|
+
record = normalizeDiscordConversation({
|
|
973
|
+
...current,
|
|
974
|
+
generation: current.generation + 1,
|
|
975
|
+
updatedAt: this.#now(),
|
|
976
|
+
inboundDispatches: [...(current.inboundDispatches ?? []), receipt],
|
|
977
|
+
});
|
|
978
|
+
return record;
|
|
979
|
+
});
|
|
980
|
+
if (record && receipt) return { record, receipt };
|
|
981
|
+
await this.#terminalizeRejectedInbound(effectId);
|
|
982
|
+
return undefined;
|
|
983
|
+
}
|
|
984
|
+
#sameInboundReceipt(left: DiscordInboundDispatchReceipt, right: DiscordInboundDispatchReceipt): boolean {
|
|
985
|
+
return (
|
|
986
|
+
left.key === right.key &&
|
|
987
|
+
left.eventId === right.eventId &&
|
|
988
|
+
left.interactionId === right.interactionId &&
|
|
989
|
+
left.kind === right.kind &&
|
|
990
|
+
left.actionId === right.actionId &&
|
|
991
|
+
left.actionNonce === right.actionNonce &&
|
|
992
|
+
left.endpointGeneration === right.endpointGeneration &&
|
|
993
|
+
left.effectId === right.effectId &&
|
|
994
|
+
left.idempotencyKey === right.idempotencyKey
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
#completeInbound(record: DiscordConversation, receipt: DiscordInboundDispatchReceipt): DiscordConversation {
|
|
998
|
+
const clearsAction =
|
|
999
|
+
receipt.kind === "action" &&
|
|
1000
|
+
record.pendingActionId === receipt.actionId &&
|
|
1001
|
+
record.pendingActionNonce === receipt.actionNonce;
|
|
1002
|
+
return normalizeDiscordConversation({
|
|
1003
|
+
...record,
|
|
1004
|
+
generation: record.generation + 1,
|
|
1005
|
+
updatedAt: this.#now(),
|
|
1006
|
+
pendingActionId: clearsAction ? undefined : record.pendingActionId,
|
|
1007
|
+
pendingActionNonce: clearsAction ? undefined : record.pendingActionNonce,
|
|
1008
|
+
pendingActionEffectId: clearsAction ? undefined : record.pendingActionEffectId,
|
|
1009
|
+
seenEventIds: [...record.seenEventIds, receipt.eventId],
|
|
1010
|
+
seenInteractionIds:
|
|
1011
|
+
receipt.interactionId === undefined
|
|
1012
|
+
? record.seenInteractionIds
|
|
1013
|
+
: [...record.seenInteractionIds, receipt.interactionId],
|
|
1014
|
+
inboundDispatches: (record.inboundDispatches ?? []).filter(
|
|
1015
|
+
candidate => !this.#sameInboundReceipt(candidate, receipt),
|
|
1016
|
+
),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
#hasLiveEffectLease(effect: ChatEffect | undefined): boolean {
|
|
1020
|
+
return (
|
|
1021
|
+
effect?.state === "leased" && typeof effect.owner === "string" && (effect.leaseExpiresAt ?? 0) > this.#now()
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
async #terminalizeEffect(id: string, status: string, lease?: ChatEffectLease): Promise<boolean> {
|
|
1025
|
+
if (lease) {
|
|
1026
|
+
const terminalized = await this.#effects.record(id, lease, "terminal", { status });
|
|
1027
|
+
return terminalized?.state === "terminal";
|
|
1028
|
+
}
|
|
1029
|
+
const effect = await this.#effects.read(id);
|
|
1030
|
+
if (!effect || effect.state === "terminal") return true;
|
|
1031
|
+
if (effect.state === "uncertain" || this.#hasLiveEffectLease(effect)) return false;
|
|
1032
|
+
if (effect.state === "leased") {
|
|
1033
|
+
const claimed = await this.#effects.claim(id, this.#providerOwner, this.#providerLeaseMs);
|
|
1034
|
+
if (!claimed) return false;
|
|
1035
|
+
await this.#effects.record(id, { owner: this.#providerOwner, epoch: claimed.epoch }, "uncertain", {
|
|
1036
|
+
status: "stale_lease_expired",
|
|
1037
|
+
});
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
return (await this.#effects.terminalize(id, { status }))?.state === "terminal";
|
|
1041
|
+
}
|
|
1042
|
+
async #terminalizeRejectedInbound(effectId: string, lease?: ChatEffectLease): Promise<void> {
|
|
1043
|
+
await this.#terminalizeEffect(effectId, "rejected", lease);
|
|
1044
|
+
}
|
|
1045
|
+
async #terminalizeInbound(
|
|
1046
|
+
record: DiscordConversation,
|
|
1047
|
+
receipt: DiscordInboundDispatchReceipt,
|
|
1048
|
+
status: string,
|
|
1049
|
+
lease?: ChatEffectLease,
|
|
1050
|
+
): Promise<void> {
|
|
1051
|
+
if (!(await this.#terminalizeEffect(receipt.effectId, status, lease))) return;
|
|
1052
|
+
const key = discordConversationKey({
|
|
1053
|
+
appId: record.appId,
|
|
1054
|
+
guildId: record.guildId,
|
|
1055
|
+
parentChannelId: record.parentChannelId,
|
|
1056
|
+
threadId: record.threadId!,
|
|
1057
|
+
});
|
|
1058
|
+
await this.#store.transact(key, current => {
|
|
1059
|
+
const matching = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
|
|
1060
|
+
return !current || !matching ? current : this.#completeInbound(current, matching);
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
async #currentInboundRecord(
|
|
1064
|
+
record: DiscordConversation,
|
|
1065
|
+
receipt: DiscordInboundDispatchReceipt,
|
|
1066
|
+
): Promise<DiscordConversation | undefined> {
|
|
1067
|
+
const current = await this.#byThread(record.guildId, record.parentChannelId, record.threadId!);
|
|
1068
|
+
const claimed = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
|
|
1069
|
+
if (
|
|
1070
|
+
current?.state !== "active" ||
|
|
1071
|
+
current.sessionId !== record.sessionId ||
|
|
1072
|
+
current.endpointGeneration !== receipt.endpointGeneration ||
|
|
1073
|
+
!claimed
|
|
1074
|
+
)
|
|
1075
|
+
return undefined;
|
|
1076
|
+
if (
|
|
1077
|
+
receipt.kind === "action" &&
|
|
1078
|
+
(current.pendingActionId !== receipt.actionId || current.pendingActionNonce !== receipt.actionNonce)
|
|
1079
|
+
)
|
|
1080
|
+
return undefined;
|
|
1081
|
+
|
|
1082
|
+
return current;
|
|
1083
|
+
}
|
|
1084
|
+
#receiptFromRouting(
|
|
1085
|
+
effectId: string,
|
|
1086
|
+
endpointGeneration: number,
|
|
1087
|
+
idempotencyKey: string,
|
|
1088
|
+
routing: DiscordInboundRouting,
|
|
1089
|
+
): DiscordInboundDispatchReceipt {
|
|
1090
|
+
return routing.kind === "command"
|
|
1091
|
+
? {
|
|
1092
|
+
key: routing.eventId,
|
|
1093
|
+
eventId: routing.eventId,
|
|
1094
|
+
kind: "command",
|
|
1095
|
+
endpointGeneration,
|
|
1096
|
+
effectId,
|
|
1097
|
+
idempotencyKey,
|
|
1098
|
+
}
|
|
1099
|
+
: {
|
|
1100
|
+
key: routing.eventId,
|
|
1101
|
+
eventId: routing.eventId,
|
|
1102
|
+
interactionId: routing.interactionId!,
|
|
1103
|
+
kind: "action",
|
|
1104
|
+
actionId: routing.actionId!,
|
|
1105
|
+
actionNonce: routing.actionNonce!,
|
|
1106
|
+
endpointGeneration,
|
|
1107
|
+
effectId,
|
|
1108
|
+
idempotencyKey,
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
async #finishInbound(record: DiscordConversation, receipt: DiscordInboundDispatchReceipt): Promise<void> {
|
|
1113
|
+
const key = discordConversationKey({
|
|
1114
|
+
appId: record.appId,
|
|
1115
|
+
guildId: record.guildId,
|
|
1116
|
+
parentChannelId: record.parentChannelId,
|
|
1117
|
+
threadId: record.threadId!,
|
|
1118
|
+
});
|
|
1119
|
+
await this.#store.transact(key, current => {
|
|
1120
|
+
const matching = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
|
|
1121
|
+
return !current || !matching ? current : this.#completeInbound(current, matching);
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
async #reconcileTerminalInboundReceipts(): Promise<void> {
|
|
1125
|
+
for (const effect of await this.#effects.list()) {
|
|
1126
|
+
if (
|
|
1127
|
+
effect.transport !== "discord" ||
|
|
1128
|
+
effect.state !== "terminal" ||
|
|
1129
|
+
(effect.kind !== "discord.inbound.command" && effect.kind !== "discord.inbound.action")
|
|
1130
|
+
)
|
|
1131
|
+
continue;
|
|
1132
|
+
const payload = effect.payload as DiscordInboundEffectPayload;
|
|
1133
|
+
const routing = payload?.routing;
|
|
1134
|
+
if (
|
|
1135
|
+
!routing ||
|
|
1136
|
+
!effect.sessionId ||
|
|
1137
|
+
!routing.guildId ||
|
|
1138
|
+
!routing.parentId ||
|
|
1139
|
+
!routing.threadId ||
|
|
1140
|
+
!routing.eventId ||
|
|
1141
|
+
payload.idempotencyKey !== effect.id ||
|
|
1142
|
+
effect.id !==
|
|
1143
|
+
`discord:${this.options.provider.applicationId}:${routing.guildId}:${routing.parentId}:${routing.threadId}:${routing.eventId}` ||
|
|
1144
|
+
(payload.type === "command"
|
|
1145
|
+
? routing.kind !== "command" || effect.kind !== "discord.inbound.command"
|
|
1146
|
+
: routing.kind !== "action" ||
|
|
1147
|
+
effect.kind !== "discord.inbound.action" ||
|
|
1148
|
+
!routing.interactionId ||
|
|
1149
|
+
!routing.actionId ||
|
|
1150
|
+
!routing.actionNonce ||
|
|
1151
|
+
payload.id !== routing.actionId)
|
|
1152
|
+
)
|
|
1153
|
+
continue;
|
|
1154
|
+
const receipt = this.#receiptFromRouting(
|
|
1155
|
+
effect.id,
|
|
1156
|
+
effect.endpointGeneration,
|
|
1157
|
+
payload.idempotencyKey,
|
|
1158
|
+
routing,
|
|
1159
|
+
);
|
|
1160
|
+
const record = await this.#byThread(routing.guildId, routing.parentId, routing.threadId);
|
|
1161
|
+
if (record?.inboundDispatches?.some(candidate => this.#sameInboundReceipt(candidate, receipt)))
|
|
1162
|
+
await this.#finishInbound(record, receipt);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
#matches(record: DiscordConversation, endpoint: DiscordEndpointBinding | null): endpoint is DiscordEndpointBinding {
|
|
1167
|
+
if (!endpoint?.isCurrent()) return false;
|
|
1168
|
+
return record.state === "active" && record.endpointGeneration === endpoint.generation;
|
|
1169
|
+
}
|
|
1170
|
+
async #bindingCurrent(sessionId: string, endpointGeneration: number): Promise<boolean> {
|
|
1171
|
+
try {
|
|
1172
|
+
const endpoint = await this.#resolveEndpoint(sessionId, endpointGeneration);
|
|
1173
|
+
return !!endpoint && endpoint.isCurrent() && endpoint.generation === endpointGeneration;
|
|
1174
|
+
} catch {
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
async #requireLiveBinding(sessionId: string, endpointGeneration: number): Promise<void> {
|
|
1179
|
+
if (!(await this.#bindingCurrent(sessionId, endpointGeneration))) throw new DiscordEndpointBindingError();
|
|
1180
|
+
}
|
|
1181
|
+
#isDefiniteSdkPreSendFailure(error: unknown): boolean {
|
|
1182
|
+
if (error instanceof DiscordEndpointBindingError) return true;
|
|
1183
|
+
if (error instanceof SdkClientError) return error.code === "connection_closed";
|
|
1184
|
+
return (
|
|
1185
|
+
error instanceof Error &&
|
|
1186
|
+
error.name === "ChatDeliveryError" &&
|
|
1187
|
+
(error as ChatDeliveryError).phase === "pre_send"
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
#isDefiniteCallbackPreSendFailure(error: unknown): boolean {
|
|
1191
|
+
// This is intentionally limited to Discord's received-and-rejected callback
|
|
1192
|
+
// response. Transport errors, connection loss, and arbitrary provider errors
|
|
1193
|
+
// leave the persisted defer intent authoritative because the callback may have
|
|
1194
|
+
// been accepted before its HTTP response was lost.
|
|
1195
|
+
return error instanceof Error && /^Discord API request failed \(4\d\d\)$/.test(error.message);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async #ensureConversation(input: DiscordNotificationInput): Promise<DiscordConversation> {
|
|
1199
|
+
const existing = await this.#bySession(input.sessionId);
|
|
1200
|
+
if (existing && closingIntent(existing)) {
|
|
1201
|
+
await this.#driveClose(existing);
|
|
1202
|
+
throw new Error("Discord thread is closing");
|
|
1203
|
+
}
|
|
1204
|
+
if (existing?.state === "active" && existing.threadId) {
|
|
1205
|
+
if (existing.endpointGeneration === input.endpointGeneration) return existing;
|
|
1206
|
+
await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
|
|
1207
|
+
return await this.#replace(existing, { ...existing, endpointGeneration: input.endpointGeneration });
|
|
1208
|
+
}
|
|
1209
|
+
const inFlight = this.#creates.get(input.sessionId);
|
|
1210
|
+
if (inFlight) {
|
|
1211
|
+
let created: DiscordConversation;
|
|
1212
|
+
try {
|
|
1213
|
+
created = await inFlight;
|
|
1214
|
+
} catch {
|
|
1215
|
+
await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
|
|
1216
|
+
return await this.#ensureConversation(input);
|
|
1217
|
+
}
|
|
1218
|
+
if (created.endpointGeneration === input.endpointGeneration) return created;
|
|
1219
|
+
await this.#requireLiveBinding(input.sessionId, input.endpointGeneration);
|
|
1220
|
+
return await this.#replace(created, { ...created, endpointGeneration: input.endpointGeneration });
|
|
1221
|
+
}
|
|
1222
|
+
const pending = this.#create(input.sessionId, input.endpointGeneration, randomUUID(), input.threadName);
|
|
1223
|
+
this.#creates.set(input.sessionId, pending);
|
|
1224
|
+
try {
|
|
1225
|
+
return await pending;
|
|
1226
|
+
} finally {
|
|
1227
|
+
this.#creates.delete(input.sessionId);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
async #create(
|
|
1232
|
+
sessionId: string,
|
|
1233
|
+
endpointGeneration: number,
|
|
1234
|
+
nonce: string,
|
|
1235
|
+
name = "GJC session",
|
|
1236
|
+
): Promise<DiscordConversation> {
|
|
1237
|
+
const intentKey = this.#intentKey(sessionId);
|
|
1238
|
+
const owner = randomUUID();
|
|
1239
|
+
let intent: DiscordConversation | undefined;
|
|
1240
|
+
for (;;) {
|
|
1241
|
+
const active = await this.#bySession(sessionId);
|
|
1242
|
+
if (active && closingIntent(active)) {
|
|
1243
|
+
await this.#driveClose(active);
|
|
1244
|
+
throw new Error("Discord thread is closing");
|
|
1245
|
+
}
|
|
1246
|
+
if (active?.state === "active" && active.threadId) {
|
|
1247
|
+
if (active.endpointGeneration === endpointGeneration) return active;
|
|
1248
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
1249
|
+
return await this.#replace(active, { ...active, endpointGeneration });
|
|
1250
|
+
}
|
|
1251
|
+
const now = this.#now();
|
|
1252
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
1253
|
+
intent = await this.#store.transact(intentKey, old => {
|
|
1254
|
+
if (old?.state === "creating" && old.createOwner && (old.createLeaseExpiresAt ?? 0) > now) return old;
|
|
1255
|
+
return {
|
|
1256
|
+
generation: (old?.generation ?? 0) + 1,
|
|
1257
|
+
state: "creating",
|
|
1258
|
+
appId: this.options.provider.applicationId,
|
|
1259
|
+
guildId: this.options.guildId,
|
|
1260
|
+
parentChannelId: this.options.parentChannelId,
|
|
1261
|
+
sessionId,
|
|
1262
|
+
endpointGeneration,
|
|
1263
|
+
createNonce: old?.createNonce ?? nonce,
|
|
1264
|
+
createOwner: owner,
|
|
1265
|
+
createLeaseExpiresAt: now + 60_000,
|
|
1266
|
+
updatedAt: now,
|
|
1267
|
+
seenEventIds: [],
|
|
1268
|
+
seenInteractionIds: [],
|
|
1269
|
+
};
|
|
1270
|
+
});
|
|
1271
|
+
if (!intent) throw new Error("Unable to persist Discord create intent");
|
|
1272
|
+
if (intent.createOwner === owner) break;
|
|
1273
|
+
await Bun.sleep(Math.min(25, Math.max(1, (intent.createLeaseExpiresAt ?? now) - now)));
|
|
1274
|
+
}
|
|
1275
|
+
const active = await this.#bySession(sessionId);
|
|
1276
|
+
if (active?.state === "active" && active.threadId) {
|
|
1277
|
+
if (active.endpointGeneration === endpointGeneration) return active;
|
|
1278
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
1279
|
+
return await this.#replace(active, { ...active, endpointGeneration });
|
|
1280
|
+
}
|
|
1281
|
+
let thread: DiscordThread | null;
|
|
1282
|
+
try {
|
|
1283
|
+
thread = await this.#withCreateIntentLease(intent, () => this.#createThreadEffect(intent, name));
|
|
1284
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
await this.#abandonCreator(intentKey, intent);
|
|
1287
|
+
throw error;
|
|
1288
|
+
}
|
|
1289
|
+
const currentIntent = await this.#store.read(intentKey);
|
|
1290
|
+
if (
|
|
1291
|
+
currentIntent?.state !== "creating" ||
|
|
1292
|
+
currentIntent.createOwner !== intent.createOwner ||
|
|
1293
|
+
currentIntent.generation !== intent.generation ||
|
|
1294
|
+
(currentIntent.createLeaseExpiresAt ?? 0) <= this.#now()
|
|
1295
|
+
) {
|
|
1296
|
+
throw new Error("Discord create intent lost its fence before mapping commit");
|
|
1297
|
+
}
|
|
1298
|
+
await this.#requireLiveBinding(sessionId, endpointGeneration);
|
|
1299
|
+
const key = discordConversationKey({
|
|
1300
|
+
appId: intent.appId,
|
|
1301
|
+
guildId: intent.guildId,
|
|
1302
|
+
parentChannelId: intent.parentChannelId,
|
|
1303
|
+
threadId: thread.id,
|
|
1304
|
+
});
|
|
1305
|
+
const record = await this.#store.transact(key, old =>
|
|
1306
|
+
normalizeDiscordConversation({
|
|
1307
|
+
generation: (old?.generation ?? 0) + 1,
|
|
1308
|
+
state: "active",
|
|
1309
|
+
appId: intent.appId,
|
|
1310
|
+
guildId: intent.guildId,
|
|
1311
|
+
parentChannelId: intent.parentChannelId,
|
|
1312
|
+
threadId: thread.id,
|
|
1313
|
+
sessionId,
|
|
1314
|
+
endpointGeneration,
|
|
1315
|
+
createNonce: intent.createNonce,
|
|
1316
|
+
effectIncarnationId: old?.effectIncarnationId ?? intent.effectIncarnationId ?? randomUUID(),
|
|
1317
|
+
updatedAt: this.#now(),
|
|
1318
|
+
seenEventIds: old?.seenEventIds ?? [],
|
|
1319
|
+
seenInteractionIds: old?.seenInteractionIds ?? [],
|
|
1320
|
+
inboundDispatches: old?.inboundDispatches,
|
|
1321
|
+
}),
|
|
1322
|
+
);
|
|
1323
|
+
if (!record) throw new Error("Unable to persist Discord thread mapping");
|
|
1324
|
+
await this.#store.delete(intentKey, intent.generation);
|
|
1325
|
+
return record;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async #sessionMappings(sessionId: string): Promise<DiscordConversation[]> {
|
|
1329
|
+
return Object.values((await this.#store.load()).conversations).filter(
|
|
1330
|
+
record => record.sessionId === sessionId && record.state !== "creating",
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
async #bySession(sessionId: string): Promise<DiscordConversation | undefined> {
|
|
1334
|
+
const document = await this.#store.load();
|
|
1335
|
+
return Object.values(document.conversations)
|
|
1336
|
+
.filter(record => record.sessionId === sessionId && record.state !== "creating")
|
|
1337
|
+
.sort(
|
|
1338
|
+
(left, right) =>
|
|
1339
|
+
stateRank(left.state) - stateRank(right.state) ||
|
|
1340
|
+
right.generation - left.generation ||
|
|
1341
|
+
right.updatedAt - left.updatedAt,
|
|
1342
|
+
)[0];
|
|
1343
|
+
}
|
|
1344
|
+
async #byThread(
|
|
1345
|
+
guildId: string,
|
|
1346
|
+
parentChannelId: string,
|
|
1347
|
+
threadId: string,
|
|
1348
|
+
): Promise<DiscordConversation | undefined> {
|
|
1349
|
+
return await this.#store.read(
|
|
1350
|
+
discordConversationKey({ appId: this.options.provider.applicationId, guildId, parentChannelId, threadId }),
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
#closeMarkerEffectId(record: DiscordConversation): string {
|
|
1354
|
+
const intent = closingIntent(record);
|
|
1355
|
+
if (!record.threadId || !intent) throw new Error("Discord close intent is unavailable.");
|
|
1356
|
+
return `close-marker:${record.threadId}:${intent.nonce}`;
|
|
1357
|
+
}
|
|
1358
|
+
#closeArchiveEffectId(record: DiscordConversation): string {
|
|
1359
|
+
const intent = closingIntent(record);
|
|
1360
|
+
if (!record.threadId || !intent) throw new Error("Discord close intent is unavailable.");
|
|
1361
|
+
return `close-archive:${record.threadId}:${intent.nonce}`;
|
|
1362
|
+
}
|
|
1363
|
+
async #markClosing(sessionId: string): Promise<DiscordConversation | undefined> {
|
|
1364
|
+
const record = await this.#bySession(sessionId);
|
|
1365
|
+
if (!record?.threadId || record.state === "closed") return undefined;
|
|
1366
|
+
const key = discordConversationKey({
|
|
1367
|
+
appId: record.appId,
|
|
1368
|
+
guildId: record.guildId,
|
|
1369
|
+
parentChannelId: record.parentChannelId,
|
|
1370
|
+
threadId: record.threadId,
|
|
1371
|
+
});
|
|
1372
|
+
let closing: DiscordConversation | undefined;
|
|
1373
|
+
await this.#store.transact(key, current => {
|
|
1374
|
+
if (!current || current.sessionId !== sessionId || !current.threadId || current.state === "closed")
|
|
1375
|
+
return current;
|
|
1376
|
+
if (closingIntent(current)) {
|
|
1377
|
+
closing = current;
|
|
1378
|
+
return current;
|
|
1379
|
+
}
|
|
1380
|
+
closing = normalizeDiscordConversation(withClosingIntent(current, randomUUID(), this.#now()));
|
|
1381
|
+
return closing;
|
|
1382
|
+
});
|
|
1383
|
+
return closing;
|
|
1384
|
+
}
|
|
1385
|
+
async #driveClose(record: DiscordConversation): Promise<void> {
|
|
1386
|
+
if (!record.threadId) return;
|
|
1387
|
+
const key = discordConversationKey({
|
|
1388
|
+
appId: record.appId,
|
|
1389
|
+
guildId: record.guildId,
|
|
1390
|
+
parentChannelId: record.parentChannelId,
|
|
1391
|
+
threadId: record.threadId,
|
|
1392
|
+
});
|
|
1393
|
+
let current = await this.#store.read(key);
|
|
1394
|
+
if (!current || !closingIntent(current)) return;
|
|
1395
|
+
for (const receipt of current.inboundDispatches ?? [])
|
|
1396
|
+
await this.#terminalizeInbound(current, receipt, "closing");
|
|
1397
|
+
current = await this.#store.read(key);
|
|
1398
|
+
if (!current || !closingIntent(current)) return;
|
|
1399
|
+
const closingRecord = current;
|
|
1400
|
+
const intent = closingIntent(closingRecord)!;
|
|
1401
|
+
await this.#postEffect(
|
|
1402
|
+
this.#closeMarkerEffectId(closingRecord),
|
|
1403
|
+
closingRecord,
|
|
1404
|
+
"This conversation is closed.",
|
|
1405
|
+
undefined,
|
|
1406
|
+
false,
|
|
1407
|
+
true,
|
|
1408
|
+
);
|
|
1409
|
+
await this.#threadEffect(this.#closeArchiveEffectId(closingRecord), closingRecord, "archive", true, true);
|
|
1410
|
+
await this.#store.transact(key, candidate => {
|
|
1411
|
+
const candidateIntent = closingIntent(candidate);
|
|
1412
|
+
return candidate && candidate.sessionId === closingRecord.sessionId && candidateIntent?.nonce === intent.nonce
|
|
1413
|
+
? normalizeDiscordConversation(withoutClosingIntent(candidate, this.#now()))
|
|
1414
|
+
: candidate;
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
async #recoverClosingConversations(): Promise<boolean> {
|
|
1418
|
+
let failed = false;
|
|
1419
|
+
for (const record of Object.values((await this.#store.load()).conversations)) {
|
|
1420
|
+
if (!closingIntent(record)) continue;
|
|
1421
|
+
try {
|
|
1422
|
+
await this.#driveClose(record);
|
|
1423
|
+
} catch {
|
|
1424
|
+
failed = true;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
return failed;
|
|
1428
|
+
}
|
|
1429
|
+
async #replace(
|
|
1430
|
+
current: DiscordConversation,
|
|
1431
|
+
next: Omit<DiscordConversation, "generation"> & { generation?: number },
|
|
1432
|
+
): Promise<DiscordConversation> {
|
|
1433
|
+
const key = current.threadId
|
|
1434
|
+
? discordConversationKey({
|
|
1435
|
+
appId: current.appId,
|
|
1436
|
+
guildId: current.guildId,
|
|
1437
|
+
parentChannelId: current.parentChannelId,
|
|
1438
|
+
threadId: current.threadId,
|
|
1439
|
+
})
|
|
1440
|
+
: this.#intentKey(current.sessionId!);
|
|
1441
|
+
const result = await this.#store.write(key, current.generation, {
|
|
1442
|
+
...next,
|
|
1443
|
+
generation: current.generation + 1,
|
|
1444
|
+
updatedAt: this.#now(),
|
|
1445
|
+
});
|
|
1446
|
+
if (!result) {
|
|
1447
|
+
const stored = await this.#store.read(key);
|
|
1448
|
+
throw new Error(
|
|
1449
|
+
`Discord conversation changed concurrently (key=${key}, expected=${current.generation}, actual=${stored?.generation ?? "missing"})`,
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1452
|
+
return (await this.#store.read(key))!;
|
|
1453
|
+
}
|
|
1454
|
+
async #beginArchive(record: DiscordConversation): Promise<DiscordConversation> {
|
|
1455
|
+
const key = discordConversationKey({
|
|
1456
|
+
appId: record.appId,
|
|
1457
|
+
guildId: record.guildId,
|
|
1458
|
+
parentChannelId: record.parentChannelId,
|
|
1459
|
+
threadId: record.threadId!,
|
|
1460
|
+
});
|
|
1461
|
+
let archiving: DiscordConversation | undefined;
|
|
1462
|
+
await this.#store.transact(key, current => {
|
|
1463
|
+
if (
|
|
1464
|
+
current?.state !== "active" ||
|
|
1465
|
+
current.sessionId !== record.sessionId ||
|
|
1466
|
+
current.endpointGeneration !== record.endpointGeneration
|
|
1467
|
+
)
|
|
1468
|
+
return current;
|
|
1469
|
+
if (current.archiveOccurrenceId) {
|
|
1470
|
+
archiving = current;
|
|
1471
|
+
return current;
|
|
1472
|
+
}
|
|
1473
|
+
const archiveOccurrenceId = randomUUID();
|
|
1474
|
+
const effectIncarnationId = backfilledEffectIncarnationId(current);
|
|
1475
|
+
archiving = normalizeDiscordConversation({
|
|
1476
|
+
...current,
|
|
1477
|
+
generation: current.generation + 1,
|
|
1478
|
+
updatedAt: this.#now(),
|
|
1479
|
+
effectIncarnationId,
|
|
1480
|
+
archiveOccurrenceId,
|
|
1481
|
+
archiveEffectId: `archive:${current.threadId}:${effectIncarnationId}:${archiveOccurrenceId}`,
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
return archiving;
|
|
1485
|
+
});
|
|
1486
|
+
if (!archiving) throw new Error("Discord archive intent lost its authority");
|
|
1487
|
+
return archiving;
|
|
1488
|
+
}
|
|
1489
|
+
async #completeArchive(record: DiscordConversation, occurrenceId: string): Promise<void> {
|
|
1490
|
+
const key = discordConversationKey({
|
|
1491
|
+
appId: record.appId,
|
|
1492
|
+
guildId: record.guildId,
|
|
1493
|
+
parentChannelId: record.parentChannelId,
|
|
1494
|
+
threadId: record.threadId!,
|
|
1495
|
+
});
|
|
1496
|
+
await this.#store.transact(key, current => {
|
|
1497
|
+
if (
|
|
1498
|
+
current?.state !== "active" ||
|
|
1499
|
+
current.sessionId !== record.sessionId ||
|
|
1500
|
+
current.archiveOccurrenceId !== occurrenceId
|
|
1501
|
+
)
|
|
1502
|
+
return current;
|
|
1503
|
+
return normalizeDiscordConversation({
|
|
1504
|
+
...current,
|
|
1505
|
+
generation: current.generation + 1,
|
|
1506
|
+
updatedAt: this.#now(),
|
|
1507
|
+
state: "archived",
|
|
1508
|
+
archivedAt: this.#now(),
|
|
1509
|
+
archiveOccurrenceId: undefined,
|
|
1510
|
+
archiveEffectId: undefined,
|
|
1511
|
+
});
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
async #completeResume(record: DiscordConversation, occurrenceId: string): Promise<DiscordConversation> {
|
|
1515
|
+
const key = discordConversationKey({
|
|
1516
|
+
appId: record.appId,
|
|
1517
|
+
guildId: record.guildId,
|
|
1518
|
+
parentChannelId: record.parentChannelId,
|
|
1519
|
+
threadId: record.threadId!,
|
|
1520
|
+
});
|
|
1521
|
+
const completed = await this.#store.transact(key, current => {
|
|
1522
|
+
if (
|
|
1523
|
+
!current ||
|
|
1524
|
+
current.sessionId !== record.sessionId ||
|
|
1525
|
+
current.endpointGeneration !== record.endpointGeneration
|
|
1526
|
+
)
|
|
1527
|
+
return current;
|
|
1528
|
+
if (current.state === "active" && current.resumeOccurrenceId === undefined) return current;
|
|
1529
|
+
if (current.state !== "resuming" || current.resumeOccurrenceId !== occurrenceId) return current;
|
|
1530
|
+
return normalizeDiscordConversation({
|
|
1531
|
+
...current,
|
|
1532
|
+
generation: current.generation + 1,
|
|
1533
|
+
updatedAt: this.#now(),
|
|
1534
|
+
state: "active",
|
|
1535
|
+
archivedAt: undefined,
|
|
1536
|
+
resumeOccurrenceId: undefined,
|
|
1537
|
+
resumeEffectId: undefined,
|
|
1538
|
+
});
|
|
1539
|
+
});
|
|
1540
|
+
if (completed?.state !== "active" || completed.sessionId !== record.sessionId)
|
|
1541
|
+
throw new Error("Discord resume occurrence lost its authority");
|
|
1542
|
+
return completed;
|
|
1543
|
+
}
|
|
1544
|
+
async #abandonCreator(intentKey: string, intent: DiscordConversation): Promise<void> {
|
|
1545
|
+
await this.#store.transact(intentKey, current => {
|
|
1546
|
+
if (!current || current.generation !== intent.generation || current.createOwner !== intent.createOwner)
|
|
1547
|
+
return current;
|
|
1548
|
+
return normalizeDiscordConversation({
|
|
1549
|
+
...current,
|
|
1550
|
+
generation: current.generation + 1,
|
|
1551
|
+
updatedAt: this.#now(),
|
|
1552
|
+
createOwner: undefined,
|
|
1553
|
+
createLeaseExpiresAt: undefined,
|
|
1554
|
+
});
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
#intentKey(sessionId: string): string {
|
|
1558
|
+
return `${this.options.provider.applicationId}:${this.options.guildId}:${this.options.parentChannelId}:creating:${sessionId}`;
|
|
1559
|
+
}
|
|
1560
|
+
async #withCreateIntentLease<T>(intent: DiscordConversation, work: () => Promise<T>): Promise<T> {
|
|
1561
|
+
let lost = false;
|
|
1562
|
+
let renewal: Promise<boolean> | undefined;
|
|
1563
|
+
let expectedGeneration = intent.generation;
|
|
1564
|
+
const renew = async (): Promise<boolean> => {
|
|
1565
|
+
if (lost) return false;
|
|
1566
|
+
if (renewal) return await renewal;
|
|
1567
|
+
const currentRenewal = (async (): Promise<boolean> => {
|
|
1568
|
+
const now = this.#now();
|
|
1569
|
+
const current = await this.#store.transact(this.#intentKey(intent.sessionId!), candidate => {
|
|
1570
|
+
if (
|
|
1571
|
+
candidate?.state !== "creating" ||
|
|
1572
|
+
candidate.createOwner !== intent.createOwner ||
|
|
1573
|
+
candidate.generation !== expectedGeneration ||
|
|
1574
|
+
(candidate.createLeaseExpiresAt ?? 0) <= now
|
|
1575
|
+
)
|
|
1576
|
+
return candidate;
|
|
1577
|
+
return {
|
|
1578
|
+
...candidate,
|
|
1579
|
+
generation: candidate.generation + 1,
|
|
1580
|
+
createLeaseExpiresAt: now + this.#providerLeaseMs,
|
|
1581
|
+
updatedAt: now,
|
|
1582
|
+
};
|
|
1583
|
+
});
|
|
1584
|
+
if (
|
|
1585
|
+
current?.state !== "creating" ||
|
|
1586
|
+
current.createOwner !== intent.createOwner ||
|
|
1587
|
+
current.generation !== expectedGeneration + 1 ||
|
|
1588
|
+
(current.createLeaseExpiresAt ?? 0) <= now
|
|
1589
|
+
) {
|
|
1590
|
+
lost = true;
|
|
1591
|
+
} else {
|
|
1592
|
+
expectedGeneration = current.generation;
|
|
1593
|
+
intent.generation = current.generation;
|
|
1594
|
+
intent.createLeaseExpiresAt = current.createLeaseExpiresAt;
|
|
1595
|
+
}
|
|
1596
|
+
return !lost;
|
|
1597
|
+
})();
|
|
1598
|
+
renewal = currentRenewal;
|
|
1599
|
+
try {
|
|
1600
|
+
return await currentRenewal;
|
|
1601
|
+
} finally {
|
|
1602
|
+
if (renewal === currentRenewal) renewal = undefined;
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
if (!(await renew())) throw new Error("Discord create intent lost its fence");
|
|
1606
|
+
const timer = setInterval(
|
|
1607
|
+
() => {
|
|
1608
|
+
void renew().catch(() => {});
|
|
1609
|
+
},
|
|
1610
|
+
Math.max(1, Math.floor(this.#providerLeaseMs / 3)),
|
|
1611
|
+
);
|
|
1612
|
+
try {
|
|
1613
|
+
const result = await work();
|
|
1614
|
+
if (!(await renew())) throw new Error("Discord create intent lost its fence");
|
|
1615
|
+
return result;
|
|
1616
|
+
} finally {
|
|
1617
|
+
clearInterval(timer);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
async #clearPending(record: DiscordConversation, actionId: string, actionNonce: string): Promise<void> {
|
|
1621
|
+
const key = record.threadId
|
|
1622
|
+
? discordConversationKey({
|
|
1623
|
+
appId: record.appId,
|
|
1624
|
+
guildId: record.guildId,
|
|
1625
|
+
parentChannelId: record.parentChannelId,
|
|
1626
|
+
threadId: record.threadId,
|
|
1627
|
+
})
|
|
1628
|
+
: this.#intentKey(record.sessionId!);
|
|
1629
|
+
await this.#store.transact(key, current => {
|
|
1630
|
+
if (current?.pendingActionId !== actionId || current.pendingActionNonce !== actionNonce) return current;
|
|
1631
|
+
return normalizeDiscordConversation({
|
|
1632
|
+
...current,
|
|
1633
|
+
generation: current.generation + 1,
|
|
1634
|
+
updatedAt: this.#now(),
|
|
1635
|
+
pendingActionId: undefined,
|
|
1636
|
+
pendingActionNonce: undefined,
|
|
1637
|
+
pendingActionEffectId: undefined,
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
}
|
|
1641
|
+
async #ensureActionPublication(record: DiscordConversation, actionId: string): Promise<DiscordConversation> {
|
|
1642
|
+
const key = discordConversationKey({
|
|
1643
|
+
appId: record.appId,
|
|
1644
|
+
guildId: record.guildId,
|
|
1645
|
+
parentChannelId: record.parentChannelId,
|
|
1646
|
+
threadId: record.threadId!,
|
|
1647
|
+
});
|
|
1648
|
+
await this.#requireLiveBinding(record.sessionId!, record.endpointGeneration!);
|
|
1649
|
+
const result = await this.#store.transact(key, current => {
|
|
1650
|
+
if (
|
|
1651
|
+
current?.state !== "active" ||
|
|
1652
|
+
current.sessionId !== record.sessionId ||
|
|
1653
|
+
current.endpointGeneration !== record.endpointGeneration
|
|
1654
|
+
)
|
|
1655
|
+
return current;
|
|
1656
|
+
if (current.pendingActionId === actionId && current.pendingActionNonce && current.pendingActionEffectId)
|
|
1657
|
+
return current;
|
|
1658
|
+
const actionNonce =
|
|
1659
|
+
current.pendingActionId === actionId && current.pendingActionNonce
|
|
1660
|
+
? current.pendingActionNonce
|
|
1661
|
+
: randomUUID();
|
|
1662
|
+
return normalizeDiscordConversation({
|
|
1663
|
+
...current,
|
|
1664
|
+
generation: current.generation + 1,
|
|
1665
|
+
updatedAt: this.#now(),
|
|
1666
|
+
pendingActionId: actionId,
|
|
1667
|
+
pendingActionNonce: actionNonce,
|
|
1668
|
+
pendingActionEffectId: `action-publication:${current.threadId}:${actionId}:${actionNonce}`,
|
|
1669
|
+
});
|
|
1670
|
+
});
|
|
1671
|
+
if (
|
|
1672
|
+
result?.state !== "active" ||
|
|
1673
|
+
result.sessionId !== record.sessionId ||
|
|
1674
|
+
result.endpointGeneration !== record.endpointGeneration ||
|
|
1675
|
+
result.pendingActionId !== actionId ||
|
|
1676
|
+
!result.pendingActionNonce ||
|
|
1677
|
+
!result.pendingActionEffectId
|
|
1678
|
+
) {
|
|
1679
|
+
throw new Error("Discord action publication intent lost its authority");
|
|
1680
|
+
}
|
|
1681
|
+
return result;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
async #runEffect<TPayload>(
|
|
1685
|
+
id: string,
|
|
1686
|
+
kind: string,
|
|
1687
|
+
sessionId: string | undefined,
|
|
1688
|
+
endpointGeneration: number,
|
|
1689
|
+
payload: TPayload,
|
|
1690
|
+
operation: (ensure: () => Promise<void>, beforeProvider: () => void) => Promise<ChatEffectReceipt>,
|
|
1691
|
+
revalidate: () => boolean | Promise<boolean>,
|
|
1692
|
+
terminalizeStaleBeforeProvider = false,
|
|
1693
|
+
): Promise<ChatEffectReceipt> {
|
|
1694
|
+
const claimed = await this.#rescheduleAfterEffectTransition(
|
|
1695
|
+
this.#effects.enqueueAndClaim<TPayload>(
|
|
1696
|
+
{ id, kind, transport: "discord", sessionId, endpointGeneration, payload },
|
|
1697
|
+
this.#providerOwner,
|
|
1698
|
+
this.#providerLeaseMs,
|
|
1699
|
+
),
|
|
1700
|
+
);
|
|
1701
|
+
let effect: ChatEffect<TPayload>;
|
|
1702
|
+
if (claimed) {
|
|
1703
|
+
// Fresh effect atomically inserted into a live lease. This closes the
|
|
1704
|
+
// enqueue→claim window in which the same-process lease-recovery timer could
|
|
1705
|
+
// claim the still-"pending" effect first and make this foreground claim fail
|
|
1706
|
+
// with "owned by another worker" (mirrors the inbound enqueueAndClaim path).
|
|
1707
|
+
effect = claimed;
|
|
1708
|
+
} else {
|
|
1709
|
+
const initial = await this.#effects.read<TPayload>(id);
|
|
1710
|
+
if (initial?.state === "terminal") {
|
|
1711
|
+
if (!initial.receipt) throw new Error(`Discord effect ${id} has no receipt`);
|
|
1712
|
+
if (terminalizeStaleBeforeProvider && initial.receipt.status === "stale_noop")
|
|
1713
|
+
throw new DiscordEndpointBindingError("Discord thread effect is no longer current.");
|
|
1714
|
+
return initial.receipt;
|
|
1715
|
+
}
|
|
1716
|
+
const reclaimed = await this.#rescheduleAfterEffectTransition(
|
|
1717
|
+
this.#effects.claim<TPayload>(id, this.#providerOwner, this.#providerLeaseMs),
|
|
1718
|
+
);
|
|
1719
|
+
if (!reclaimed) throw new Error(`Discord effect ${id} is owned by another worker`);
|
|
1720
|
+
effect = reclaimed;
|
|
1721
|
+
}
|
|
1722
|
+
const lease: ChatEffectLease = { owner: this.#providerOwner, epoch: effect.epoch };
|
|
1723
|
+
let renewalLost = false;
|
|
1724
|
+
let revalidationFailed = false;
|
|
1725
|
+
let providerEffectStarted = false;
|
|
1726
|
+
let renewal: Promise<boolean> | undefined;
|
|
1727
|
+
const renew = async (): Promise<boolean> => {
|
|
1728
|
+
if (renewalLost) return false;
|
|
1729
|
+
if (renewal) return await renewal;
|
|
1730
|
+
const currentRenewal = (async (): Promise<boolean> => {
|
|
1731
|
+
const renewed = await this.#rescheduleAfterEffectTransition(
|
|
1732
|
+
this.#effects.renew(id, lease, this.#providerLeaseMs),
|
|
1733
|
+
);
|
|
1734
|
+
if (!renewed) renewalLost = true;
|
|
1735
|
+
else if (!(await revalidate())) {
|
|
1736
|
+
revalidationFailed = true;
|
|
1737
|
+
renewalLost = true;
|
|
1738
|
+
}
|
|
1739
|
+
return !renewalLost;
|
|
1740
|
+
})();
|
|
1741
|
+
renewal = currentRenewal;
|
|
1742
|
+
try {
|
|
1743
|
+
return await currentRenewal;
|
|
1744
|
+
} finally {
|
|
1745
|
+
if (renewal === currentRenewal) renewal = undefined;
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
const timer = setInterval(
|
|
1749
|
+
() => {
|
|
1750
|
+
void renew().catch(() => {});
|
|
1751
|
+
},
|
|
1752
|
+
Math.max(1, Math.floor(this.#providerLeaseMs / 3)),
|
|
1753
|
+
);
|
|
1754
|
+
const ensure = async (): Promise<void> => {
|
|
1755
|
+
if (!(await renew())) throw new Error(`Discord effect ${id} lost its fence`);
|
|
1756
|
+
};
|
|
1757
|
+
try {
|
|
1758
|
+
await ensure();
|
|
1759
|
+
const receipt = await operation(ensure, () => {
|
|
1760
|
+
providerEffectStarted = true;
|
|
1761
|
+
});
|
|
1762
|
+
await ensure();
|
|
1763
|
+
const committed = await this.#effects.record(id, lease, "terminal", receipt);
|
|
1764
|
+
if (!committed) throw new Error(`Discord effect ${id} lost its fence before commit`);
|
|
1765
|
+
return receipt;
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
if (terminalizeStaleBeforeProvider && revalidationFailed && !providerEffectStarted)
|
|
1768
|
+
await this.#rescheduleAfterEffectTransition(
|
|
1769
|
+
this.#effects.record(id, lease, "terminal", { status: "stale_noop" }),
|
|
1770
|
+
);
|
|
1771
|
+
else if (!renewalLost)
|
|
1772
|
+
await this.#rescheduleAfterEffectTransition(
|
|
1773
|
+
this.#effects.record(id, lease, "uncertain", { status: "uncertain" }),
|
|
1774
|
+
);
|
|
1775
|
+
throw error;
|
|
1776
|
+
} finally {
|
|
1777
|
+
clearInterval(timer);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
async #postEffect(
|
|
1782
|
+
id: string,
|
|
1783
|
+
record: DiscordConversation,
|
|
1784
|
+
content: string,
|
|
1785
|
+
components?: DiscordMessageComponent[],
|
|
1786
|
+
actionPublication = false,
|
|
1787
|
+
closing = false,
|
|
1788
|
+
allowInactive = false,
|
|
1789
|
+
): Promise<void> {
|
|
1790
|
+
const nonce = discordEffectNonce(id);
|
|
1791
|
+
const existingEffect = await this.#effects.read<{
|
|
1792
|
+
threadId: string;
|
|
1793
|
+
content: string;
|
|
1794
|
+
nonce: string;
|
|
1795
|
+
components?: DiscordMessageComponent[];
|
|
1796
|
+
}>(id);
|
|
1797
|
+
const payload = existingEffect?.payload ?? {
|
|
1798
|
+
threadId: record.threadId!,
|
|
1799
|
+
content,
|
|
1800
|
+
nonce,
|
|
1801
|
+
...(components ? { components } : {}),
|
|
1802
|
+
};
|
|
1803
|
+
await this.#runEffect(
|
|
1804
|
+
id,
|
|
1805
|
+
"post-message",
|
|
1806
|
+
record.sessionId,
|
|
1807
|
+
record.endpointGeneration!,
|
|
1808
|
+
payload,
|
|
1809
|
+
async ensure => {
|
|
1810
|
+
await ensure();
|
|
1811
|
+
const reconciled = await this.options.provider.findMessageByNonce({
|
|
1812
|
+
threadId: payload.threadId,
|
|
1813
|
+
nonce: payload.nonce,
|
|
1814
|
+
});
|
|
1815
|
+
if (reconciled)
|
|
1816
|
+
return {
|
|
1817
|
+
provider: "discord",
|
|
1818
|
+
messageId: reconciled.id,
|
|
1819
|
+
threadId: record.threadId,
|
|
1820
|
+
status: "reconciled",
|
|
1821
|
+
};
|
|
1822
|
+
await ensure();
|
|
1823
|
+
const posted = await this.options.provider.postMessage({
|
|
1824
|
+
threadId: payload.threadId,
|
|
1825
|
+
content: payload.content,
|
|
1826
|
+
nonce: payload.nonce,
|
|
1827
|
+
...(payload.components ? { components: payload.components } : {}),
|
|
1828
|
+
});
|
|
1829
|
+
return { provider: "discord", messageId: posted.id, threadId: record.threadId, status: "posted" };
|
|
1830
|
+
},
|
|
1831
|
+
async () => {
|
|
1832
|
+
const current = await this.#byThread(record.guildId, record.parentChannelId, record.threadId!);
|
|
1833
|
+
const intent = closingIntent(record);
|
|
1834
|
+
const mappingCurrent = closing
|
|
1835
|
+
? !!current &&
|
|
1836
|
+
intent?.nonce === closingIntent(current)?.nonce &&
|
|
1837
|
+
current.sessionId === record.sessionId &&
|
|
1838
|
+
current.endpointGeneration === record.endpointGeneration
|
|
1839
|
+
: !!current &&
|
|
1840
|
+
(allowInactive || current.state === "active") &&
|
|
1841
|
+
current.generation === record.generation &&
|
|
1842
|
+
current.endpointGeneration === record.endpointGeneration &&
|
|
1843
|
+
(!actionPublication || current.pendingActionEffectId === id);
|
|
1844
|
+
return (
|
|
1845
|
+
mappingCurrent &&
|
|
1846
|
+
(closing || allowInactive || (await this.#bindingCurrent(record.sessionId!, record.endpointGeneration!)))
|
|
1847
|
+
);
|
|
1848
|
+
},
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
async #threadEffect(
|
|
1852
|
+
id: string,
|
|
1853
|
+
record: DiscordConversation,
|
|
1854
|
+
operation: "archive" | "unarchive",
|
|
1855
|
+
locked = false,
|
|
1856
|
+
closing = false,
|
|
1857
|
+
occurrenceId?: string,
|
|
1858
|
+
): Promise<void> {
|
|
1859
|
+
await this.#runEffect(
|
|
1860
|
+
id,
|
|
1861
|
+
operation,
|
|
1862
|
+
record.sessionId,
|
|
1863
|
+
record.endpointGeneration!,
|
|
1864
|
+
{ threadId: record.threadId!, locked, ...(occurrenceId === undefined ? {} : { occurrenceId }) },
|
|
1865
|
+
|
|
1866
|
+
async (ensure, beforeProvider) => {
|
|
1867
|
+
await ensure();
|
|
1868
|
+
beforeProvider();
|
|
1869
|
+
if (operation === "archive")
|
|
1870
|
+
await this.options.provider.archiveThread({
|
|
1871
|
+
threadId: record.threadId!,
|
|
1872
|
+
...(locked ? { locked: true } : {}),
|
|
1873
|
+
});
|
|
1874
|
+
else await this.options.provider.unarchiveThread({ threadId: record.threadId! });
|
|
1875
|
+
return { provider: "discord", threadId: record.threadId, status: operation };
|
|
1876
|
+
},
|
|
1877
|
+
async () => {
|
|
1878
|
+
const current = await this.#byThread(record.guildId, record.parentChannelId, record.threadId!);
|
|
1879
|
+
const intent = closingIntent(record);
|
|
1880
|
+
const mappingCurrent = closing
|
|
1881
|
+
? !!current &&
|
|
1882
|
+
intent?.nonce === closingIntent(current)?.nonce &&
|
|
1883
|
+
current.sessionId === record.sessionId &&
|
|
1884
|
+
current.endpointGeneration === record.endpointGeneration
|
|
1885
|
+
: !!current &&
|
|
1886
|
+
current.state === (operation === "archive" ? "active" : "resuming") &&
|
|
1887
|
+
current.generation === record.generation &&
|
|
1888
|
+
current.endpointGeneration === record.endpointGeneration &&
|
|
1889
|
+
(occurrenceId === undefined ||
|
|
1890
|
+
(operation === "archive"
|
|
1891
|
+
? current.archiveEffectId === id && current.archiveOccurrenceId === occurrenceId
|
|
1892
|
+
: current.resumeEffectId === id && current.resumeOccurrenceId === occurrenceId));
|
|
1893
|
+
return (
|
|
1894
|
+
mappingCurrent &&
|
|
1895
|
+
(closing ||
|
|
1896
|
+
(!!record.sessionId && (await this.#bindingCurrent(record.sessionId, record.endpointGeneration!))))
|
|
1897
|
+
);
|
|
1898
|
+
},
|
|
1899
|
+
!closing,
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
async #createThreadEffect(intent: DiscordConversation, name: string): Promise<DiscordThread> {
|
|
1903
|
+
const effectId = `create:${intent.sessionId}:${intent.createNonce}`;
|
|
1904
|
+
const nonce = discordEffectNonce(effectId);
|
|
1905
|
+
const receipt = await this.#runEffect(
|
|
1906
|
+
effectId,
|
|
1907
|
+
"create-thread",
|
|
1908
|
+
intent.sessionId,
|
|
1909
|
+
intent.endpointGeneration!,
|
|
1910
|
+
{ guildId: intent.guildId, parentId: intent.parentChannelId, name, nonce },
|
|
1911
|
+
async ensure => {
|
|
1912
|
+
await ensure();
|
|
1913
|
+
const existing = await this.options.provider.findThreadByNonce({
|
|
1914
|
+
guildId: intent.guildId,
|
|
1915
|
+
parentId: intent.parentChannelId,
|
|
1916
|
+
nonce,
|
|
1917
|
+
});
|
|
1918
|
+
await ensure();
|
|
1919
|
+
const thread =
|
|
1920
|
+
existing ??
|
|
1921
|
+
(await this.options.provider.createThread({
|
|
1922
|
+
guildId: intent.guildId,
|
|
1923
|
+
parentId: intent.parentChannelId,
|
|
1924
|
+
name,
|
|
1925
|
+
nonce,
|
|
1926
|
+
}));
|
|
1927
|
+
return {
|
|
1928
|
+
provider: "discord",
|
|
1929
|
+
threadId: thread.id,
|
|
1930
|
+
channelId: thread.parentId,
|
|
1931
|
+
status: existing ? "reconciled" : "created",
|
|
1932
|
+
};
|
|
1933
|
+
},
|
|
1934
|
+
async () => {
|
|
1935
|
+
const current = await this.#store.read(this.#intentKey(intent.sessionId!));
|
|
1936
|
+
return (
|
|
1937
|
+
current?.state === "creating" &&
|
|
1938
|
+
current.createOwner === intent.createOwner &&
|
|
1939
|
+
current.generation === intent.generation &&
|
|
1940
|
+
(current.createLeaseExpiresAt ?? 0) > this.#now() &&
|
|
1941
|
+
(await this.#bindingCurrent(intent.sessionId!, intent.endpointGeneration!))
|
|
1942
|
+
);
|
|
1943
|
+
},
|
|
1944
|
+
);
|
|
1945
|
+
if (!receipt.threadId) throw new Error("Discord create effect has no thread receipt");
|
|
1946
|
+
return { id: receipt.threadId, guildId: intent.guildId, parentId: intent.parentChannelId, archived: false };
|
|
1947
|
+
}
|
|
1948
|
+
async #recoverCreateThread(
|
|
1949
|
+
effect: ChatEffect,
|
|
1950
|
+
payload: { guildId?: string; parentId?: string; name?: string; nonce?: string },
|
|
1951
|
+
): Promise<void> {
|
|
1952
|
+
if (!effect.sessionId || !payload.nonce) return;
|
|
1953
|
+
const intentKey = this.#intentKey(effect.sessionId);
|
|
1954
|
+
const intent = await this.#store.read(intentKey);
|
|
1955
|
+
const matchesIntent =
|
|
1956
|
+
intent?.state === "creating" &&
|
|
1957
|
+
intent.sessionId === effect.sessionId &&
|
|
1958
|
+
intent.guildId === payload.guildId &&
|
|
1959
|
+
intent.parentChannelId === payload.parentId &&
|
|
1960
|
+
discordEffectNonce(`create:${intent.sessionId}:${intent.createNonce}`) === payload.nonce;
|
|
1961
|
+
if (!matchesIntent || !intent) {
|
|
1962
|
+
if (effect.state !== "terminal") await this.#terminalizeEffect(effect.id, "rejected");
|
|
1963
|
+
|
|
1964
|
+
return;
|
|
1965
|
+
}
|
|
1966
|
+
if (!(await this.#bindingCurrent(effect.sessionId, effect.endpointGeneration))) {
|
|
1967
|
+
await this.#store.delete(intentKey, intent.generation);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
if (effect.state !== "terminal") {
|
|
1971
|
+
await this.#create(effect.sessionId, effect.endpointGeneration, intent.createNonce!, payload.name);
|
|
1972
|
+
return;
|
|
1973
|
+
}
|
|
1974
|
+
const threadId = effect.receipt?.threadId;
|
|
1975
|
+
if (!threadId) return;
|
|
1976
|
+
// Any durable mapping for this session is already session-level authority.
|
|
1977
|
+
// Do not let a terminal receipt reactivate an older remote thread merely
|
|
1978
|
+
// because its exact thread key is absent. Delete only this generation so a
|
|
1979
|
+
// later notification must mint a fresh nonce and provider effect.
|
|
1980
|
+
if ((await this.#sessionMappings(effect.sessionId)).length > 0) {
|
|
1981
|
+
await this.#store.delete(intentKey, intent.generation);
|
|
1982
|
+
return;
|
|
1983
|
+
}
|
|
1984
|
+
await this.#requireLiveBinding(effect.sessionId, effect.endpointGeneration);
|
|
1985
|
+
const key = discordConversationKey({
|
|
1986
|
+
appId: intent.appId,
|
|
1987
|
+
guildId: intent.guildId,
|
|
1988
|
+
parentChannelId: intent.parentChannelId,
|
|
1989
|
+
threadId,
|
|
1990
|
+
});
|
|
1991
|
+
const committed = await this.#store.transact(
|
|
1992
|
+
key,
|
|
1993
|
+
old =>
|
|
1994
|
+
old ??
|
|
1995
|
+
normalizeDiscordConversation({
|
|
1996
|
+
generation: 1,
|
|
1997
|
+
state: "active",
|
|
1998
|
+
appId: intent.appId,
|
|
1999
|
+
guildId: intent.guildId,
|
|
2000
|
+
parentChannelId: intent.parentChannelId,
|
|
2001
|
+
threadId,
|
|
2002
|
+
sessionId: intent.sessionId,
|
|
2003
|
+
endpointGeneration: intent.endpointGeneration,
|
|
2004
|
+
createNonce: intent.createNonce,
|
|
2005
|
+
effectIncarnationId: intent.createNonce,
|
|
2006
|
+
updatedAt: this.#now(),
|
|
2007
|
+
seenEventIds: [],
|
|
2008
|
+
seenInteractionIds: [],
|
|
2009
|
+
}),
|
|
2010
|
+
);
|
|
2011
|
+
if (committed) await this.#store.delete(intentKey, intent.generation);
|
|
2012
|
+
}
|
|
2013
|
+
async #drainProviderEffects(): Promise<boolean> {
|
|
2014
|
+
let failed = false;
|
|
2015
|
+
for (const effect of await this.#effects.list()) {
|
|
2016
|
+
if (
|
|
2017
|
+
effect.transport !== "discord" ||
|
|
2018
|
+
(effect.state === "terminal" &&
|
|
2019
|
+
effect.kind !== "create-thread" &&
|
|
2020
|
+
effect.kind !== "archive" &&
|
|
2021
|
+
effect.kind !== "unarchive")
|
|
2022
|
+
)
|
|
2023
|
+
continue;
|
|
2024
|
+
if (this.#hasLiveEffectLease(effect)) continue;
|
|
2025
|
+
|
|
2026
|
+
const payload = effect.payload as {
|
|
2027
|
+
guildId?: string;
|
|
2028
|
+
parentId?: string;
|
|
2029
|
+
name?: string;
|
|
2030
|
+
nonce?: string;
|
|
2031
|
+
threadId?: string;
|
|
2032
|
+
content?: string;
|
|
2033
|
+
components?: DiscordMessageComponent[];
|
|
2034
|
+
locked?: boolean;
|
|
2035
|
+
occurrenceId?: string;
|
|
2036
|
+
};
|
|
2037
|
+
const providerEffect =
|
|
2038
|
+
effect.kind === "post-message" || effect.kind === "archive" || effect.kind === "unarchive";
|
|
2039
|
+
if (
|
|
2040
|
+
providerEffect &&
|
|
2041
|
+
(!effect.sessionId ||
|
|
2042
|
+
!payload.threadId ||
|
|
2043
|
+
(effect.kind === "post-message" && payload.content === undefined))
|
|
2044
|
+
) {
|
|
2045
|
+
await this.#terminalizeEffect(effect.id, "stale_noop");
|
|
2046
|
+
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
try {
|
|
2050
|
+
if (effect.kind === "create-thread" && effect.sessionId && payload.nonce) {
|
|
2051
|
+
await this.#recoverCreateThread(effect, payload);
|
|
2052
|
+
}
|
|
2053
|
+
if (effect.kind === "post-message" && payload.threadId && payload.content !== undefined) {
|
|
2054
|
+
const record = await this.#byThread(
|
|
2055
|
+
this.options.guildId,
|
|
2056
|
+
this.options.parentChannelId,
|
|
2057
|
+
payload.threadId,
|
|
2058
|
+
);
|
|
2059
|
+
const closing = closingIntent(record);
|
|
2060
|
+
const closeMarker = !!closing && effect.id === this.#closeMarkerEffectId(record!);
|
|
2061
|
+
const inactiveFailure = effect.id.startsWith("failure:");
|
|
2062
|
+
if (
|
|
2063
|
+
!record ||
|
|
2064
|
+
(record.state !== "active" && !closeMarker && !inactiveFailure) ||
|
|
2065
|
+
record.endpointGeneration !== effect.endpointGeneration ||
|
|
2066
|
+
(effect.id.startsWith("action-publication:") && record.pendingActionEffectId !== effect.id)
|
|
2067
|
+
) {
|
|
2068
|
+
await this.#terminalizeEffect(effect.id, "stale_noop");
|
|
2069
|
+
} else {
|
|
2070
|
+
await this.#postEffect(
|
|
2071
|
+
effect.id,
|
|
2072
|
+
record,
|
|
2073
|
+
payload.content,
|
|
2074
|
+
payload.components,
|
|
2075
|
+
effect.id.startsWith("action-publication:"),
|
|
2076
|
+
closeMarker,
|
|
2077
|
+
inactiveFailure,
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
}
|
|
2081
|
+
if ((effect.kind === "archive" || effect.kind === "unarchive") && payload.threadId) {
|
|
2082
|
+
const record = await this.#byThread(
|
|
2083
|
+
this.options.guildId,
|
|
2084
|
+
this.options.parentChannelId,
|
|
2085
|
+
payload.threadId,
|
|
2086
|
+
);
|
|
2087
|
+
const closing = closingIntent(record);
|
|
2088
|
+
const closeArchive =
|
|
2089
|
+
!!closing &&
|
|
2090
|
+
effect.kind === "archive" &&
|
|
2091
|
+
payload.locked === true &&
|
|
2092
|
+
effect.id === this.#closeArchiveEffectId(record!);
|
|
2093
|
+
const occurrenceId = payload.occurrenceId;
|
|
2094
|
+
const occurrenceMatches =
|
|
2095
|
+
closeArchive ||
|
|
2096
|
+
(!!record &&
|
|
2097
|
+
typeof occurrenceId === "string" &&
|
|
2098
|
+
effect.id === (effect.kind === "archive" ? record.archiveEffectId : record.resumeEffectId) &&
|
|
2099
|
+
(effect.kind === "archive"
|
|
2100
|
+
? record.archiveOccurrenceId === occurrenceId
|
|
2101
|
+
: record.resumeOccurrenceId === occurrenceId));
|
|
2102
|
+
if (
|
|
2103
|
+
!record ||
|
|
2104
|
+
(!closeArchive &&
|
|
2105
|
+
(effect.kind === "archive" ? record.state !== "active" : record.state !== "resuming")) ||
|
|
2106
|
+
record.endpointGeneration !== effect.endpointGeneration ||
|
|
2107
|
+
!occurrenceMatches
|
|
2108
|
+
) {
|
|
2109
|
+
await this.#terminalizeEffect(effect.id, "stale_noop");
|
|
2110
|
+
} else {
|
|
2111
|
+
await this.#threadEffect(effect.id, record, effect.kind, payload.locked, closeArchive, occurrenceId);
|
|
2112
|
+
if (!closeArchive && occurrenceId) {
|
|
2113
|
+
if (effect.kind === "archive") await this.#completeArchive(record, occurrenceId);
|
|
2114
|
+
else await this.#completeResume(record, occurrenceId);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
} catch {
|
|
2119
|
+
failed = true; /* retained for a later journal-authoritative replay */
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
return failed;
|
|
2123
|
+
}
|
|
2124
|
+
async #rescheduleAfterEffectTransition<T extends ChatEffect | undefined>(transition: Promise<T>): Promise<T> {
|
|
2125
|
+
const effect = await transition;
|
|
2126
|
+
if (effect?.state !== "terminal") await this.#scheduleLeaseRecovery();
|
|
2127
|
+
return effect;
|
|
2128
|
+
}
|
|
2129
|
+
async #scheduleLeaseRecovery(recoveryFailed = false): Promise<void> {
|
|
2130
|
+
if (!this.#started) return;
|
|
2131
|
+
const now = this.#now();
|
|
2132
|
+
const recoveryAt = (await this.#effects.list())
|
|
2133
|
+
.filter(effect => effect.transport === "discord")
|
|
2134
|
+
.reduce<number | undefined>(
|
|
2135
|
+
(earliest, effect) => {
|
|
2136
|
+
const claimAt =
|
|
2137
|
+
effect.state === "leased" && Number.isFinite(effect.leaseExpiresAt)
|
|
2138
|
+
? effect.leaseExpiresAt
|
|
2139
|
+
: effect.state === "pending" ||
|
|
2140
|
+
effect.state === "accepted" ||
|
|
2141
|
+
(effect.state === "uncertain" && !effect.kind.includes(".inbound."))
|
|
2142
|
+
? now
|
|
2143
|
+
: undefined;
|
|
2144
|
+
return claimAt === undefined || (earliest !== undefined && earliest <= claimAt) ? earliest : claimAt;
|
|
2145
|
+
},
|
|
2146
|
+
recoveryFailed ? now : undefined,
|
|
2147
|
+
);
|
|
2148
|
+
if (recoveryAt === undefined) {
|
|
2149
|
+
if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
|
|
2150
|
+
this.#leaseRecoveryTimer = undefined;
|
|
2151
|
+
this.#leaseRecoveryAt = undefined;
|
|
2152
|
+
this.#leaseRecoveryFailures = 0;
|
|
2153
|
+
return;
|
|
2154
|
+
}
|
|
2155
|
+
if (this.#leaseRecoveryAt !== undefined && this.#leaseRecoveryAt <= recoveryAt) return;
|
|
2156
|
+
if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
|
|
2157
|
+
this.#leaseRecoveryAt = recoveryAt;
|
|
2158
|
+
const delay =
|
|
2159
|
+
recoveryAt <= now
|
|
2160
|
+
? Math.min(1_000, 25 * 2 ** Math.min(this.#leaseRecoveryFailures, 5))
|
|
2161
|
+
: Math.min(recoveryAt - now, 2_147_483_647);
|
|
2162
|
+
this.#leaseRecoveryTimer = setTimeout(() => {
|
|
2163
|
+
this.#leaseRecoveryTimer = undefined;
|
|
2164
|
+
this.#leaseRecoveryAt = undefined;
|
|
2165
|
+
void this.#track(this.#recoverLeasedEffects());
|
|
2166
|
+
}, delay);
|
|
2167
|
+
}
|
|
2168
|
+
async #recoverLeasedEffects(): Promise<void> {
|
|
2169
|
+
if (!this.#started) return;
|
|
2170
|
+
let failed = false;
|
|
2171
|
+
try {
|
|
2172
|
+
try {
|
|
2173
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
2174
|
+
failed ||= await this.#recoverClosingConversations();
|
|
2175
|
+
failed ||= await this.#drainProviderEffects();
|
|
2176
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
2177
|
+
failed ||= await this.#recoverClosingConversations();
|
|
2178
|
+
} catch {
|
|
2179
|
+
failed = true;
|
|
2180
|
+
}
|
|
2181
|
+
try {
|
|
2182
|
+
failed = (await this.#drainPendingDispatches()) || failed;
|
|
2183
|
+
} catch {
|
|
2184
|
+
failed = true;
|
|
2185
|
+
}
|
|
2186
|
+
} finally {
|
|
2187
|
+
if (failed) this.#leaseRecoveryFailures = Math.min(this.#leaseRecoveryFailures + 1, 5);
|
|
2188
|
+
else this.#leaseRecoveryFailures = 0;
|
|
2189
|
+
try {
|
|
2190
|
+
await this.#scheduleLeaseRecovery(failed);
|
|
2191
|
+
} catch {
|
|
2192
|
+
/* retained effects are retried by the next trigger */
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
async #fail(threadId: string): Promise<void> {
|
|
2198
|
+
try {
|
|
2199
|
+
const record = await this.#byThread(this.options.guildId, this.options.parentChannelId, threadId);
|
|
2200
|
+
if (record)
|
|
2201
|
+
await this.#postEffect(
|
|
2202
|
+
`failure:${threadId}:${randomUUID()}`,
|
|
2203
|
+
record,
|
|
2204
|
+
FAILURE,
|
|
2205
|
+
undefined,
|
|
2206
|
+
false,
|
|
2207
|
+
false,
|
|
2208
|
+
true,
|
|
2209
|
+
);
|
|
2210
|
+
} catch {}
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function decodeCustomId(value: string): { generation: number; actionId: string; actionNonce: string } | undefined {
|
|
2215
|
+
const match = /^gjc:(\d+):([^:]+):([0-9a-f-]{36})$/.exec(value);
|
|
2216
|
+
if (!match) return undefined;
|
|
2217
|
+
const generation = Number(match[1]);
|
|
2218
|
+
return Number.isSafeInteger(generation) && generation >= 0
|
|
2219
|
+
? { generation, actionId: match[2]!, actionNonce: match[3]! }
|
|
2220
|
+
: undefined;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
function stateRank(state: string): number {
|
|
2224
|
+
return state === "closing"
|
|
2225
|
+
? -1
|
|
2226
|
+
: state === "active"
|
|
2227
|
+
? 0
|
|
2228
|
+
: state === "resuming"
|
|
2229
|
+
? 1
|
|
2230
|
+
: state === "archived"
|
|
2231
|
+
? 2
|
|
2232
|
+
: state === "closed"
|
|
2233
|
+
? 3
|
|
2234
|
+
: 4;
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
function actionComponents(
|
|
2238
|
+
generation: number,
|
|
2239
|
+
actionId: string,
|
|
2240
|
+
actionNonce: string,
|
|
2241
|
+
options: string[],
|
|
2242
|
+
): DiscordMessageComponent[] {
|
|
2243
|
+
return [
|
|
2244
|
+
{
|
|
2245
|
+
type: 1,
|
|
2246
|
+
components: [
|
|
2247
|
+
{
|
|
2248
|
+
type: 3,
|
|
2249
|
+
customId: `gjc:${generation}:${actionId}:${actionNonce}`,
|
|
2250
|
+
placeholder: "Choose an option",
|
|
2251
|
+
minValues: 1,
|
|
2252
|
+
maxValues: 1,
|
|
2253
|
+
options: options.slice(0, 25).map((option, index) => ({
|
|
2254
|
+
label: option.slice(0, 100) || `Option ${index + 1}`,
|
|
2255
|
+
value: String(index),
|
|
2256
|
+
})),
|
|
2257
|
+
},
|
|
2258
|
+
],
|
|
2259
|
+
},
|
|
2260
|
+
];
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function componentAnswer(value: string | number): string | number {
|
|
2264
|
+
if (typeof value === "string" && /^\d+$/.test(value)) {
|
|
2265
|
+
const index = Number(value);
|
|
2266
|
+
if (Number.isSafeInteger(index)) return index;
|
|
2267
|
+
}
|
|
2268
|
+
return value;
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
function discordEffectNonce(effectId: string): string {
|
|
2272
|
+
// Discord nonces are bounded; hash the durable effect identifier rather than
|
|
2273
|
+
// truncating its potentially shared prefix.
|
|
2274
|
+
return `gjc-${createHash("sha256").update(effectId).digest("hex").slice(0, 21)}`;
|
|
2275
|
+
}
|