@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,2144 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SdkClientError } from "../client/client";
|
|
3
|
+
import { readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
|
|
4
|
+
import type { ChatDeliveryError } from "./chat-daemon-runtime";
|
|
5
|
+
import { ConversationStore } from "./conversation-store";
|
|
6
|
+
import {
|
|
7
|
+
acceptsSlackInbound,
|
|
8
|
+
normalizeSlackConversation,
|
|
9
|
+
type SlackConversation,
|
|
10
|
+
type SlackInboundDispatchReceipt,
|
|
11
|
+
slackConversationKey,
|
|
12
|
+
} from "./slack-conversation";
|
|
13
|
+
|
|
14
|
+
export class SlackEndpointBindingError extends Error {
|
|
15
|
+
constructor(message = "Slack session endpoint changed before dispatch.") {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "SlackEndpointBindingError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
class SlackStaleEffectError extends Error {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("Slack effect is no longer current");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class SlackReconciledAbsentEffectError extends Error {
|
|
28
|
+
constructor() {
|
|
29
|
+
super("Slack effect was not found during reconciliation");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
import { type ChatEffect, ChatEffectJournal, type ChatEffectLease } from "./chat-effect-journal";
|
|
34
|
+
import { SlackProviderError } from "./slack-live-provider";
|
|
35
|
+
import type { SlackPostedMessage, SlackProvider, SlackSocketEnvelope } from "./slack-provider";
|
|
36
|
+
|
|
37
|
+
// Durable filesystem publication leases must outlast one event-loop and persistence turn.
|
|
38
|
+
const MIN_PUBLICATION_LEASE_MS = 100;
|
|
39
|
+
|
|
40
|
+
export interface SlackEndpoint extends SdkSessionEndpoint {
|
|
41
|
+
generation: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SlackSdkClient {
|
|
45
|
+
send(frame: Record<string, unknown>): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SlackNotificationDaemonOptions {
|
|
49
|
+
agentDir: string;
|
|
50
|
+
repo: string;
|
|
51
|
+
teamId: string;
|
|
52
|
+
channelId: string;
|
|
53
|
+
provider: SlackProvider;
|
|
54
|
+
botUserId?: string;
|
|
55
|
+
/** Fail-closed authorization for the paired Slack principal. */
|
|
56
|
+
authorizeActor?: (actorId: string) => boolean | Promise<boolean>;
|
|
57
|
+
store?: ConversationStore<SlackConversation>;
|
|
58
|
+
now?: () => number;
|
|
59
|
+
randomId?: () => string;
|
|
60
|
+
/** Stable identity for the process attempting durable provider publication. */
|
|
61
|
+
publicationOwnerId?: string;
|
|
62
|
+
publicationLeaseMs?: number;
|
|
63
|
+
|
|
64
|
+
resolveEndpoint?: (sessionId: string) => Promise<SlackEndpoint | null>;
|
|
65
|
+
createClient: (endpoint: SlackEndpoint) => SlackSdkClient;
|
|
66
|
+
onCommand?: (
|
|
67
|
+
sessionId: string,
|
|
68
|
+
content: string,
|
|
69
|
+
endpoint: SlackEndpoint,
|
|
70
|
+
idempotencyKey: string,
|
|
71
|
+
) => Promise<boolean>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
type SlackEvent = {
|
|
75
|
+
type?: unknown;
|
|
76
|
+
channel?: unknown;
|
|
77
|
+
ts?: unknown;
|
|
78
|
+
thread_ts?: unknown;
|
|
79
|
+
user?: unknown;
|
|
80
|
+
bot_id?: unknown;
|
|
81
|
+
subtype?: unknown;
|
|
82
|
+
text?: unknown;
|
|
83
|
+
client_msg_id?: unknown;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type EventsPayload = {
|
|
87
|
+
type?: unknown;
|
|
88
|
+
event_id?: unknown;
|
|
89
|
+
team_id?: unknown;
|
|
90
|
+
event_context?: unknown;
|
|
91
|
+
event?: SlackEvent;
|
|
92
|
+
};
|
|
93
|
+
type SlackInboundRouting = {
|
|
94
|
+
teamId: string;
|
|
95
|
+
channelId: string;
|
|
96
|
+
rootTs: string;
|
|
97
|
+
actorId: string;
|
|
98
|
+
eventId: string;
|
|
99
|
+
interactionId: string;
|
|
100
|
+
retryKey: string;
|
|
101
|
+
eventContext?: string;
|
|
102
|
+
kind: "action" | "command";
|
|
103
|
+
actionId?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type SlackInboundEffectPayload =
|
|
107
|
+
| { type: "reply"; id: string; answer: string; idempotencyKey: string; routing: SlackInboundRouting }
|
|
108
|
+
| { type: "command"; content: string; idempotencyKey: string; routing: SlackInboundRouting };
|
|
109
|
+
|
|
110
|
+
type SlackRootPublication = {
|
|
111
|
+
conversation: SlackConversation;
|
|
112
|
+
created: boolean;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function text(value: unknown): string | undefined {
|
|
116
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function messageFromEnvelope(
|
|
120
|
+
payload: unknown,
|
|
121
|
+
):
|
|
122
|
+
| { eventId: string; eventContext?: string; teamId: string; channelId: string; rootTs: string; event: SlackEvent }
|
|
123
|
+
| undefined {
|
|
124
|
+
if (!payload || typeof payload !== "object") return undefined;
|
|
125
|
+
const eventPayload = payload as EventsPayload;
|
|
126
|
+
if (eventPayload.type !== "events_api" || !eventPayload.event || eventPayload.event.type !== "message")
|
|
127
|
+
return undefined;
|
|
128
|
+
const eventId = text(eventPayload.event_id);
|
|
129
|
+
const teamId = text(eventPayload.team_id);
|
|
130
|
+
const channelId = text(eventPayload.event.channel);
|
|
131
|
+
const ts = text(eventPayload.event.ts);
|
|
132
|
+
const rootTs = text(eventPayload.event.thread_ts) ?? ts;
|
|
133
|
+
if (!eventId || !teamId || !channelId || !rootTs) return undefined;
|
|
134
|
+
return {
|
|
135
|
+
eventId,
|
|
136
|
+
eventContext: text(eventPayload.event_context),
|
|
137
|
+
teamId,
|
|
138
|
+
channelId,
|
|
139
|
+
rootTs,
|
|
140
|
+
event: eventPayload.event,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function nextRecord(record: SlackConversation, update: Partial<SlackConversation>): SlackConversation {
|
|
145
|
+
return normalizeSlackConversation({ ...record, ...update, generation: record.generation + 1 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Slack Socket Mode notification daemon. Accepted inbound SDK effects are
|
|
150
|
+
* persisted before acknowledgement, including their replay payload and captured
|
|
151
|
+
* endpoint generation; endpoint credentials and Socket Mode cursors are never
|
|
152
|
+
* written to disk.
|
|
153
|
+
*/
|
|
154
|
+
|
|
155
|
+
export class SlackNotificationDaemon {
|
|
156
|
+
readonly store: ConversationStore<SlackConversation>;
|
|
157
|
+
readonly #now: () => number;
|
|
158
|
+
readonly #randomId: () => string;
|
|
159
|
+
readonly #resolveEndpoint: (sessionId: string) => Promise<SlackEndpoint | null>;
|
|
160
|
+
readonly #publicationOwnerId: string;
|
|
161
|
+
readonly #publicationLeaseMs: number;
|
|
162
|
+
readonly #journal: ChatEffectJournal;
|
|
163
|
+
|
|
164
|
+
readonly #inflightInbound = new Set<string>();
|
|
165
|
+
readonly #activeWork = new Set<Promise<unknown>>();
|
|
166
|
+
readonly #rollovers = new Map<string, Promise<SlackRootPublication>>();
|
|
167
|
+
#lifecycleTail: Promise<void> = Promise.resolve();
|
|
168
|
+
#lifecycleGeneration = 0;
|
|
169
|
+
#startedGeneration: number | undefined;
|
|
170
|
+
#startOperation: Promise<void> | undefined;
|
|
171
|
+
#startOperationGeneration: number | undefined;
|
|
172
|
+
#stopOperation: Promise<void> | undefined;
|
|
173
|
+
#started = false;
|
|
174
|
+
#providerStarting = false;
|
|
175
|
+
#providerRunning = false;
|
|
176
|
+
#providerStopRequestedGeneration: number | undefined;
|
|
177
|
+
#leaseRecoveryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
178
|
+
#leaseRecoveryAt: number | undefined;
|
|
179
|
+
#leaseRecoveryFailures = 0;
|
|
180
|
+
#leaseRecoveryTimerGeneration = 0;
|
|
181
|
+
#leaseRecoveryScheduling: Promise<void> = Promise.resolve();
|
|
182
|
+
#recoveringLeasedEffects = false;
|
|
183
|
+
|
|
184
|
+
constructor(private readonly options: SlackNotificationDaemonOptions) {
|
|
185
|
+
this.store =
|
|
186
|
+
options.store ?? new ConversationStore<SlackConversation>({ agentDir: options.agentDir, kind: "slack" });
|
|
187
|
+
this.#now = options.now ?? Date.now;
|
|
188
|
+
this.#randomId = options.randomId ?? randomUUID;
|
|
189
|
+
this.#publicationOwnerId = options.publicationOwnerId ?? randomUUID();
|
|
190
|
+
this.#publicationLeaseMs = Math.max(options.publicationLeaseMs ?? 30_000, MIN_PUBLICATION_LEASE_MS);
|
|
191
|
+
this.#journal = new ChatEffectJournal({ agentDir: options.agentDir, transport: "slack", now: this.#now });
|
|
192
|
+
this.#resolveEndpoint =
|
|
193
|
+
options.resolveEndpoint ??
|
|
194
|
+
(async sessionId => {
|
|
195
|
+
const endpoint = await readSdkSessionEndpoint(options.repo, sessionId);
|
|
196
|
+
return endpoint ? { ...endpoint, generation: 1 } : null;
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async start(): Promise<void> {
|
|
201
|
+
const lifecycleGeneration = this.#lifecycleGeneration;
|
|
202
|
+
if (this.#started && this.#startedGeneration === lifecycleGeneration) return;
|
|
203
|
+
if (this.#startOperation && this.#startOperationGeneration === lifecycleGeneration)
|
|
204
|
+
return await this.#startOperation;
|
|
205
|
+
const operation = this.#enqueueLifecycle(async () => {
|
|
206
|
+
if (lifecycleGeneration !== this.#lifecycleGeneration || this.#started) return;
|
|
207
|
+
this.#started = true;
|
|
208
|
+
this.#startedGeneration = lifecycleGeneration;
|
|
209
|
+
try {
|
|
210
|
+
// Recovery is a barrier: Socket Mode must not ACK an envelope before the
|
|
211
|
+
// mapping authority represented by durable effects has been restored.
|
|
212
|
+
await this.#reconcileTerminalProviderReceipts();
|
|
213
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
214
|
+
const providerRecoveryFailed = await this.#drainProviderEffects();
|
|
215
|
+
await this.#drainPendingDispatches();
|
|
216
|
+
await this.#scheduleLeaseRecovery(providerRecoveryFailed);
|
|
217
|
+
// stop() invalidates this generation synchronously before it joins the
|
|
218
|
+
// lifecycle queue, so no stale recovery can open Socket Mode.
|
|
219
|
+
if (
|
|
220
|
+
lifecycleGeneration !== this.#lifecycleGeneration ||
|
|
221
|
+
!this.#started ||
|
|
222
|
+
this.#startedGeneration !== lifecycleGeneration
|
|
223
|
+
) {
|
|
224
|
+
this.#started = false;
|
|
225
|
+
this.#startedGeneration = undefined;
|
|
226
|
+
this.#clearLeaseRecoveryTimer();
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this.#providerStarting = true;
|
|
230
|
+
try {
|
|
231
|
+
await this.options.provider.start(async envelope => {
|
|
232
|
+
await this.#track(this.handleEnvelope(envelope));
|
|
233
|
+
});
|
|
234
|
+
this.#providerRunning = true;
|
|
235
|
+
} finally {
|
|
236
|
+
this.#providerStarting = false;
|
|
237
|
+
}
|
|
238
|
+
if (lifecycleGeneration !== this.#lifecycleGeneration || this.#startedGeneration !== lifecycleGeneration) {
|
|
239
|
+
this.#started = false;
|
|
240
|
+
this.#startedGeneration = undefined;
|
|
241
|
+
this.#providerRunning = false;
|
|
242
|
+
this.#clearLeaseRecoveryTimer();
|
|
243
|
+
if (this.#providerStopRequestedGeneration !== lifecycleGeneration) await this.options.provider.stop();
|
|
244
|
+
}
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (this.#startedGeneration === lifecycleGeneration) {
|
|
247
|
+
this.#started = false;
|
|
248
|
+
this.#startedGeneration = undefined;
|
|
249
|
+
this.#clearLeaseRecoveryTimer();
|
|
250
|
+
}
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
this.#startOperation = operation;
|
|
255
|
+
this.#startOperationGeneration = lifecycleGeneration;
|
|
256
|
+
try {
|
|
257
|
+
await operation;
|
|
258
|
+
} finally {
|
|
259
|
+
if (this.#startOperation === operation) {
|
|
260
|
+
this.#startOperation = undefined;
|
|
261
|
+
this.#startOperationGeneration = undefined;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async stop(): Promise<void> {
|
|
267
|
+
if (this.#stopOperation) return await this.#stopOperation;
|
|
268
|
+
const lifecycleGeneration = this.#lifecycleGeneration++;
|
|
269
|
+
const stopProvider = this.#providerStarting || this.#providerRunning;
|
|
270
|
+
this.#started = false;
|
|
271
|
+
this.#startedGeneration = undefined;
|
|
272
|
+
this.#providerRunning = false;
|
|
273
|
+
this.#clearLeaseRecoveryTimer();
|
|
274
|
+
if (stopProvider) this.#providerStopRequestedGeneration = lifecycleGeneration;
|
|
275
|
+
const providerStop = stopProvider ? this.options.provider.stop() : undefined;
|
|
276
|
+
const operation = this.#enqueueLifecycle(async () => {
|
|
277
|
+
// Calling provider.stop() before joining the lifecycle queue lets a provider
|
|
278
|
+
// cancel an open that resolves only after its socket is stopped.
|
|
279
|
+
if (providerStop) await providerStop;
|
|
280
|
+
// Drain until quiescent: a tracked task can schedule further tracked work
|
|
281
|
+
// (recovery/reconciliation) while we await, and any that outlives stop() would
|
|
282
|
+
// bleed timing pressure into the next daemon/test. #started is already false,
|
|
283
|
+
// so no new lease-recovery timers can be armed and the loop terminates.
|
|
284
|
+
while (this.#activeWork.size > 0) await Promise.all([...this.#activeWork]);
|
|
285
|
+
});
|
|
286
|
+
this.#stopOperation = operation;
|
|
287
|
+
try {
|
|
288
|
+
await operation;
|
|
289
|
+
} finally {
|
|
290
|
+
if (this.#stopOperation === operation) this.#stopOperation = undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Accepted inbound effects are durably claimed before their Socket Mode ACK. */
|
|
295
|
+
async handleEnvelope(envelope: SlackSocketEnvelope): Promise<boolean> {
|
|
296
|
+
if (!text(envelope.envelope_id)) return false;
|
|
297
|
+
const inbound = messageFromEnvelope(envelope.payload);
|
|
298
|
+
if (!inbound || inbound.teamId !== this.options.teamId || inbound.channelId !== this.options.channelId) {
|
|
299
|
+
await this.options.provider.ack(envelope.envelope_id);
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
const actorId = text(inbound.event.user);
|
|
303
|
+
if (
|
|
304
|
+
inbound.event.bot_id ||
|
|
305
|
+
inbound.event.subtype === "bot_message" ||
|
|
306
|
+
actorId === this.options.botUserId ||
|
|
307
|
+
!actorId ||
|
|
308
|
+
!(await this.#actorAuthorized(actorId))
|
|
309
|
+
) {
|
|
310
|
+
await this.options.provider.ack(envelope.envelope_id);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
const claim = await this.#claimInbound(inbound, actorId);
|
|
314
|
+
if (!claim) {
|
|
315
|
+
await this.options.provider.ack(envelope.envelope_id);
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const inflightKey = `${claim.key}\u0000${claim.receipt.key}`;
|
|
319
|
+
if (this.#inflightInbound.has(inflightKey)) {
|
|
320
|
+
await this.options.provider.ack(envelope.envelope_id);
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
this.#inflightInbound.add(inflightKey);
|
|
324
|
+
try {
|
|
325
|
+
await this.options.provider.ack(envelope.envelope_id);
|
|
326
|
+
return await this.#dispatchInbound(claim);
|
|
327
|
+
} finally {
|
|
328
|
+
this.#inflightInbound.delete(inflightKey);
|
|
329
|
+
await this.#scheduleLeaseRecovery();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
#track<T>(work: Promise<T>): Promise<T> {
|
|
334
|
+
this.#activeWork.add(work);
|
|
335
|
+
return work.finally(() => this.#activeWork.delete(work));
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
#enqueueLifecycle<T>(operation: () => Promise<T>): Promise<T> {
|
|
339
|
+
const scheduled = this.#lifecycleTail.then(operation, operation);
|
|
340
|
+
this.#lifecycleTail = scheduled.then(
|
|
341
|
+
() => undefined,
|
|
342
|
+
() => undefined,
|
|
343
|
+
);
|
|
344
|
+
return scheduled;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
async postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
|
|
348
|
+
return (await this.#postRoot(sessionId, body, endpointGeneration)).conversation;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async #postRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
|
|
352
|
+
const endpoint = await this.#resolveEndpoint(sessionId);
|
|
353
|
+
if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
|
|
354
|
+
throw new SlackEndpointBindingError("Slack root publication requires the current session endpoint.");
|
|
355
|
+
const generation = endpoint.generation;
|
|
356
|
+
const pendingKey = this.#intentKey(sessionId);
|
|
357
|
+
let claimed = false;
|
|
358
|
+
const pending = await this.store.transact(pendingKey, current => {
|
|
359
|
+
if (current?.state === "active") return current;
|
|
360
|
+
const now = this.#now();
|
|
361
|
+
if (
|
|
362
|
+
current?.rootPublicationOwner !== undefined &&
|
|
363
|
+
current.rootPublicationOwner !== this.#publicationOwnerId &&
|
|
364
|
+
!this.#leaseExpired(current.rootPublicationLeaseExpiresAt, now)
|
|
365
|
+
)
|
|
366
|
+
return current;
|
|
367
|
+
if (current?.state === "posting_root") {
|
|
368
|
+
if (current.rootPublicationOwner === this.#publicationOwnerId) return current;
|
|
369
|
+
claimed = true;
|
|
370
|
+
return nextRecord(current, {
|
|
371
|
+
rootPublicationOwner: this.#publicationOwnerId,
|
|
372
|
+
rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
|
|
373
|
+
rootPublicationFence: (current.rootPublicationFence ?? 0) + 1,
|
|
374
|
+
updatedAt: now,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
claimed = true;
|
|
378
|
+
return {
|
|
379
|
+
generation: (current?.generation ?? 0) + 1,
|
|
380
|
+
state: "posting_root",
|
|
381
|
+
teamId: this.options.teamId,
|
|
382
|
+
channelId: this.options.channelId,
|
|
383
|
+
sessionId,
|
|
384
|
+
clientMsgId: this.#randomId(),
|
|
385
|
+
rootPublicationOwner: this.#publicationOwnerId,
|
|
386
|
+
rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
|
|
387
|
+
rootPublicationFence: (current?.rootPublicationFence ?? 0) + 1,
|
|
388
|
+
endpointGeneration: generation,
|
|
389
|
+
updatedAt: now,
|
|
390
|
+
seenEventIds: current?.seenEventIds ?? [],
|
|
391
|
+
seenContextIds: current?.seenContextIds ?? [],
|
|
392
|
+
seenRetryKeys: current?.seenRetryKeys ?? [],
|
|
393
|
+
seenInteractionIds: current?.seenInteractionIds ?? [],
|
|
394
|
+
inboundDispatches: current?.inboundDispatches ?? [],
|
|
395
|
+
};
|
|
396
|
+
});
|
|
397
|
+
if (!pending?.clientMsgId) throw new Error("Unable to persist Slack root post intent");
|
|
398
|
+
if (pending.state === "active") {
|
|
399
|
+
if (pending.endpointGeneration !== generation) throw new SlackEndpointBindingError();
|
|
400
|
+
return { conversation: pending, created: false };
|
|
401
|
+
}
|
|
402
|
+
if (!claimed)
|
|
403
|
+
return { conversation: await this.#waitForRoot(pendingKey, sessionId, body, generation), created: false };
|
|
404
|
+
if (pending.endpointGeneration !== generation) throw new SlackEndpointBindingError();
|
|
405
|
+
const clientMsgId = pending.clientMsgId;
|
|
406
|
+
const fence = pending.rootPublicationFence;
|
|
407
|
+
if (fence === undefined) throw new Error("Unable to fence Slack root post intent");
|
|
408
|
+
let posted: SlackPostedMessage | null = null;
|
|
409
|
+
let createdRootEffect = false;
|
|
410
|
+
try {
|
|
411
|
+
await this.#withRootLease(pendingKey, clientMsgId, fence, async () => {
|
|
412
|
+
const effectId = `root:${sessionId}:${clientMsgId}`;
|
|
413
|
+
const durable = await this.#providerEffectPayload(effectId, sessionId, generation, {
|
|
414
|
+
channel: this.options.channelId,
|
|
415
|
+
text: body,
|
|
416
|
+
clientMsgId,
|
|
417
|
+
});
|
|
418
|
+
createdRootEffect = !durable.existed;
|
|
419
|
+
posted = await this.#postDurable(effectId, sessionId, generation, durable.payload);
|
|
420
|
+
});
|
|
421
|
+
} catch (error) {
|
|
422
|
+
if (this.#isUncertainPostFailure(error)) {
|
|
423
|
+
// The journal retains the stable root effect for fenced reconciliation.
|
|
424
|
+
}
|
|
425
|
+
if (!posted) {
|
|
426
|
+
if (!this.#isUncertainPostFailure(error)) {
|
|
427
|
+
await this.store.transact(pendingKey, current =>
|
|
428
|
+
current?.clientMsgId === clientMsgId &&
|
|
429
|
+
current.rootPublicationOwner === this.#publicationOwnerId &&
|
|
430
|
+
current.rootPublicationFence === fence
|
|
431
|
+
? nextRecord(current, {
|
|
432
|
+
state: "error",
|
|
433
|
+
rootPublicationOwner: undefined,
|
|
434
|
+
rootPublicationLeaseExpiresAt: undefined,
|
|
435
|
+
updatedAt: this.#now(),
|
|
436
|
+
lastError: "provider_failure",
|
|
437
|
+
})
|
|
438
|
+
: current,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
throw error;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (!posted) throw new Error("Slack root post was not confirmed");
|
|
445
|
+
const confirmedPosted = posted as SlackPostedMessage;
|
|
446
|
+
// A confirmed receipt remains authoritative even if the endpoint rolled after
|
|
447
|
+
// dispatch; resume performs any required replacement after reconciliation.
|
|
448
|
+
const active = await this.store.transact(pendingKey, current => {
|
|
449
|
+
if (
|
|
450
|
+
!current ||
|
|
451
|
+
current.clientMsgId !== clientMsgId ||
|
|
452
|
+
current.rootPublicationOwner !== this.#publicationOwnerId ||
|
|
453
|
+
current.rootPublicationFence !== fence
|
|
454
|
+
)
|
|
455
|
+
return current;
|
|
456
|
+
return nextRecord(current, {
|
|
457
|
+
state: "active",
|
|
458
|
+
rootTs: confirmedPosted.ts,
|
|
459
|
+
endpointGeneration: generation,
|
|
460
|
+
updatedAt: this.#now(),
|
|
461
|
+
lastError: undefined,
|
|
462
|
+
rootPublicationOwner: undefined,
|
|
463
|
+
rootPublicationLeaseExpiresAt: undefined,
|
|
464
|
+
});
|
|
465
|
+
});
|
|
466
|
+
if (!active) throw new Error("Slack root mapping disappeared");
|
|
467
|
+
if (active.state !== "active")
|
|
468
|
+
return { conversation: await this.#waitForRoot(pendingKey, sessionId, body, generation), created: false };
|
|
469
|
+
return { conversation: active, created: createdRootEffect };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Deliver a notification into the mapped root thread, creating that root once. */
|
|
473
|
+
async notify(
|
|
474
|
+
sessionId: string,
|
|
475
|
+
body: string,
|
|
476
|
+
actionId?: string,
|
|
477
|
+
endpointGeneration?: number,
|
|
478
|
+
): Promise<SlackConversation> {
|
|
479
|
+
const endpoint = await this.#resolveEndpoint(sessionId);
|
|
480
|
+
if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
|
|
481
|
+
throw new SlackEndpointBindingError("Slack notification requires the current session endpoint.");
|
|
482
|
+
const generation = endpoint.generation;
|
|
483
|
+
const existing = await this.findSession(sessionId, false);
|
|
484
|
+
const usedExistingRoot =
|
|
485
|
+
existing?.record.state === "active" &&
|
|
486
|
+
!!existing.record.rootTs &&
|
|
487
|
+
existing.record.endpointGeneration === generation;
|
|
488
|
+
let conversation: SlackConversation;
|
|
489
|
+
let bodyWasUsedAsRoot = false;
|
|
490
|
+
if (usedExistingRoot) {
|
|
491
|
+
conversation = existing.record;
|
|
492
|
+
} else {
|
|
493
|
+
const publication =
|
|
494
|
+
existing?.record.state === "active"
|
|
495
|
+
? await this.#resumeWithRootPublication(sessionId, body, generation)
|
|
496
|
+
: await this.#postRoot(sessionId, body, generation);
|
|
497
|
+
conversation = publication.conversation;
|
|
498
|
+
bodyWasUsedAsRoot = publication.created;
|
|
499
|
+
}
|
|
500
|
+
if (!conversation.rootTs) return conversation;
|
|
501
|
+
const key = this.#intentKey(sessionId);
|
|
502
|
+
const conversationGeneration = this.#requireEndpointGeneration(conversation);
|
|
503
|
+
if (conversationGeneration !== generation) throw new SlackEndpointBindingError();
|
|
504
|
+
if (bodyWasUsedAsRoot) {
|
|
505
|
+
if (!actionId) return conversation;
|
|
506
|
+
const active = await this.store.transact(key, current =>
|
|
507
|
+
current && acceptsSlackInbound(current, conversation.rootTs!, conversationGeneration)
|
|
508
|
+
? nextRecord(current, { pendingActionId: actionId, updatedAt: this.#now() })
|
|
509
|
+
: current,
|
|
510
|
+
);
|
|
511
|
+
if (!active) throw new Error("Slack root disappeared while activating action authority");
|
|
512
|
+
return active;
|
|
513
|
+
}
|
|
514
|
+
if (!actionId) {
|
|
515
|
+
await this.#postDurable(`notification:${sessionId}:${this.#randomId()}`, sessionId, conversationGeneration, {
|
|
516
|
+
channel: conversation.channelId,
|
|
517
|
+
threadTs: conversation.rootTs,
|
|
518
|
+
text: body,
|
|
519
|
+
clientMsgId: this.#randomId(),
|
|
520
|
+
});
|
|
521
|
+
return conversation;
|
|
522
|
+
}
|
|
523
|
+
let actionClaimed = false;
|
|
524
|
+
const intent = await this.store.transact(key, current => {
|
|
525
|
+
if (!current || !acceptsSlackInbound(current, conversation.rootTs!, conversationGeneration)) return current;
|
|
526
|
+
const now = this.#now();
|
|
527
|
+
if (current.outboundActionId && current.outboundActionId !== actionId) return current;
|
|
528
|
+
if (current.outboundActionId === actionId) {
|
|
529
|
+
if (
|
|
530
|
+
current.outboundActionOwner === this.#publicationOwnerId ||
|
|
531
|
+
!this.#leaseExpired(current.outboundActionLeaseExpiresAt, now)
|
|
532
|
+
)
|
|
533
|
+
return current;
|
|
534
|
+
actionClaimed = true;
|
|
535
|
+
return nextRecord(current, {
|
|
536
|
+
outboundActionOwner: this.#publicationOwnerId,
|
|
537
|
+
outboundActionLeaseExpiresAt: now + this.#publicationLeaseMs,
|
|
538
|
+
outboundActionFence: (current.outboundActionFence ?? 0) + 1,
|
|
539
|
+
updatedAt: now,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
actionClaimed = true;
|
|
543
|
+
return nextRecord(current, {
|
|
544
|
+
outboundActionId: actionId,
|
|
545
|
+
outboundActionClientMsgId: this.#randomId(),
|
|
546
|
+
outboundActionOwner: this.#publicationOwnerId,
|
|
547
|
+
outboundActionLeaseExpiresAt: now + this.#publicationLeaseMs,
|
|
548
|
+
outboundActionFence: (current.outboundActionFence ?? 0) + 1,
|
|
549
|
+
updatedAt: now,
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
if (!intent || intent.outboundActionId !== actionId || !intent.outboundActionClientMsgId) {
|
|
553
|
+
throw new Error("Another Slack action publication is pending");
|
|
554
|
+
}
|
|
555
|
+
if (!actionClaimed) return await this.#waitForAction(key, sessionId, body, actionId);
|
|
556
|
+
const clientMsgId = intent.outboundActionClientMsgId;
|
|
557
|
+
const fence = intent.outboundActionFence;
|
|
558
|
+
if (fence === undefined) throw new Error("Unable to fence Slack action post intent");
|
|
559
|
+
let published: SlackPostedMessage | null = null;
|
|
560
|
+
try {
|
|
561
|
+
await this.#withActionLease(key, clientMsgId, fence, async () => {
|
|
562
|
+
const effectId = `action:${sessionId}:${actionId}:${clientMsgId}`;
|
|
563
|
+
const durable = await this.#providerEffectPayload(effectId, sessionId, conversationGeneration, {
|
|
564
|
+
channel: conversation.channelId,
|
|
565
|
+
threadTs: conversation.rootTs,
|
|
566
|
+
text: body,
|
|
567
|
+
clientMsgId,
|
|
568
|
+
});
|
|
569
|
+
published = await this.#postDurable(effectId, sessionId, conversationGeneration, durable.payload);
|
|
570
|
+
});
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (this.#isUncertainPostFailure(error)) {
|
|
573
|
+
// The journal retains the stable action effect for fenced reconciliation.
|
|
574
|
+
}
|
|
575
|
+
if (!published) {
|
|
576
|
+
if (!this.#isUncertainPostFailure(error)) {
|
|
577
|
+
await this.store.transact(key, current =>
|
|
578
|
+
current?.outboundActionClientMsgId === clientMsgId &&
|
|
579
|
+
current.outboundActionOwner === this.#publicationOwnerId &&
|
|
580
|
+
current.outboundActionFence === fence
|
|
581
|
+
? nextRecord(current, {
|
|
582
|
+
outboundActionId: undefined,
|
|
583
|
+
outboundActionClientMsgId: undefined,
|
|
584
|
+
outboundActionOwner: undefined,
|
|
585
|
+
outboundActionLeaseExpiresAt: undefined,
|
|
586
|
+
updatedAt: this.#now(),
|
|
587
|
+
})
|
|
588
|
+
: current,
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
throw error;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
const active = await this.store.transact(key, current =>
|
|
595
|
+
current?.outboundActionClientMsgId === clientMsgId &&
|
|
596
|
+
current.outboundActionOwner === this.#publicationOwnerId &&
|
|
597
|
+
current.outboundActionFence === fence
|
|
598
|
+
? nextRecord(current, {
|
|
599
|
+
pendingActionId: actionId,
|
|
600
|
+
outboundActionId: undefined,
|
|
601
|
+
outboundActionClientMsgId: undefined,
|
|
602
|
+
outboundActionOwner: undefined,
|
|
603
|
+
outboundActionLeaseExpiresAt: undefined,
|
|
604
|
+
updatedAt: this.#now(),
|
|
605
|
+
})
|
|
606
|
+
: current,
|
|
607
|
+
);
|
|
608
|
+
if (!active) throw new Error("Slack conversation disappeared while activating action authority");
|
|
609
|
+
return active;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Posts a safe command outcome to the active mapped root thread. */
|
|
613
|
+
async postCommandResult(sessionId: string, content: string): Promise<boolean> {
|
|
614
|
+
const found = await this.findSession(sessionId, false);
|
|
615
|
+
if (found?.record.state !== "active" || !found.record.rootTs) return false;
|
|
616
|
+
const generation = this.#requireEndpointGeneration(found.record);
|
|
617
|
+
await this.#postDurable(`command-result:${sessionId}:${this.#randomId()}`, sessionId, generation, {
|
|
618
|
+
channel: found.record.channelId,
|
|
619
|
+
threadTs: found.record.rootTs,
|
|
620
|
+
text: content,
|
|
621
|
+
clientMsgId: this.#randomId(),
|
|
622
|
+
});
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async resolveAction(sessionId: string, actionId: string): Promise<void> {
|
|
627
|
+
const found = await this.findSession(sessionId, true);
|
|
628
|
+
if (!found) return;
|
|
629
|
+
await this.store.transact(found.key, current =>
|
|
630
|
+
current?.pendingActionId === actionId
|
|
631
|
+
? nextRecord(current, { pendingActionId: undefined, updatedAt: this.#now() })
|
|
632
|
+
: current,
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async close(sessionId: string, marker = "Session closed."): Promise<boolean> {
|
|
637
|
+
const found = await this.findSession(sessionId, true);
|
|
638
|
+
if (!found?.record.rootTs || found.record.state !== "active") return false;
|
|
639
|
+
const effectId = `close-marker:${sessionId}:${found.record.clientMsgId ?? found.record.rootTs}`;
|
|
640
|
+
const existing = await this.#journal.read<{
|
|
641
|
+
channel: string;
|
|
642
|
+
text: string;
|
|
643
|
+
threadTs?: string;
|
|
644
|
+
clientMsgId: string;
|
|
645
|
+
}>(effectId);
|
|
646
|
+
const durable = await this.#providerEffectPayload(
|
|
647
|
+
effectId,
|
|
648
|
+
sessionId,
|
|
649
|
+
this.#requireEndpointGeneration(found.record),
|
|
650
|
+
{
|
|
651
|
+
channel: found.record.channelId,
|
|
652
|
+
threadTs: found.record.rootTs,
|
|
653
|
+
text: marker,
|
|
654
|
+
clientMsgId: existing?.payload.clientMsgId ?? this.#randomId(),
|
|
655
|
+
},
|
|
656
|
+
);
|
|
657
|
+
await this.#postDurable(effectId, sessionId, this.#requireEndpointGeneration(found.record), durable.payload);
|
|
658
|
+
await this.store.transact(found.key, current =>
|
|
659
|
+
current
|
|
660
|
+
? nextRecord(current, { state: "closed_marker", pendingActionId: undefined, updatedAt: this.#now() })
|
|
661
|
+
: current,
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
return true;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async resume(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackConversation> {
|
|
668
|
+
return (await this.#resumeWithRootPublication(sessionId, body, endpointGeneration)).conversation;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async #resumeWithRootPublication(
|
|
672
|
+
sessionId: string,
|
|
673
|
+
body: string,
|
|
674
|
+
endpointGeneration?: number,
|
|
675
|
+
): Promise<SlackRootPublication> {
|
|
676
|
+
const inFlight = this.#rollovers.get(sessionId);
|
|
677
|
+
if (inFlight) {
|
|
678
|
+
const publication = await inFlight;
|
|
679
|
+
const activeGeneration = this.#requireEndpointGeneration(publication.conversation);
|
|
680
|
+
if (endpointGeneration === undefined || endpointGeneration === activeGeneration)
|
|
681
|
+
return { conversation: publication.conversation, created: false };
|
|
682
|
+
if (endpointGeneration < activeGeneration)
|
|
683
|
+
throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
|
|
684
|
+
return await this.#resumeWithRootPublication(sessionId, body, endpointGeneration);
|
|
685
|
+
}
|
|
686
|
+
const rollover = this.#resumeRoot(sessionId, body, endpointGeneration);
|
|
687
|
+
this.#rollovers.set(sessionId, rollover);
|
|
688
|
+
try {
|
|
689
|
+
return await rollover;
|
|
690
|
+
} finally {
|
|
691
|
+
if (this.#rollovers.get(sessionId) === rollover) this.#rollovers.delete(sessionId);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async #resumeRoot(sessionId: string, body: string, endpointGeneration?: number): Promise<SlackRootPublication> {
|
|
696
|
+
const endpoint = await this.#resolveEndpoint(sessionId);
|
|
697
|
+
if (!endpoint || (endpointGeneration !== undefined && endpoint.generation !== endpointGeneration))
|
|
698
|
+
throw new SlackEndpointBindingError("Slack root rollover requires the current session endpoint.");
|
|
699
|
+
const generation = endpoint.generation;
|
|
700
|
+
const forced = endpointGeneration === undefined;
|
|
701
|
+
let waitedForRollover = false;
|
|
702
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
703
|
+
let previous = await this.findSession(sessionId, true);
|
|
704
|
+
while (previous?.record.state === "posting_root") {
|
|
705
|
+
const reconciled = await this.#waitForRootReconciliation(previous.key, previous.record);
|
|
706
|
+
if (!reconciled) throw new Error("Slack root mapping disappeared during reconciliation.");
|
|
707
|
+
previous = { key: previous.key, record: reconciled };
|
|
708
|
+
waitedForRollover = true;
|
|
709
|
+
}
|
|
710
|
+
if (!previous) return await this.#postRoot(sessionId, body, generation);
|
|
711
|
+
if (previous.record.state !== "active") {
|
|
712
|
+
if (
|
|
713
|
+
previous.record.rootPublicationOwner !== this.#publicationOwnerId &&
|
|
714
|
+
!this.#leaseExpired(previous.record.rootPublicationLeaseExpiresAt, this.#now())
|
|
715
|
+
) {
|
|
716
|
+
waitedForRollover = true;
|
|
717
|
+
await Bun.sleep(10);
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
return await this.#postRoot(sessionId, body, generation);
|
|
721
|
+
}
|
|
722
|
+
const previousGeneration = this.#requireEndpointGeneration(previous.record);
|
|
723
|
+
if (previousGeneration > generation)
|
|
724
|
+
throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
|
|
725
|
+
if (previousGeneration === generation && (!forced || waitedForRollover))
|
|
726
|
+
return { conversation: previous.record, created: false };
|
|
727
|
+
|
|
728
|
+
let claimed = false;
|
|
729
|
+
let fence: number | undefined;
|
|
730
|
+
const locked = await this.store.transact(previous.key, current => {
|
|
731
|
+
if (
|
|
732
|
+
current?.state !== "active" ||
|
|
733
|
+
current.rootTs !== previous!.record.rootTs ||
|
|
734
|
+
current.endpointGeneration !== previousGeneration
|
|
735
|
+
)
|
|
736
|
+
return current;
|
|
737
|
+
const now = this.#now();
|
|
738
|
+
if (
|
|
739
|
+
current.rootPublicationOwner === this.#publicationOwnerId ||
|
|
740
|
+
!this.#leaseExpired(current.rootPublicationLeaseExpiresAt, now)
|
|
741
|
+
)
|
|
742
|
+
return current;
|
|
743
|
+
claimed = true;
|
|
744
|
+
fence = (current.rootPublicationFence ?? 0) + 1;
|
|
745
|
+
return nextRecord(current, {
|
|
746
|
+
rootPublicationOwner: this.#publicationOwnerId,
|
|
747
|
+
rootPublicationLeaseExpiresAt: now + this.#publicationLeaseMs,
|
|
748
|
+
rootPublicationFence: fence,
|
|
749
|
+
updatedAt: now,
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
if (!locked) throw new Error("Slack root mapping disappeared during rollover.");
|
|
753
|
+
if (!claimed || fence === undefined) {
|
|
754
|
+
waitedForRollover = true;
|
|
755
|
+
await Bun.sleep(10);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
let closed = false;
|
|
759
|
+
try {
|
|
760
|
+
closed = await this.close(sessionId);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
if (!(error instanceof SlackStaleEffectError)) throw error;
|
|
763
|
+
// The old-generation close marker was deliberately suppressed after
|
|
764
|
+
// reconciliation; this fenced rollover still owns the state transition.
|
|
765
|
+
closed = true;
|
|
766
|
+
}
|
|
767
|
+
const resumed = await this.store.transact(previous.key, current =>
|
|
768
|
+
closed &&
|
|
769
|
+
current &&
|
|
770
|
+
(current.state === "closed_marker" || current.state === "active") &&
|
|
771
|
+
current.rootPublicationOwner === this.#publicationOwnerId &&
|
|
772
|
+
current.rootPublicationFence === fence
|
|
773
|
+
? nextRecord(current, { state: "resumed_root", pendingActionId: undefined, updatedAt: this.#now() })
|
|
774
|
+
: current,
|
|
775
|
+
);
|
|
776
|
+
if (
|
|
777
|
+
resumed?.state === "resumed_root" &&
|
|
778
|
+
resumed.rootPublicationOwner === this.#publicationOwnerId &&
|
|
779
|
+
resumed.rootPublicationFence === fence
|
|
780
|
+
)
|
|
781
|
+
return await this.#postRoot(sessionId, body, generation);
|
|
782
|
+
waitedForRollover = true;
|
|
783
|
+
await Bun.sleep(10);
|
|
784
|
+
}
|
|
785
|
+
throw new Error("Slack root rollover is still pending");
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async #claimInbound(
|
|
789
|
+
inbound: {
|
|
790
|
+
eventId: string;
|
|
791
|
+
eventContext?: string;
|
|
792
|
+
teamId: string;
|
|
793
|
+
channelId: string;
|
|
794
|
+
rootTs: string;
|
|
795
|
+
event: SlackEvent;
|
|
796
|
+
},
|
|
797
|
+
actorId: string,
|
|
798
|
+
): Promise<
|
|
799
|
+
{ key: string; endpoint: SlackEndpoint; sessionId: string; receipt: SlackInboundDispatchReceipt } | undefined
|
|
800
|
+
> {
|
|
801
|
+
const document = await this.store.load();
|
|
802
|
+
const matched = Object.entries(document.conversations)
|
|
803
|
+
.map(([mappingKey, candidate]) => ({ mappingKey, candidate }))
|
|
804
|
+
.filter(
|
|
805
|
+
({ candidate }) =>
|
|
806
|
+
candidate.teamId === inbound.teamId &&
|
|
807
|
+
candidate.channelId === inbound.channelId &&
|
|
808
|
+
candidate.rootTs === inbound.rootTs,
|
|
809
|
+
)
|
|
810
|
+
.sort(
|
|
811
|
+
(left, right) =>
|
|
812
|
+
(right.candidate.endpointGeneration ?? -1) - (left.candidate.endpointGeneration ?? -1) ||
|
|
813
|
+
right.candidate.generation - left.candidate.generation ||
|
|
814
|
+
right.candidate.updatedAt - left.candidate.updatedAt,
|
|
815
|
+
)[0];
|
|
816
|
+
if (!matched) return undefined;
|
|
817
|
+
const { mappingKey: key, candidate: record } = matched;
|
|
818
|
+
if (
|
|
819
|
+
!record.sessionId ||
|
|
820
|
+
!record.endpointGeneration ||
|
|
821
|
+
!acceptsSlackInbound(record, inbound.rootTs, record.endpointGeneration)
|
|
822
|
+
)
|
|
823
|
+
return undefined;
|
|
824
|
+
const endpoint = await this.#resolveEndpoint(record.sessionId);
|
|
825
|
+
if (!endpoint || endpoint.generation !== record.endpointGeneration) return undefined;
|
|
826
|
+
const interactionId = text(inbound.event.client_msg_id) ?? inbound.eventId;
|
|
827
|
+
const retryKey = `${inbound.eventId}:${interactionId}`;
|
|
828
|
+
const inboundText = text(inbound.event.text);
|
|
829
|
+
const command = inboundText?.startsWith("/sdk ") ?? false;
|
|
830
|
+
|
|
831
|
+
const idempotencyKey = `slack:${inbound.teamId}:${inbound.channelId}:${inbound.rootTs}:${actorId}:${inbound.eventId}:${interactionId}`;
|
|
832
|
+
const effectId = `inbound:${inbound.teamId}:${inbound.channelId}:${inbound.rootTs}:${actorId}:${inbound.eventId}:${interactionId}`;
|
|
833
|
+
const routing: SlackInboundRouting = {
|
|
834
|
+
teamId: inbound.teamId,
|
|
835
|
+
channelId: inbound.channelId,
|
|
836
|
+
rootTs: inbound.rootTs,
|
|
837
|
+
eventId: inbound.eventId,
|
|
838
|
+
interactionId,
|
|
839
|
+
actorId,
|
|
840
|
+
retryKey,
|
|
841
|
+
eventContext: inbound.eventContext,
|
|
842
|
+
kind: command ? "command" : "action",
|
|
843
|
+
...(command ? {} : record.pendingActionId ? { actionId: record.pendingActionId } : {}),
|
|
844
|
+
};
|
|
845
|
+
const initialPayload: SlackInboundEffectPayload | undefined = command
|
|
846
|
+
? { type: "command", content: inboundText!, idempotencyKey, routing }
|
|
847
|
+
: record.pendingActionId
|
|
848
|
+
? { type: "reply", id: record.pendingActionId, answer: inboundText ?? "", idempotencyKey, routing }
|
|
849
|
+
: undefined;
|
|
850
|
+
const existingEffect = await this.#journal.read<SlackInboundEffectPayload>(effectId);
|
|
851
|
+
const payload = existingEffect?.payload ?? initialPayload;
|
|
852
|
+
if (!payload) return undefined;
|
|
853
|
+
await this.#rescheduleAfterEffectTransition(
|
|
854
|
+
this.#journal.enqueue({
|
|
855
|
+
id: effectId,
|
|
856
|
+
kind: command ? "sdk.inbound.command" : "sdk.inbound.reply",
|
|
857
|
+
transport: "slack",
|
|
858
|
+
sessionId: record.sessionId,
|
|
859
|
+
endpointGeneration: endpoint.generation,
|
|
860
|
+
payload,
|
|
861
|
+
}),
|
|
862
|
+
);
|
|
863
|
+
let sessionId: string | undefined;
|
|
864
|
+
let receipt: SlackInboundDispatchReceipt | undefined;
|
|
865
|
+
await this.store.transact(key, current => {
|
|
866
|
+
if (!current?.sessionId || !acceptsSlackInbound(current, inbound.rootTs, endpoint.generation)) return current;
|
|
867
|
+
const existing = (current.inboundDispatches ?? []).find(
|
|
868
|
+
candidate =>
|
|
869
|
+
candidate.eventId === inbound.eventId ||
|
|
870
|
+
candidate.interactionId === interactionId ||
|
|
871
|
+
candidate.retryKey === retryKey ||
|
|
872
|
+
(inbound.eventContext !== undefined && candidate.eventContext === inbound.eventContext),
|
|
873
|
+
);
|
|
874
|
+
if (existing) {
|
|
875
|
+
sessionId = current.sessionId;
|
|
876
|
+
receipt = existing;
|
|
877
|
+
return current;
|
|
878
|
+
}
|
|
879
|
+
if (
|
|
880
|
+
current.seenEventIds.includes(inbound.eventId) ||
|
|
881
|
+
current.seenInteractionIds.includes(interactionId) ||
|
|
882
|
+
current.seenRetryKeys.includes(retryKey) ||
|
|
883
|
+
(inbound.eventContext !== undefined && current.seenContextIds.includes(inbound.eventContext)) ||
|
|
884
|
+
(!command && !current.pendingActionId)
|
|
885
|
+
)
|
|
886
|
+
return current;
|
|
887
|
+
sessionId = current.sessionId;
|
|
888
|
+
receipt = {
|
|
889
|
+
key: `${inbound.eventId}:${interactionId}`,
|
|
890
|
+
eventId: inbound.eventId,
|
|
891
|
+
interactionId,
|
|
892
|
+
retryKey,
|
|
893
|
+
eventContext: inbound.eventContext,
|
|
894
|
+
kind: command ? "command" : "action",
|
|
895
|
+
...(command ? {} : { actionId: current.pendingActionId }),
|
|
896
|
+
endpointGeneration: endpoint.generation,
|
|
897
|
+
effectId,
|
|
898
|
+
idempotencyKey,
|
|
899
|
+
};
|
|
900
|
+
return nextRecord(current, {
|
|
901
|
+
inboundDispatches: [...(current.inboundDispatches ?? []), receipt],
|
|
902
|
+
updatedAt: this.#now(),
|
|
903
|
+
});
|
|
904
|
+
});
|
|
905
|
+
return sessionId && receipt ? { key, endpoint, sessionId, receipt } : undefined;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async #dispatchInbound(claim: {
|
|
909
|
+
key: string;
|
|
910
|
+
endpoint: SlackEndpoint;
|
|
911
|
+
sessionId: string;
|
|
912
|
+
receipt: SlackInboundDispatchReceipt;
|
|
913
|
+
}): Promise<boolean> {
|
|
914
|
+
const current = await this.store.read(claim.key);
|
|
915
|
+
const effect = await this.#journal.read<SlackInboundEffectPayload>(claim.receipt.effectId);
|
|
916
|
+
if (effect?.state === "terminal") {
|
|
917
|
+
await this.#finalizeTerminalInboundDispatch(claim.key, claim.receipt);
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
if (
|
|
921
|
+
!current ||
|
|
922
|
+
!this.#mappedInboundDispatchable(current, claim.receipt, effect) ||
|
|
923
|
+
claim.receipt.endpointGeneration !== claim.endpoint.generation
|
|
924
|
+
) {
|
|
925
|
+
await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_mapping");
|
|
926
|
+
return false;
|
|
927
|
+
}
|
|
928
|
+
return await this.#dispatchEffect(claim, claim.receipt.effectId);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
async #dispatchEffect(
|
|
932
|
+
claim: { key: string; endpoint: SlackEndpoint; sessionId: string; receipt: SlackInboundDispatchReceipt },
|
|
933
|
+
effectId: string,
|
|
934
|
+
): Promise<boolean> {
|
|
935
|
+
const effect = await this.#rescheduleAfterEffectTransition(
|
|
936
|
+
this.#journal.claim<SlackInboundEffectPayload>(
|
|
937
|
+
effectId,
|
|
938
|
+
this.#publicationOwnerId,
|
|
939
|
+
Math.max(this.#publicationLeaseMs, 100),
|
|
940
|
+
),
|
|
941
|
+
);
|
|
942
|
+
if (!effect) return false;
|
|
943
|
+
if (!this.#matchesInboundEffect(effect, claim.receipt)) {
|
|
944
|
+
await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_mapping");
|
|
945
|
+
return false;
|
|
946
|
+
}
|
|
947
|
+
const lease: ChatEffectLease = { owner: this.#publicationOwnerId, epoch: effect.epoch };
|
|
948
|
+
try {
|
|
949
|
+
if (effect.payload.type === "command") {
|
|
950
|
+
const payload = effect.payload;
|
|
951
|
+
const accepted = await this.#withEffectLease(effect.id, lease, async () => {
|
|
952
|
+
if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
|
|
953
|
+
return await (this.options.onCommand?.(
|
|
954
|
+
claim.sessionId,
|
|
955
|
+
payload.content,
|
|
956
|
+
claim.endpoint,
|
|
957
|
+
payload.idempotencyKey,
|
|
958
|
+
) ?? Promise.resolve(false));
|
|
959
|
+
});
|
|
960
|
+
if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
|
|
961
|
+
await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
const recorded = await this.#journal.record(effect.id, lease, "terminal", {
|
|
965
|
+
status: accepted ? "accepted" : "rejected",
|
|
966
|
+
});
|
|
967
|
+
if (recorded) await this.#finishDispatch(claim, "terminal");
|
|
968
|
+
return accepted && !!recorded;
|
|
969
|
+
}
|
|
970
|
+
await this.#withEffectLease(effect.id, lease, async () => {
|
|
971
|
+
if (!(await this.#inboundEffectCurrent(claim, effect.id))) throw new SlackStaleEffectError();
|
|
972
|
+
this.options.createClient(claim.endpoint).send(effect.payload);
|
|
973
|
+
});
|
|
974
|
+
if (!(await this.#inboundEffectCurrent(claim, effect.id))) {
|
|
975
|
+
await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
978
|
+
const recorded = await this.#journal.record(effect.id, lease, "terminal", { status: "sent" });
|
|
979
|
+
if (recorded) await this.#finishDispatch(claim, "terminal");
|
|
980
|
+
return !!recorded;
|
|
981
|
+
} catch (error) {
|
|
982
|
+
if (error instanceof SlackStaleEffectError) {
|
|
983
|
+
await this.#terminalizeStaleInboundDispatch(claim.key, claim.receipt, "stale_binding");
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
const state = this.#isDefiniteSdkPreSendFailure(error) ? "accepted" : "uncertain";
|
|
987
|
+
|
|
988
|
+
const recorded = await this.#rescheduleAfterEffectTransition(
|
|
989
|
+
this.#journal.record(effect.id, lease, state, { status: state }),
|
|
990
|
+
);
|
|
991
|
+
if (recorded && state === "accepted") await this.#releaseDispatch(claim);
|
|
992
|
+
else if (recorded) await this.#finishDispatch(claim, "uncertain");
|
|
993
|
+
return false;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async #inboundEffectCurrent(
|
|
998
|
+
claim: { key: string; endpoint: SlackEndpoint; sessionId: string; receipt: SlackInboundDispatchReceipt },
|
|
999
|
+
effectId: string,
|
|
1000
|
+
): Promise<boolean> {
|
|
1001
|
+
const endpoint = await this.#resolveEndpoint(claim.sessionId);
|
|
1002
|
+
if (!endpoint || endpoint.generation !== claim.endpoint.generation) return false;
|
|
1003
|
+
const [current, effect] = await Promise.all([
|
|
1004
|
+
this.store.read(claim.key),
|
|
1005
|
+
this.#journal.read<SlackInboundEffectPayload>(effectId),
|
|
1006
|
+
]);
|
|
1007
|
+
return (
|
|
1008
|
+
!!current &&
|
|
1009
|
+
!!effect &&
|
|
1010
|
+
current.sessionId === claim.sessionId &&
|
|
1011
|
+
acceptsSlackInbound(current, current.rootTs ?? "", claim.endpoint.generation) &&
|
|
1012
|
+
claim.receipt.endpointGeneration === claim.endpoint.generation &&
|
|
1013
|
+
this.#matchesInboundEffect(effect, claim.receipt) &&
|
|
1014
|
+
(await this.#actorAuthorized(effect.payload.routing.actorId)) &&
|
|
1015
|
+
(current.inboundDispatches ?? []).some(receipt => this.#sameInboundReceipt(receipt, claim.receipt))
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async #withEffectLease<T>(id: string, lease: ChatEffectLease, operation: () => Promise<T>): Promise<T> {
|
|
1020
|
+
// The effect was just claimed, so its lease is current. The periodic renewal
|
|
1021
|
+
// still protects the external operation without another durable write first.
|
|
1022
|
+
return await this.#withRenewal(
|
|
1023
|
+
operation,
|
|
1024
|
+
async () => {
|
|
1025
|
+
if (
|
|
1026
|
+
!(await this.#rescheduleAfterEffectTransition(
|
|
1027
|
+
this.#journal.renew(id, lease, Math.max(this.#publicationLeaseMs, 100)),
|
|
1028
|
+
))
|
|
1029
|
+
)
|
|
1030
|
+
throw new Error("Slack effect lease renewal failed");
|
|
1031
|
+
},
|
|
1032
|
+
false,
|
|
1033
|
+
);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
async #drainPendingDispatches(): Promise<void> {
|
|
1037
|
+
await this.#reconcileTerminalInboundReceipts();
|
|
1038
|
+
const document = await this.store.load();
|
|
1039
|
+
const dispatchableEffectIds = new Set<string>();
|
|
1040
|
+
for (const [key, record] of Object.entries(document.conversations)) {
|
|
1041
|
+
for (const receipt of record.inboundDispatches ?? []) {
|
|
1042
|
+
const effect = await this.#journal.read<SlackInboundEffectPayload>(receipt.effectId);
|
|
1043
|
+
if (effect?.state === "terminal") {
|
|
1044
|
+
await this.#finalizeTerminalInboundDispatch(key, receipt);
|
|
1045
|
+
continue;
|
|
1046
|
+
}
|
|
1047
|
+
if (!this.#mappedInboundDispatchable(record, receipt, effect)) {
|
|
1048
|
+
await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_mapping");
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
const endpoint = await this.#resolveEndpoint(record.sessionId!);
|
|
1052
|
+
if (!endpoint || endpoint.generation !== receipt.endpointGeneration) {
|
|
1053
|
+
await this.#terminalizeStaleInboundDispatch(key, receipt, "stale_binding");
|
|
1054
|
+
continue;
|
|
1055
|
+
}
|
|
1056
|
+
if (
|
|
1057
|
+
effect.state === "uncertain" ||
|
|
1058
|
+
(effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now())
|
|
1059
|
+
)
|
|
1060
|
+
continue;
|
|
1061
|
+
dispatchableEffectIds.add(receipt.effectId);
|
|
1062
|
+
const inflightKey = `${key}\u0000${receipt.key}`;
|
|
1063
|
+
if (this.#inflightInbound.has(inflightKey)) continue;
|
|
1064
|
+
this.#inflightInbound.add(inflightKey);
|
|
1065
|
+
try {
|
|
1066
|
+
await this.#dispatchInbound({ key, endpoint, sessionId: record.sessionId!, receipt });
|
|
1067
|
+
} finally {
|
|
1068
|
+
this.#inflightInbound.delete(inflightKey);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
for (const effect of await this.#journal.list()) {
|
|
1073
|
+
if (
|
|
1074
|
+
dispatchableEffectIds.has(effect.id) ||
|
|
1075
|
+
effect.transport !== "slack" ||
|
|
1076
|
+
effect.state === "terminal" ||
|
|
1077
|
+
!effect.sessionId ||
|
|
1078
|
+
(effect.kind !== "sdk.inbound.command" && effect.kind !== "sdk.inbound.reply")
|
|
1079
|
+
)
|
|
1080
|
+
continue;
|
|
1081
|
+
const adopted = await this.#adoptOrphanInbound(effect as ChatEffect<SlackInboundEffectPayload>);
|
|
1082
|
+
if (!adopted) continue;
|
|
1083
|
+
const endpoint = await this.#resolveEndpoint(adopted.sessionId);
|
|
1084
|
+
if (
|
|
1085
|
+
!endpoint ||
|
|
1086
|
+
endpoint.generation !== adopted.receipt.endpointGeneration ||
|
|
1087
|
+
!(await this.#inboundEffectCurrent(
|
|
1088
|
+
{ key: adopted.key, endpoint, sessionId: adopted.sessionId, receipt: adopted.receipt },
|
|
1089
|
+
effect.id,
|
|
1090
|
+
))
|
|
1091
|
+
) {
|
|
1092
|
+
await this.#terminalizeStaleInboundDispatch(adopted.key, adopted.receipt, "stale_binding");
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (effect.state === "uncertain" || (effect.state === "leased" && (effect.leaseExpiresAt ?? 0) > this.#now()))
|
|
1096
|
+
continue;
|
|
1097
|
+
await this.#dispatchInbound({
|
|
1098
|
+
key: adopted.key,
|
|
1099
|
+
endpoint,
|
|
1100
|
+
sessionId: adopted.sessionId,
|
|
1101
|
+
receipt: adopted.receipt,
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
async #actorAuthorized(actorId: string): Promise<boolean> {
|
|
1107
|
+
const authorizeActor = this.options.authorizeActor;
|
|
1108
|
+
if (!authorizeActor || !text(actorId)) return false;
|
|
1109
|
+
try {
|
|
1110
|
+
return await authorizeActor(actorId);
|
|
1111
|
+
} catch {
|
|
1112
|
+
return false;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
#validInboundRouting(routing: SlackInboundRouting): boolean {
|
|
1116
|
+
return (
|
|
1117
|
+
routing.teamId === this.options.teamId &&
|
|
1118
|
+
routing.channelId === this.options.channelId &&
|
|
1119
|
+
text(routing.rootTs) !== undefined &&
|
|
1120
|
+
text(routing.actorId) !== undefined &&
|
|
1121
|
+
text(routing.eventId) !== undefined &&
|
|
1122
|
+
text(routing.interactionId) !== undefined &&
|
|
1123
|
+
routing.retryKey === `${routing.eventId}:${routing.interactionId}` &&
|
|
1124
|
+
(routing.eventContext === undefined || text(routing.eventContext) !== undefined) &&
|
|
1125
|
+
(routing.kind === "command" || (routing.kind === "action" && text(routing.actionId) !== undefined))
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
#matchesInboundEffect(effect: ChatEffect<SlackInboundEffectPayload>, receipt: SlackInboundDispatchReceipt): boolean {
|
|
1129
|
+
const payload = effect.payload;
|
|
1130
|
+
const routing = payload?.routing;
|
|
1131
|
+
if (
|
|
1132
|
+
!routing ||
|
|
1133
|
+
!this.#validInboundRouting(routing) ||
|
|
1134
|
+
!effect.sessionId ||
|
|
1135
|
+
!Number.isSafeInteger(effect.endpointGeneration) ||
|
|
1136
|
+
effect.endpointGeneration <= 0
|
|
1137
|
+
)
|
|
1138
|
+
return false;
|
|
1139
|
+
const effectId = `inbound:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
|
|
1140
|
+
const idempotencyKey = `slack:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
|
|
1141
|
+
return (
|
|
1142
|
+
effect.id === effectId &&
|
|
1143
|
+
payload.idempotencyKey === idempotencyKey &&
|
|
1144
|
+
receipt.key === `${routing.eventId}:${routing.interactionId}` &&
|
|
1145
|
+
receipt.eventId === routing.eventId &&
|
|
1146
|
+
receipt.interactionId === routing.interactionId &&
|
|
1147
|
+
receipt.retryKey === routing.retryKey &&
|
|
1148
|
+
receipt.eventContext === routing.eventContext &&
|
|
1149
|
+
receipt.kind === routing.kind &&
|
|
1150
|
+
receipt.actionId === routing.actionId &&
|
|
1151
|
+
receipt.endpointGeneration === effect.endpointGeneration &&
|
|
1152
|
+
receipt.effectId === effect.id &&
|
|
1153
|
+
receipt.idempotencyKey === payload.idempotencyKey &&
|
|
1154
|
+
((payload.type === "command" && routing.kind === "command" && effect.kind === "sdk.inbound.command") ||
|
|
1155
|
+
(payload.type === "reply" &&
|
|
1156
|
+
routing.kind === "action" &&
|
|
1157
|
+
effect.kind === "sdk.inbound.reply" &&
|
|
1158
|
+
payload.id === routing.actionId))
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
#sameInboundReceipt(left: SlackInboundDispatchReceipt, right: SlackInboundDispatchReceipt): boolean {
|
|
1162
|
+
return (
|
|
1163
|
+
left.key === right.key &&
|
|
1164
|
+
left.eventId === right.eventId &&
|
|
1165
|
+
left.interactionId === right.interactionId &&
|
|
1166
|
+
left.retryKey === right.retryKey &&
|
|
1167
|
+
left.eventContext === right.eventContext &&
|
|
1168
|
+
left.kind === right.kind &&
|
|
1169
|
+
left.actionId === right.actionId &&
|
|
1170
|
+
left.endpointGeneration === right.endpointGeneration &&
|
|
1171
|
+
left.effectId === right.effectId &&
|
|
1172
|
+
left.idempotencyKey === right.idempotencyKey
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
#mappedInboundDispatchable(
|
|
1176
|
+
record: SlackConversation,
|
|
1177
|
+
receipt: SlackInboundDispatchReceipt,
|
|
1178
|
+
effect: ChatEffect<SlackInboundEffectPayload> | undefined,
|
|
1179
|
+
): effect is ChatEffect<SlackInboundEffectPayload> {
|
|
1180
|
+
return (
|
|
1181
|
+
!!effect &&
|
|
1182
|
+
record.state === "active" &&
|
|
1183
|
+
!!record.sessionId &&
|
|
1184
|
+
!!record.rootTs &&
|
|
1185
|
+
acceptsSlackInbound(record, record.rootTs, receipt.endpointGeneration) &&
|
|
1186
|
+
record.sessionId === effect.sessionId &&
|
|
1187
|
+
this.#matchesInboundEffect(effect, receipt)
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
async #reconcileTerminalInboundReceipts(): Promise<void> {
|
|
1191
|
+
for (const effect of await this.#journal.list()) {
|
|
1192
|
+
if (
|
|
1193
|
+
effect.transport !== "slack" ||
|
|
1194
|
+
effect.state !== "terminal" ||
|
|
1195
|
+
(effect.kind !== "sdk.inbound.command" && effect.kind !== "sdk.inbound.reply")
|
|
1196
|
+
)
|
|
1197
|
+
continue;
|
|
1198
|
+
const payload = effect.payload as SlackInboundEffectPayload;
|
|
1199
|
+
if (!payload?.routing || !this.#validInboundRouting(payload.routing)) continue;
|
|
1200
|
+
const key = slackConversationKey({
|
|
1201
|
+
teamId: payload.routing.teamId,
|
|
1202
|
+
channelId: payload.routing.channelId,
|
|
1203
|
+
rootTs: payload.routing.rootTs,
|
|
1204
|
+
});
|
|
1205
|
+
const current = await this.store.read(key);
|
|
1206
|
+
let receipt: SlackInboundDispatchReceipt | undefined;
|
|
1207
|
+
if (current && current.sessionId === effect.sessionId) {
|
|
1208
|
+
receipt = current.inboundDispatches?.find(candidate =>
|
|
1209
|
+
this.#matchesInboundEffect(effect as ChatEffect<SlackInboundEffectPayload>, candidate),
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
if (receipt) await this.#finalizeTerminalInboundDispatch(key, receipt);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
async #terminalizeStaleInboundDispatch(
|
|
1216
|
+
key: string,
|
|
1217
|
+
receipt: SlackInboundDispatchReceipt,
|
|
1218
|
+
status: "stale_binding" | "stale_mapping",
|
|
1219
|
+
): Promise<void> {
|
|
1220
|
+
await this.#journal.terminalize(receipt.effectId, { status });
|
|
1221
|
+
await this.#finalizeTerminalInboundDispatch(key, receipt);
|
|
1222
|
+
}
|
|
1223
|
+
async #finalizeTerminalInboundDispatch(key: string, receipt: SlackInboundDispatchReceipt): Promise<void> {
|
|
1224
|
+
await this.store.transact(key, current => {
|
|
1225
|
+
const found = current?.inboundDispatches?.find(candidate => this.#sameInboundReceipt(candidate, receipt));
|
|
1226
|
+
return !current || !found ? current : this.#completeInboundDispatch(current, found, true);
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
#completeInboundDispatch(
|
|
1230
|
+
current: SlackConversation,
|
|
1231
|
+
receipt: SlackInboundDispatchReceipt,
|
|
1232
|
+
terminal: boolean,
|
|
1233
|
+
): SlackConversation {
|
|
1234
|
+
return nextRecord(current, {
|
|
1235
|
+
pendingActionId:
|
|
1236
|
+
receipt.kind === "action" && current.pendingActionId === receipt.actionId
|
|
1237
|
+
? undefined
|
|
1238
|
+
: current.pendingActionId,
|
|
1239
|
+
seenEventIds: [...current.seenEventIds, receipt.eventId],
|
|
1240
|
+
seenInteractionIds: [...current.seenInteractionIds, receipt.interactionId],
|
|
1241
|
+
seenRetryKeys: [...current.seenRetryKeys, receipt.retryKey],
|
|
1242
|
+
seenContextIds:
|
|
1243
|
+
receipt.eventContext === undefined
|
|
1244
|
+
? current.seenContextIds
|
|
1245
|
+
: [...current.seenContextIds, receipt.eventContext],
|
|
1246
|
+
inboundDispatches: terminal
|
|
1247
|
+
? (current.inboundDispatches ?? []).filter(candidate => !this.#sameInboundReceipt(candidate, receipt))
|
|
1248
|
+
: current.inboundDispatches,
|
|
1249
|
+
updatedAt: this.#now(),
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
async #adoptOrphanInbound(
|
|
1253
|
+
effect: ChatEffect<SlackInboundEffectPayload>,
|
|
1254
|
+
): Promise<{ key: string; sessionId: string; receipt: SlackInboundDispatchReceipt } | undefined> {
|
|
1255
|
+
const payload = effect.payload;
|
|
1256
|
+
const routing = payload?.routing;
|
|
1257
|
+
if (!routing || !this.#validInboundRouting(routing) || !(await this.#actorAuthorized(routing.actorId))) {
|
|
1258
|
+
await this.#terminalizeRejectedInbound(effect.id);
|
|
1259
|
+
return undefined;
|
|
1260
|
+
}
|
|
1261
|
+
const matches = Object.entries((await this.store.load()).conversations).filter(
|
|
1262
|
+
([, record]) =>
|
|
1263
|
+
record.teamId === routing.teamId &&
|
|
1264
|
+
record.channelId === routing.channelId &&
|
|
1265
|
+
record.rootTs === routing.rootTs,
|
|
1266
|
+
);
|
|
1267
|
+
if (matches.length !== 1) {
|
|
1268
|
+
await this.#terminalizeRejectedInbound(effect.id);
|
|
1269
|
+
return undefined;
|
|
1270
|
+
}
|
|
1271
|
+
const key = matches[0]![0];
|
|
1272
|
+
const expectedEffectId = `inbound:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
|
|
1273
|
+
const expectedIdempotencyKey = `slack:${routing.teamId}:${routing.channelId}:${routing.rootTs}:${routing.actorId}:${routing.eventId}:${routing.interactionId}`;
|
|
1274
|
+
const validPayload =
|
|
1275
|
+
effect.transport === "slack" &&
|
|
1276
|
+
!!effect.sessionId &&
|
|
1277
|
+
Number.isSafeInteger(effect.endpointGeneration) &&
|
|
1278
|
+
effect.endpointGeneration > 0 &&
|
|
1279
|
+
effect.id === expectedEffectId &&
|
|
1280
|
+
payload.idempotencyKey === expectedIdempotencyKey &&
|
|
1281
|
+
((payload.type === "command" && routing.kind === "command" && effect.kind === "sdk.inbound.command") ||
|
|
1282
|
+
(payload.type === "reply" &&
|
|
1283
|
+
routing.kind === "action" &&
|
|
1284
|
+
effect.kind === "sdk.inbound.reply" &&
|
|
1285
|
+
payload.id === routing.actionId));
|
|
1286
|
+
let receipt: SlackInboundDispatchReceipt | undefined;
|
|
1287
|
+
await this.store.transact(key, current => {
|
|
1288
|
+
if (
|
|
1289
|
+
!validPayload ||
|
|
1290
|
+
!current ||
|
|
1291
|
+
!effect.sessionId ||
|
|
1292
|
+
current.sessionId !== effect.sessionId ||
|
|
1293
|
+
!acceptsSlackInbound(current, routing.rootTs, effect.endpointGeneration)
|
|
1294
|
+
)
|
|
1295
|
+
return current;
|
|
1296
|
+
const existing = (current.inboundDispatches ?? []).find(
|
|
1297
|
+
candidate =>
|
|
1298
|
+
candidate.eventId === routing.eventId ||
|
|
1299
|
+
candidate.interactionId === routing.interactionId ||
|
|
1300
|
+
candidate.retryKey === routing.retryKey ||
|
|
1301
|
+
(routing.eventContext !== undefined && candidate.eventContext === routing.eventContext),
|
|
1302
|
+
);
|
|
1303
|
+
if (existing) {
|
|
1304
|
+
if (this.#matchesInboundEffect(effect, existing)) receipt = existing;
|
|
1305
|
+
return current;
|
|
1306
|
+
}
|
|
1307
|
+
if (
|
|
1308
|
+
current.seenEventIds.includes(routing.eventId) ||
|
|
1309
|
+
current.seenInteractionIds.includes(routing.interactionId) ||
|
|
1310
|
+
current.seenRetryKeys.includes(routing.retryKey) ||
|
|
1311
|
+
(routing.eventContext !== undefined && current.seenContextIds.includes(routing.eventContext)) ||
|
|
1312
|
+
(routing.kind === "action" && current.pendingActionId !== routing.actionId)
|
|
1313
|
+
)
|
|
1314
|
+
return current;
|
|
1315
|
+
receipt = {
|
|
1316
|
+
key: `${routing.eventId}:${routing.interactionId}`,
|
|
1317
|
+
eventId: routing.eventId,
|
|
1318
|
+
interactionId: routing.interactionId,
|
|
1319
|
+
retryKey: routing.retryKey,
|
|
1320
|
+
eventContext: routing.eventContext,
|
|
1321
|
+
kind: routing.kind,
|
|
1322
|
+
...(routing.kind === "action" ? { actionId: routing.actionId } : {}),
|
|
1323
|
+
endpointGeneration: effect.endpointGeneration,
|
|
1324
|
+
effectId: effect.id,
|
|
1325
|
+
idempotencyKey: payload.idempotencyKey,
|
|
1326
|
+
};
|
|
1327
|
+
return nextRecord(current, {
|
|
1328
|
+
inboundDispatches: [...(current.inboundDispatches ?? []), receipt],
|
|
1329
|
+
updatedAt: this.#now(),
|
|
1330
|
+
});
|
|
1331
|
+
});
|
|
1332
|
+
if (receipt && effect.sessionId) return { key, sessionId: effect.sessionId, receipt };
|
|
1333
|
+
await this.#terminalizeRejectedInbound(effect.id);
|
|
1334
|
+
return undefined;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async #terminalizeRejectedInbound(effectId: string): Promise<void> {
|
|
1338
|
+
await this.#journal.terminalize(effectId, { status: "rejected" });
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
async #finishDispatch(
|
|
1342
|
+
claim: { key: string; endpoint: SlackEndpoint; receipt: SlackInboundDispatchReceipt },
|
|
1343
|
+
state: "terminal" | "uncertain",
|
|
1344
|
+
): Promise<void> {
|
|
1345
|
+
await this.store.transact(claim.key, current => {
|
|
1346
|
+
if (!current || !acceptsSlackInbound(current, current.rootTs ?? "", claim.endpoint.generation)) return current;
|
|
1347
|
+
const found = (current.inboundDispatches ?? []).find(receipt =>
|
|
1348
|
+
this.#sameInboundReceipt(receipt, claim.receipt),
|
|
1349
|
+
);
|
|
1350
|
+
return found ? this.#completeInboundDispatch(current, found, state === "terminal") : current;
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
async #releaseDispatch(claim: {
|
|
1355
|
+
key: string;
|
|
1356
|
+
endpoint: SlackEndpoint;
|
|
1357
|
+
receipt: SlackInboundDispatchReceipt;
|
|
1358
|
+
}): Promise<void> {
|
|
1359
|
+
await this.store.transact(claim.key, current => {
|
|
1360
|
+
if (!current || !acceptsSlackInbound(current, current.rootTs ?? "", claim.endpoint.generation)) return current;
|
|
1361
|
+
const found = (current.inboundDispatches ?? []).find(receipt =>
|
|
1362
|
+
this.#sameInboundReceipt(receipt, claim.receipt),
|
|
1363
|
+
);
|
|
1364
|
+
return found
|
|
1365
|
+
? nextRecord(current, {
|
|
1366
|
+
inboundDispatches: (current.inboundDispatches ?? []).filter(
|
|
1367
|
+
candidate => !this.#sameInboundReceipt(candidate, found),
|
|
1368
|
+
),
|
|
1369
|
+
updatedAt: this.#now(),
|
|
1370
|
+
})
|
|
1371
|
+
: current;
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
async #reconcileTerminalProviderReceipts(): Promise<void> {
|
|
1376
|
+
for (const effect of await this.#journal.list()) {
|
|
1377
|
+
try {
|
|
1378
|
+
if (effect.kind !== "provider-post" || effect.transport !== "slack" || effect.state !== "terminal")
|
|
1379
|
+
continue;
|
|
1380
|
+
const payload = effect.payload as { channel?: unknown; threadTs?: unknown; clientMsgId?: unknown };
|
|
1381
|
+
const receipt = effect.receipt;
|
|
1382
|
+
if (
|
|
1383
|
+
receipt?.provider !== "slack" ||
|
|
1384
|
+
typeof payload.clientMsgId !== "string" ||
|
|
1385
|
+
(receipt.status !== "posted" && receipt.status !== "not_found") ||
|
|
1386
|
+
(receipt.status === "posted" && typeof receipt.timestamp !== "string")
|
|
1387
|
+
)
|
|
1388
|
+
continue;
|
|
1389
|
+
const normalized = {
|
|
1390
|
+
channel: payload.channel,
|
|
1391
|
+
threadTs: payload.threadTs,
|
|
1392
|
+
clientMsgId: payload.clientMsgId,
|
|
1393
|
+
};
|
|
1394
|
+
if (typeof normalized.threadTs === "string") {
|
|
1395
|
+
if (receipt.status === "posted") await this.#activateReconciledAction(effect, normalized);
|
|
1396
|
+
else await this.#releaseUnreconciledAction(effect, normalized);
|
|
1397
|
+
} else if (receipt.status === "posted") {
|
|
1398
|
+
await this.#activateReconciledRoot(effect, normalized, receipt.timestamp!);
|
|
1399
|
+
} else {
|
|
1400
|
+
await this.#releaseUnreconciledRoot(effect, normalized);
|
|
1401
|
+
}
|
|
1402
|
+
} catch {
|
|
1403
|
+
await this.#recordRecoveryFailure(effect);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async #activateReconciledRoot(
|
|
1409
|
+
effect: ChatEffect,
|
|
1410
|
+
payload: { channel?: unknown; clientMsgId: string },
|
|
1411
|
+
rootTs: string,
|
|
1412
|
+
): Promise<void> {
|
|
1413
|
+
if (
|
|
1414
|
+
!effect.sessionId ||
|
|
1415
|
+
typeof payload.channel !== "string" ||
|
|
1416
|
+
payload.channel !== this.options.channelId ||
|
|
1417
|
+
!Number.isSafeInteger(effect.endpointGeneration) ||
|
|
1418
|
+
effect.endpointGeneration <= 0
|
|
1419
|
+
)
|
|
1420
|
+
return;
|
|
1421
|
+
// Receipt recovery must retain the original mapping through an endpoint roll;
|
|
1422
|
+
// inbound routing still fences it against the now-current endpoint generation.
|
|
1423
|
+
await this.store.transact(this.#intentKey(effect.sessionId), current =>
|
|
1424
|
+
current &&
|
|
1425
|
+
current.state === "posting_root" &&
|
|
1426
|
+
current.sessionId === effect.sessionId &&
|
|
1427
|
+
current.teamId === this.options.teamId &&
|
|
1428
|
+
current.channelId === payload.channel &&
|
|
1429
|
+
current.clientMsgId === payload.clientMsgId &&
|
|
1430
|
+
current.endpointGeneration === effect.endpointGeneration
|
|
1431
|
+
? nextRecord(current, {
|
|
1432
|
+
state: "active",
|
|
1433
|
+
rootTs,
|
|
1434
|
+
endpointGeneration: effect.endpointGeneration,
|
|
1435
|
+
rootPublicationOwner: undefined,
|
|
1436
|
+
rootPublicationLeaseExpiresAt: undefined,
|
|
1437
|
+
lastError: undefined,
|
|
1438
|
+
updatedAt: this.#now(),
|
|
1439
|
+
})
|
|
1440
|
+
: current,
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async #activateReconciledAction(
|
|
1445
|
+
effect: ChatEffect,
|
|
1446
|
+
payload: { channel?: unknown; threadTs?: unknown; clientMsgId: string },
|
|
1447
|
+
): Promise<void> {
|
|
1448
|
+
if (
|
|
1449
|
+
!effect.sessionId ||
|
|
1450
|
+
typeof payload.channel !== "string" ||
|
|
1451
|
+
typeof payload.threadTs !== "string" ||
|
|
1452
|
+
!Number.isSafeInteger(effect.endpointGeneration) ||
|
|
1453
|
+
effect.endpointGeneration <= 0
|
|
1454
|
+
)
|
|
1455
|
+
return;
|
|
1456
|
+
const threadTs = payload.threadTs;
|
|
1457
|
+
const document = await this.store.load();
|
|
1458
|
+
const found = Object.entries(document.conversations)
|
|
1459
|
+
.map(([key, record]) => ({ key, record }))
|
|
1460
|
+
.filter(
|
|
1461
|
+
({ record }) =>
|
|
1462
|
+
record.state === "active" &&
|
|
1463
|
+
record.sessionId === effect.sessionId &&
|
|
1464
|
+
record.teamId === this.options.teamId &&
|
|
1465
|
+
record.channelId === payload.channel &&
|
|
1466
|
+
record.rootTs === threadTs &&
|
|
1467
|
+
record.endpointGeneration === effect.endpointGeneration &&
|
|
1468
|
+
record.outboundActionClientMsgId === payload.clientMsgId &&
|
|
1469
|
+
typeof record.outboundActionId === "string",
|
|
1470
|
+
)[0];
|
|
1471
|
+
if (!found) return;
|
|
1472
|
+
await this.store.transact(found.key, current =>
|
|
1473
|
+
current &&
|
|
1474
|
+
acceptsSlackInbound(current, threadTs, effect.endpointGeneration) &&
|
|
1475
|
+
current.outboundActionClientMsgId === payload.clientMsgId &&
|
|
1476
|
+
current.outboundActionId === found.record.outboundActionId
|
|
1477
|
+
? nextRecord(current, {
|
|
1478
|
+
pendingActionId: current.outboundActionId,
|
|
1479
|
+
outboundActionId: undefined,
|
|
1480
|
+
outboundActionClientMsgId: undefined,
|
|
1481
|
+
outboundActionOwner: undefined,
|
|
1482
|
+
outboundActionLeaseExpiresAt: undefined,
|
|
1483
|
+
updatedAt: this.#now(),
|
|
1484
|
+
})
|
|
1485
|
+
: current,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
async #releaseUnreconciledRoot(
|
|
1489
|
+
effect: ChatEffect,
|
|
1490
|
+
payload: { channel?: unknown; clientMsgId: string },
|
|
1491
|
+
): Promise<void> {
|
|
1492
|
+
if (
|
|
1493
|
+
!effect.sessionId ||
|
|
1494
|
+
typeof payload.channel !== "string" ||
|
|
1495
|
+
payload.channel !== this.options.channelId ||
|
|
1496
|
+
!Number.isSafeInteger(effect.endpointGeneration) ||
|
|
1497
|
+
effect.endpointGeneration <= 0
|
|
1498
|
+
)
|
|
1499
|
+
return;
|
|
1500
|
+
await this.store.transact(this.#intentKey(effect.sessionId), current =>
|
|
1501
|
+
current &&
|
|
1502
|
+
current.state === "posting_root" &&
|
|
1503
|
+
current.sessionId === effect.sessionId &&
|
|
1504
|
+
current.teamId === this.options.teamId &&
|
|
1505
|
+
current.channelId === payload.channel &&
|
|
1506
|
+
current.clientMsgId === payload.clientMsgId &&
|
|
1507
|
+
current.endpointGeneration === effect.endpointGeneration
|
|
1508
|
+
? nextRecord(current, {
|
|
1509
|
+
state: "error",
|
|
1510
|
+
rootPublicationOwner: undefined,
|
|
1511
|
+
rootPublicationLeaseExpiresAt: undefined,
|
|
1512
|
+
lastError: "provider_not_found",
|
|
1513
|
+
updatedAt: this.#now(),
|
|
1514
|
+
})
|
|
1515
|
+
: current,
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
async #releaseUnreconciledAction(
|
|
1519
|
+
effect: ChatEffect,
|
|
1520
|
+
payload: { channel?: unknown; threadTs?: unknown; clientMsgId: string },
|
|
1521
|
+
): Promise<void> {
|
|
1522
|
+
if (
|
|
1523
|
+
!effect.sessionId ||
|
|
1524
|
+
typeof payload.channel !== "string" ||
|
|
1525
|
+
typeof payload.threadTs !== "string" ||
|
|
1526
|
+
!Number.isSafeInteger(effect.endpointGeneration) ||
|
|
1527
|
+
effect.endpointGeneration <= 0
|
|
1528
|
+
)
|
|
1529
|
+
return;
|
|
1530
|
+
const threadTs = payload.threadTs;
|
|
1531
|
+
const document = await this.store.load();
|
|
1532
|
+
const found = Object.entries(document.conversations).find(
|
|
1533
|
+
([, record]) =>
|
|
1534
|
+
record.state === "active" &&
|
|
1535
|
+
record.sessionId === effect.sessionId &&
|
|
1536
|
+
record.teamId === this.options.teamId &&
|
|
1537
|
+
record.channelId === payload.channel &&
|
|
1538
|
+
record.rootTs === threadTs &&
|
|
1539
|
+
record.endpointGeneration === effect.endpointGeneration &&
|
|
1540
|
+
record.outboundActionClientMsgId === payload.clientMsgId,
|
|
1541
|
+
);
|
|
1542
|
+
if (!found) return;
|
|
1543
|
+
await this.store.transact(found[0], current =>
|
|
1544
|
+
current &&
|
|
1545
|
+
acceptsSlackInbound(current, threadTs, effect.endpointGeneration) &&
|
|
1546
|
+
current.outboundActionClientMsgId === payload.clientMsgId
|
|
1547
|
+
? nextRecord(current, {
|
|
1548
|
+
outboundActionId: undefined,
|
|
1549
|
+
outboundActionClientMsgId: undefined,
|
|
1550
|
+
outboundActionOwner: undefined,
|
|
1551
|
+
outboundActionLeaseExpiresAt: undefined,
|
|
1552
|
+
updatedAt: this.#now(),
|
|
1553
|
+
})
|
|
1554
|
+
: current,
|
|
1555
|
+
);
|
|
1556
|
+
}
|
|
1557
|
+
async #recordRecoveryFailure(effect: ChatEffect): Promise<void> {
|
|
1558
|
+
try {
|
|
1559
|
+
if (!effect.sessionId) return;
|
|
1560
|
+
const found = await this.findSession(effect.sessionId, true);
|
|
1561
|
+
if (!found) return;
|
|
1562
|
+
await this.store.transact(found.key, current => {
|
|
1563
|
+
if (!current || current.sessionId !== effect.sessionId) return current;
|
|
1564
|
+
return nextRecord(current, { lastError: "recovery_failure", updatedAt: this.#now() });
|
|
1565
|
+
});
|
|
1566
|
+
} catch {
|
|
1567
|
+
// Diagnostics must never remove the durable recovery trigger.
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
async #drainProviderEffects(): Promise<boolean> {
|
|
1572
|
+
let failed = false;
|
|
1573
|
+
for (const effect of await this.#journal.list()) {
|
|
1574
|
+
try {
|
|
1575
|
+
if (effect.kind !== "provider-post" || effect.state === "terminal") continue;
|
|
1576
|
+
const current = !!effect.sessionId && (await this.#providerEffectCurrent(effect));
|
|
1577
|
+
if (!current && effect.state !== "uncertain" && effect.state !== "leased") {
|
|
1578
|
+
await this.#journal.terminalize(effect.id, { provider: "slack", status: "stale_noop" });
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if (!effect.sessionId) continue;
|
|
1582
|
+
const payload = effect.payload as {
|
|
1583
|
+
channel?: unknown;
|
|
1584
|
+
text?: unknown;
|
|
1585
|
+
threadTs?: unknown;
|
|
1586
|
+
clientMsgId?: unknown;
|
|
1587
|
+
};
|
|
1588
|
+
if (
|
|
1589
|
+
typeof payload.channel !== "string" ||
|
|
1590
|
+
typeof payload.text !== "string" ||
|
|
1591
|
+
typeof payload.clientMsgId !== "string" ||
|
|
1592
|
+
(payload.threadTs !== undefined && typeof payload.threadTs !== "string")
|
|
1593
|
+
)
|
|
1594
|
+
continue;
|
|
1595
|
+
await this.#postDurable(effect.id, effect.sessionId, effect.endpointGeneration, {
|
|
1596
|
+
channel: payload.channel,
|
|
1597
|
+
text: payload.text,
|
|
1598
|
+
...(typeof payload.threadTs === "string" ? { threadTs: payload.threadTs } : {}),
|
|
1599
|
+
clientMsgId: payload.clientMsgId,
|
|
1600
|
+
});
|
|
1601
|
+
} catch {
|
|
1602
|
+
failed = true;
|
|
1603
|
+
await this.#recordRecoveryFailure(effect);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
await this.#reconcileTerminalProviderReceipts();
|
|
1607
|
+
return failed;
|
|
1608
|
+
}
|
|
1609
|
+
async #rescheduleAfterEffectTransition<T extends ChatEffect | undefined>(transition: Promise<T>): Promise<T> {
|
|
1610
|
+
const effect = await transition;
|
|
1611
|
+
if (effect?.state !== "terminal") await this.#scheduleLeaseRecovery();
|
|
1612
|
+
return effect;
|
|
1613
|
+
}
|
|
1614
|
+
async #scheduleLeaseRecovery(recoveryFailed = false): Promise<void> {
|
|
1615
|
+
const scheduled = this.#leaseRecoveryScheduling.then(async () => {
|
|
1616
|
+
await this.#scheduleLeaseRecoveryNow(recoveryFailed);
|
|
1617
|
+
});
|
|
1618
|
+
this.#leaseRecoveryScheduling = scheduled.catch(() => undefined);
|
|
1619
|
+
return await scheduled;
|
|
1620
|
+
}
|
|
1621
|
+
async #scheduleLeaseRecoveryNow(recoveryFailed: boolean): Promise<void> {
|
|
1622
|
+
if (!this.#started) return;
|
|
1623
|
+
const now = this.#now();
|
|
1624
|
+
const recoveryAt = (await this.#journal.list())
|
|
1625
|
+
.filter(effect => effect.transport === "slack")
|
|
1626
|
+
.reduce<number | undefined>(
|
|
1627
|
+
(earliest, effect) => {
|
|
1628
|
+
const claimAt =
|
|
1629
|
+
effect.state === "leased" && Number.isFinite(effect.leaseExpiresAt)
|
|
1630
|
+
? effect.leaseExpiresAt
|
|
1631
|
+
: effect.state === "pending" ||
|
|
1632
|
+
effect.state === "accepted" ||
|
|
1633
|
+
(effect.state === "uncertain" && !effect.kind.includes(".inbound."))
|
|
1634
|
+
? now
|
|
1635
|
+
: undefined;
|
|
1636
|
+
return claimAt === undefined || (earliest !== undefined && earliest <= claimAt) ? earliest : claimAt;
|
|
1637
|
+
},
|
|
1638
|
+
recoveryFailed ? now : undefined,
|
|
1639
|
+
);
|
|
1640
|
+
if (!this.#started) return;
|
|
1641
|
+
if (recoveryAt === undefined) {
|
|
1642
|
+
this.#clearLeaseRecoveryTimer();
|
|
1643
|
+
this.#leaseRecoveryFailures = 0;
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
if (this.#leaseRecoveryAt !== undefined && this.#leaseRecoveryAt <= recoveryAt) return;
|
|
1647
|
+
this.#clearLeaseRecoveryTimer();
|
|
1648
|
+
this.#leaseRecoveryAt = recoveryAt;
|
|
1649
|
+
const delay =
|
|
1650
|
+
recoveryAt <= now
|
|
1651
|
+
? Math.min(1_000, 25 * 2 ** Math.min(this.#leaseRecoveryFailures, 5))
|
|
1652
|
+
: Math.min(recoveryAt - now, 2_147_483_647);
|
|
1653
|
+
const timerGeneration = this.#leaseRecoveryTimerGeneration;
|
|
1654
|
+
this.#leaseRecoveryTimer = setTimeout(() => {
|
|
1655
|
+
if (!this.#started || timerGeneration !== this.#leaseRecoveryTimerGeneration) return;
|
|
1656
|
+
this.#leaseRecoveryTimer = undefined;
|
|
1657
|
+
this.#leaseRecoveryAt = undefined;
|
|
1658
|
+
void this.#track(this.#recoverLeasedEffects());
|
|
1659
|
+
}, delay);
|
|
1660
|
+
}
|
|
1661
|
+
#clearLeaseRecoveryTimer(): void {
|
|
1662
|
+
this.#leaseRecoveryTimerGeneration++;
|
|
1663
|
+
if (this.#leaseRecoveryTimer) clearTimeout(this.#leaseRecoveryTimer);
|
|
1664
|
+
this.#leaseRecoveryTimer = undefined;
|
|
1665
|
+
this.#leaseRecoveryAt = undefined;
|
|
1666
|
+
}
|
|
1667
|
+
async #recoverLeasedEffects(): Promise<void> {
|
|
1668
|
+
if (!this.#started || this.#recoveringLeasedEffects) return;
|
|
1669
|
+
this.#recoveringLeasedEffects = true;
|
|
1670
|
+
let failed = false;
|
|
1671
|
+
try {
|
|
1672
|
+
try {
|
|
1673
|
+
await this.#reconcileTerminalProviderReceipts();
|
|
1674
|
+
} catch {
|
|
1675
|
+
failed = true;
|
|
1676
|
+
}
|
|
1677
|
+
try {
|
|
1678
|
+
failed ||= await this.#drainProviderEffects();
|
|
1679
|
+
} catch {
|
|
1680
|
+
failed = true;
|
|
1681
|
+
}
|
|
1682
|
+
try {
|
|
1683
|
+
await this.#reconcileTerminalProviderReceipts();
|
|
1684
|
+
} catch {
|
|
1685
|
+
failed = true;
|
|
1686
|
+
}
|
|
1687
|
+
try {
|
|
1688
|
+
await this.#drainPendingDispatches();
|
|
1689
|
+
} catch {
|
|
1690
|
+
failed = true;
|
|
1691
|
+
}
|
|
1692
|
+
} finally {
|
|
1693
|
+
if (failed) this.#leaseRecoveryFailures = Math.min(this.#leaseRecoveryFailures + 1, 5);
|
|
1694
|
+
else this.#leaseRecoveryFailures = 0;
|
|
1695
|
+
try {
|
|
1696
|
+
await this.#scheduleLeaseRecovery(failed);
|
|
1697
|
+
} catch {
|
|
1698
|
+
/* retained effects are retried by the next trigger */
|
|
1699
|
+
} finally {
|
|
1700
|
+
this.#recoveringLeasedEffects = false;
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
async #providerEffectCurrent(effect: ChatEffect): Promise<boolean> {
|
|
1706
|
+
if (!effect.sessionId || !Number.isSafeInteger(effect.endpointGeneration) || effect.endpointGeneration <= 0)
|
|
1707
|
+
return false;
|
|
1708
|
+
const endpoint = await this.#resolveEndpoint(effect.sessionId);
|
|
1709
|
+
if (!endpoint || endpoint.generation !== effect.endpointGeneration) return false;
|
|
1710
|
+
const payload = effect.payload as { threadTs?: unknown };
|
|
1711
|
+
const threadTs = payload.threadTs;
|
|
1712
|
+
const records = Object.values((await this.store.load()).conversations);
|
|
1713
|
+
if (typeof threadTs === "string") {
|
|
1714
|
+
return records.some(
|
|
1715
|
+
record =>
|
|
1716
|
+
record.sessionId === effect.sessionId &&
|
|
1717
|
+
acceptsSlackInbound(record, threadTs, effect.endpointGeneration),
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
return records.some(
|
|
1721
|
+
record =>
|
|
1722
|
+
record.sessionId === effect.sessionId &&
|
|
1723
|
+
record.endpointGeneration === effect.endpointGeneration &&
|
|
1724
|
+
record.state === "posting_root",
|
|
1725
|
+
);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
async #providerEffectPayload(
|
|
1729
|
+
id: string,
|
|
1730
|
+
sessionId: string,
|
|
1731
|
+
endpointGeneration: number,
|
|
1732
|
+
expected: { channel: string; text: string; threadTs?: string; clientMsgId: string },
|
|
1733
|
+
): Promise<{ payload: typeof expected; existed: boolean }> {
|
|
1734
|
+
const existing = await this.#journal.read<typeof expected>(id);
|
|
1735
|
+
if (!existing) return { payload: expected, existed: false };
|
|
1736
|
+
const payload = existing.payload;
|
|
1737
|
+
if (
|
|
1738
|
+
existing.kind !== "provider-post" ||
|
|
1739
|
+
existing.transport !== "slack" ||
|
|
1740
|
+
existing.sessionId !== sessionId ||
|
|
1741
|
+
existing.endpointGeneration !== endpointGeneration ||
|
|
1742
|
+
typeof payload.channel !== "string" ||
|
|
1743
|
+
typeof payload.text !== "string" ||
|
|
1744
|
+
typeof payload.clientMsgId !== "string" ||
|
|
1745
|
+
(payload.threadTs !== undefined && typeof payload.threadTs !== "string") ||
|
|
1746
|
+
payload.channel !== expected.channel ||
|
|
1747
|
+
payload.threadTs !== expected.threadTs ||
|
|
1748
|
+
payload.clientMsgId !== expected.clientMsgId
|
|
1749
|
+
) {
|
|
1750
|
+
throw new SlackEndpointBindingError("Slack provider effect identity does not match the durable occurrence.");
|
|
1751
|
+
}
|
|
1752
|
+
return { payload, existed: true };
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async #postDurable(
|
|
1756
|
+
id: string,
|
|
1757
|
+
sessionId: string,
|
|
1758
|
+
endpointGeneration: number,
|
|
1759
|
+
payload: { channel: string; text: string; threadTs?: string; clientMsgId: string },
|
|
1760
|
+
): Promise<SlackPostedMessage> {
|
|
1761
|
+
if (!Number.isSafeInteger(endpointGeneration) || endpointGeneration <= 0)
|
|
1762
|
+
throw new SlackEndpointBindingError("Slack provider effects require a positive endpoint generation.");
|
|
1763
|
+
const initial = await this.#rescheduleAfterEffectTransition(
|
|
1764
|
+
this.#journal.enqueue({
|
|
1765
|
+
id,
|
|
1766
|
+
kind: "provider-post",
|
|
1767
|
+
transport: "slack",
|
|
1768
|
+
sessionId,
|
|
1769
|
+
endpointGeneration,
|
|
1770
|
+
payload,
|
|
1771
|
+
}),
|
|
1772
|
+
);
|
|
1773
|
+
const fromReceipt = (effect: typeof initial): SlackPostedMessage | undefined => {
|
|
1774
|
+
if (
|
|
1775
|
+
effect.state !== "terminal" ||
|
|
1776
|
+
effect.receipt?.status !== "posted" ||
|
|
1777
|
+
!effect.receipt.channelId ||
|
|
1778
|
+
!effect.receipt.timestamp
|
|
1779
|
+
)
|
|
1780
|
+
return undefined;
|
|
1781
|
+
return {
|
|
1782
|
+
channel: effect.receipt.channelId,
|
|
1783
|
+
ts: effect.receipt.timestamp,
|
|
1784
|
+
client_msg_id: effect.receipt.messageId,
|
|
1785
|
+
};
|
|
1786
|
+
};
|
|
1787
|
+
const completed = fromReceipt(initial);
|
|
1788
|
+
if (completed) return completed;
|
|
1789
|
+
if (initial.state === "terminal") throw new Error("Slack provider effect previously failed");
|
|
1790
|
+
let effect: ChatEffect<typeof payload> | undefined;
|
|
1791
|
+
for (let attempt = 0; attempt < 100 && !effect; attempt++) {
|
|
1792
|
+
effect = await this.#rescheduleAfterEffectTransition(
|
|
1793
|
+
this.#journal.claim<typeof payload>(id, this.#publicationOwnerId, Math.max(this.#publicationLeaseMs, 100)),
|
|
1794
|
+
);
|
|
1795
|
+
if (effect) break;
|
|
1796
|
+
const current = await this.#journal.read<typeof payload>(id);
|
|
1797
|
+
if (current) {
|
|
1798
|
+
const posted = fromReceipt(current);
|
|
1799
|
+
if (posted) return posted;
|
|
1800
|
+
if (current.state === "terminal") throw new Error("Slack provider effect previously failed");
|
|
1801
|
+
}
|
|
1802
|
+
await Bun.sleep(1);
|
|
1803
|
+
}
|
|
1804
|
+
if (!effect) throw new Error("Slack provider effect is owned by another worker");
|
|
1805
|
+
const lease: ChatEffectLease = { owner: this.#publicationOwnerId, epoch: effect.epoch };
|
|
1806
|
+
// A recovered lease may have crossed the provider boundary before its owner
|
|
1807
|
+
// died, and a fresh post may have reached Slack at a now-superseded generation.
|
|
1808
|
+
// Reconcile first in all cases. Only an effect that may have crossed the
|
|
1809
|
+
// provider boundary is terminalized after an absent reconciliation; a stale
|
|
1810
|
+
// pending effect remains nonterminal for recovery.
|
|
1811
|
+
const requiresReconciliation =
|
|
1812
|
+
initial.state === "accepted" || initial.state === "uncertain" || initial.state === "leased";
|
|
1813
|
+
try {
|
|
1814
|
+
const posted = await this.#withEffectLease(id, lease, async () => {
|
|
1815
|
+
const found = await this.options.provider.findMessageByClientMsgId({
|
|
1816
|
+
channel: effect!.payload.channel,
|
|
1817
|
+
threadTs: effect!.payload.threadTs,
|
|
1818
|
+
clientMsgId: effect!.payload.clientMsgId,
|
|
1819
|
+
});
|
|
1820
|
+
if (found) return found;
|
|
1821
|
+
if (!(await this.#providerEffectCurrent(effect!))) {
|
|
1822
|
+
if (requiresReconciliation) throw new SlackReconciledAbsentEffectError();
|
|
1823
|
+
throw new SlackStaleEffectError();
|
|
1824
|
+
}
|
|
1825
|
+
return await this.options.provider.postMessage(effect!.payload);
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
if (
|
|
1829
|
+
!(await this.#journal.record(id, lease, "terminal", {
|
|
1830
|
+
provider: "slack",
|
|
1831
|
+
channelId: posted.channel,
|
|
1832
|
+
timestamp: posted.ts,
|
|
1833
|
+
messageId: posted.client_msg_id ?? posted.ts,
|
|
1834
|
+
status: "posted",
|
|
1835
|
+
}))
|
|
1836
|
+
)
|
|
1837
|
+
throw new Error("Slack provider effect lease expired before commit");
|
|
1838
|
+
|
|
1839
|
+
return posted;
|
|
1840
|
+
} catch (error) {
|
|
1841
|
+
if (error instanceof SlackReconciledAbsentEffectError) {
|
|
1842
|
+
await this.#rescheduleAfterEffectTransition(
|
|
1843
|
+
this.#journal.record(id, lease, "terminal", { provider: "slack", status: "not_found" }),
|
|
1844
|
+
);
|
|
1845
|
+
throw new SlackStaleEffectError();
|
|
1846
|
+
}
|
|
1847
|
+
if (error instanceof SlackStaleEffectError) throw error;
|
|
1848
|
+
if (this.#isUncertainPostFailure(error)) {
|
|
1849
|
+
try {
|
|
1850
|
+
const reconciled = await this.#withEffectLease(
|
|
1851
|
+
id,
|
|
1852
|
+
lease,
|
|
1853
|
+
async () =>
|
|
1854
|
+
await this.options.provider.findMessageByClientMsgId({
|
|
1855
|
+
channel: effect!.payload.channel,
|
|
1856
|
+
threadTs: effect!.payload.threadTs,
|
|
1857
|
+
clientMsgId: effect!.payload.clientMsgId,
|
|
1858
|
+
}),
|
|
1859
|
+
);
|
|
1860
|
+
if (reconciled) {
|
|
1861
|
+
if (
|
|
1862
|
+
!(await this.#journal.record(id, lease, "terminal", {
|
|
1863
|
+
provider: "slack",
|
|
1864
|
+
channelId: reconciled.channel,
|
|
1865
|
+
timestamp: reconciled.ts,
|
|
1866
|
+
messageId: reconciled.client_msg_id ?? reconciled.ts,
|
|
1867
|
+
status: "posted",
|
|
1868
|
+
}))
|
|
1869
|
+
)
|
|
1870
|
+
throw new Error("Slack provider effect lease expired before commit");
|
|
1871
|
+
return reconciled;
|
|
1872
|
+
}
|
|
1873
|
+
} catch {
|
|
1874
|
+
// Preserve the uncertain effect for a later reconciliation attempt.
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
const uncertain = this.#isUncertainPostFailure(error);
|
|
1878
|
+
await this.#rescheduleAfterEffectTransition(
|
|
1879
|
+
this.#journal.record(id, lease, uncertain ? "uncertain" : "terminal", {
|
|
1880
|
+
provider: "slack",
|
|
1881
|
+
status: uncertain ? "uncertain" : "failed",
|
|
1882
|
+
}),
|
|
1883
|
+
);
|
|
1884
|
+
throw error;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
async #withRootLease<T>(key: string, clientMsgId: string, fence: number, operation: () => Promise<T>): Promise<T> {
|
|
1889
|
+
return await this.#withPublicationLease(operation, async () => {
|
|
1890
|
+
let renewed = false;
|
|
1891
|
+
await this.store.transact(key, current => {
|
|
1892
|
+
if (
|
|
1893
|
+
!current ||
|
|
1894
|
+
current.clientMsgId !== clientMsgId ||
|
|
1895
|
+
current.rootPublicationOwner !== this.#publicationOwnerId ||
|
|
1896
|
+
current.rootPublicationFence !== fence
|
|
1897
|
+
)
|
|
1898
|
+
return current;
|
|
1899
|
+
renewed = true;
|
|
1900
|
+
return nextRecord(current, {
|
|
1901
|
+
rootPublicationLeaseExpiresAt: this.#now() + this.#publicationLeaseMs,
|
|
1902
|
+
updatedAt: this.#now(),
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
if (!renewed) throw new Error("Slack root publication lease renewal failed");
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
async #withActionLease<T>(key: string, clientMsgId: string, fence: number, operation: () => Promise<T>): Promise<T> {
|
|
1910
|
+
return await this.#withPublicationLease(operation, async () => {
|
|
1911
|
+
let renewed = false;
|
|
1912
|
+
await this.store.transact(key, current => {
|
|
1913
|
+
if (
|
|
1914
|
+
!current ||
|
|
1915
|
+
current.outboundActionClientMsgId !== clientMsgId ||
|
|
1916
|
+
current.outboundActionOwner !== this.#publicationOwnerId ||
|
|
1917
|
+
current.outboundActionFence !== fence
|
|
1918
|
+
)
|
|
1919
|
+
return current;
|
|
1920
|
+
renewed = true;
|
|
1921
|
+
return nextRecord(current, {
|
|
1922
|
+
outboundActionLeaseExpiresAt: this.#now() + this.#publicationLeaseMs,
|
|
1923
|
+
updatedAt: this.#now(),
|
|
1924
|
+
});
|
|
1925
|
+
});
|
|
1926
|
+
if (!renewed) throw new Error("Slack action publication lease renewal failed");
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
async #withPublicationLease<T>(operation: () => Promise<T>, renew: () => Promise<void>): Promise<T> {
|
|
1931
|
+
return await this.#withRenewal(operation, renew);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
async #withRenewal<T>(
|
|
1935
|
+
operation: () => Promise<T>,
|
|
1936
|
+
renew: () => Promise<void>,
|
|
1937
|
+
renewBeforeOperation = true,
|
|
1938
|
+
): Promise<T> {
|
|
1939
|
+
if (renewBeforeOperation) await renew();
|
|
1940
|
+
let failure: unknown;
|
|
1941
|
+
let renewing = Promise.resolve();
|
|
1942
|
+
let renewalPending = false;
|
|
1943
|
+
const renewLease = async () => {
|
|
1944
|
+
try {
|
|
1945
|
+
await renew();
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
failure ??= error;
|
|
1948
|
+
}
|
|
1949
|
+
};
|
|
1950
|
+
const timer = setInterval(
|
|
1951
|
+
() => {
|
|
1952
|
+
if (renewalPending || failure) return;
|
|
1953
|
+
renewalPending = true;
|
|
1954
|
+
renewing = renewing.then(renewLease).finally(() => {
|
|
1955
|
+
renewalPending = false;
|
|
1956
|
+
});
|
|
1957
|
+
},
|
|
1958
|
+
Math.max(1, Math.floor(this.#publicationLeaseMs / 4)),
|
|
1959
|
+
);
|
|
1960
|
+
try {
|
|
1961
|
+
const result = await operation();
|
|
1962
|
+
await renewing;
|
|
1963
|
+
if (failure) throw failure;
|
|
1964
|
+
return result;
|
|
1965
|
+
} finally {
|
|
1966
|
+
clearInterval(timer);
|
|
1967
|
+
await renewing;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
async #waitForRootReconciliation(key: string, initial: SlackConversation): Promise<SlackConversation | undefined> {
|
|
1972
|
+
const clientMsgId = initial.clientMsgId;
|
|
1973
|
+
const endpointGeneration = initial.endpointGeneration;
|
|
1974
|
+
if (
|
|
1975
|
+
typeof clientMsgId !== "string" ||
|
|
1976
|
+
typeof endpointGeneration !== "number" ||
|
|
1977
|
+
!Number.isSafeInteger(endpointGeneration) ||
|
|
1978
|
+
endpointGeneration <= 0
|
|
1979
|
+
)
|
|
1980
|
+
throw new SlackEndpointBindingError("Slack root publication has no stable reconciliation identity.");
|
|
1981
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
1982
|
+
const current = await this.store.read(key);
|
|
1983
|
+
if (
|
|
1984
|
+
current?.state !== "posting_root" ||
|
|
1985
|
+
current.clientMsgId !== clientMsgId ||
|
|
1986
|
+
current.endpointGeneration !== endpointGeneration
|
|
1987
|
+
)
|
|
1988
|
+
return current;
|
|
1989
|
+
const effect:
|
|
1990
|
+
| ChatEffect<{
|
|
1991
|
+
channel?: unknown;
|
|
1992
|
+
text?: unknown;
|
|
1993
|
+
threadTs?: unknown;
|
|
1994
|
+
clientMsgId?: unknown;
|
|
1995
|
+
}>
|
|
1996
|
+
| undefined = await this.#journal.read(`root:${current.sessionId}:${clientMsgId}`);
|
|
1997
|
+
if (effect?.state === "terminal") {
|
|
1998
|
+
await this.#reconcileTerminalProviderReceipts();
|
|
1999
|
+
await Bun.sleep(10);
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
const payload: { channel?: unknown; text?: unknown; threadTs?: unknown; clientMsgId?: unknown } | undefined =
|
|
2003
|
+
effect?.payload;
|
|
2004
|
+
if (
|
|
2005
|
+
effect?.kind === "provider-post" &&
|
|
2006
|
+
effect.transport === "slack" &&
|
|
2007
|
+
effect.sessionId === current.sessionId &&
|
|
2008
|
+
typeof current.sessionId === "string" &&
|
|
2009
|
+
effect.endpointGeneration === endpointGeneration &&
|
|
2010
|
+
(effect.state === "pending" ||
|
|
2011
|
+
effect.state === "accepted" ||
|
|
2012
|
+
effect.state === "uncertain" ||
|
|
2013
|
+
(effect.state === "leased" && this.#leaseExpired(effect.leaseExpiresAt, this.#now()))) &&
|
|
2014
|
+
payload &&
|
|
2015
|
+
typeof payload.channel === "string" &&
|
|
2016
|
+
payload.channel === current.channelId &&
|
|
2017
|
+
typeof payload.text === "string" &&
|
|
2018
|
+
payload.threadTs === undefined &&
|
|
2019
|
+
payload.clientMsgId === clientMsgId
|
|
2020
|
+
) {
|
|
2021
|
+
try {
|
|
2022
|
+
await this.#postDurable(effect.id, current.sessionId, endpointGeneration, {
|
|
2023
|
+
channel: payload.channel,
|
|
2024
|
+
text: payload.text,
|
|
2025
|
+
clientMsgId,
|
|
2026
|
+
});
|
|
2027
|
+
} catch (error) {
|
|
2028
|
+
if (!(error instanceof SlackStaleEffectError)) throw error;
|
|
2029
|
+
}
|
|
2030
|
+
await this.#reconcileTerminalProviderReceipts();
|
|
2031
|
+
continue;
|
|
2032
|
+
}
|
|
2033
|
+
await Bun.sleep(10);
|
|
2034
|
+
}
|
|
2035
|
+
throw new Error("Slack root post reconciliation is still pending");
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
async #waitForRoot(
|
|
2039
|
+
key: string,
|
|
2040
|
+
sessionId: string,
|
|
2041
|
+
body: string,
|
|
2042
|
+
endpointGeneration: number,
|
|
2043
|
+
): Promise<SlackConversation> {
|
|
2044
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
2045
|
+
await Bun.sleep(10);
|
|
2046
|
+
const current = await this.store.read(key);
|
|
2047
|
+
if (current?.state === "active") {
|
|
2048
|
+
const currentGeneration = this.#requireEndpointGeneration(current);
|
|
2049
|
+
if (currentGeneration === endpointGeneration) return current;
|
|
2050
|
+
if (currentGeneration > endpointGeneration)
|
|
2051
|
+
throw new SlackEndpointBindingError("Slack root belongs to a newer endpoint generation.");
|
|
2052
|
+
return await this.resume(sessionId, body, endpointGeneration);
|
|
2053
|
+
}
|
|
2054
|
+
if (
|
|
2055
|
+
current?.state === "error" ||
|
|
2056
|
+
(current?.state === "posting_root" &&
|
|
2057
|
+
this.#leaseExpired(current.rootPublicationLeaseExpiresAt, this.#now())) ||
|
|
2058
|
+
(current !== undefined &&
|
|
2059
|
+
current.rootPublicationOwner !== undefined &&
|
|
2060
|
+
this.#leaseExpired(current.rootPublicationLeaseExpiresAt, this.#now()))
|
|
2061
|
+
) {
|
|
2062
|
+
return await this.postRoot(sessionId, body, endpointGeneration);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
throw new Error("Slack root post is still pending");
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
async #waitForAction(key: string, sessionId: string, body: string, actionId: string): Promise<SlackConversation> {
|
|
2069
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
2070
|
+
await Bun.sleep(10);
|
|
2071
|
+
const current = await this.store.read(key);
|
|
2072
|
+
if (current?.pendingActionId === actionId && !current.outboundActionId) return current;
|
|
2073
|
+
if (
|
|
2074
|
+
current?.outboundActionId === actionId &&
|
|
2075
|
+
this.#leaseExpired(current.outboundActionLeaseExpiresAt, this.#now())
|
|
2076
|
+
) {
|
|
2077
|
+
return await this.notify(sessionId, body, actionId);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
throw new Error("Slack action publication is still pending");
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
async findSession(
|
|
2084
|
+
sessionId: string,
|
|
2085
|
+
includeInactive: boolean,
|
|
2086
|
+
): Promise<
|
|
2087
|
+
| {
|
|
2088
|
+
key: string;
|
|
2089
|
+
record: SlackConversation;
|
|
2090
|
+
}
|
|
2091
|
+
| undefined
|
|
2092
|
+
> {
|
|
2093
|
+
const document = await this.store.load();
|
|
2094
|
+
return Object.entries(document.conversations)
|
|
2095
|
+
.map(([key, record]) => ({ key, record }))
|
|
2096
|
+
.filter(
|
|
2097
|
+
entry =>
|
|
2098
|
+
entry.record.sessionId === sessionId &&
|
|
2099
|
+
(includeInactive ||
|
|
2100
|
+
entry.record.state === "active" ||
|
|
2101
|
+
entry.record.state === "posting_root" ||
|
|
2102
|
+
entry.record.state === "error"),
|
|
2103
|
+
)
|
|
2104
|
+
.sort(
|
|
2105
|
+
(left, right) =>
|
|
2106
|
+
(right.record.endpointGeneration ?? -1) - (left.record.endpointGeneration ?? -1) ||
|
|
2107
|
+
right.record.generation - left.record.generation ||
|
|
2108
|
+
right.record.updatedAt - left.record.updatedAt,
|
|
2109
|
+
)[0];
|
|
2110
|
+
}
|
|
2111
|
+
#intentKey(sessionId: string): string {
|
|
2112
|
+
return slackConversationKey({
|
|
2113
|
+
teamId: this.options.teamId,
|
|
2114
|
+
channelId: this.options.channelId,
|
|
2115
|
+
rootTs: `intent:${sessionId}`,
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
#requireEndpointGeneration(record: SlackConversation): number {
|
|
2119
|
+
const generation = record.endpointGeneration;
|
|
2120
|
+
if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation <= 0)
|
|
2121
|
+
throw new SlackEndpointBindingError("Slack conversation has no current endpoint generation.");
|
|
2122
|
+
return generation;
|
|
2123
|
+
}
|
|
2124
|
+
#leaseExpired(expiresAt: number | undefined, now: number): boolean {
|
|
2125
|
+
return expiresAt === undefined || expiresAt <= now;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
#isUncertainPostFailure(error: unknown): boolean {
|
|
2129
|
+
return (
|
|
2130
|
+
error instanceof SlackProviderError &&
|
|
2131
|
+
error.operation === "chat.postMessage" &&
|
|
2132
|
+
(error.code === "connection" || error.mayHaveBeenAccepted)
|
|
2133
|
+
);
|
|
2134
|
+
}
|
|
2135
|
+
#isDefiniteSdkPreSendFailure(error: unknown): boolean {
|
|
2136
|
+
if (error instanceof SlackEndpointBindingError) return true;
|
|
2137
|
+
if (error instanceof SdkClientError) return error.code === "connection_closed";
|
|
2138
|
+
return (
|
|
2139
|
+
error instanceof Error &&
|
|
2140
|
+
error.name === "ChatDeliveryError" &&
|
|
2141
|
+
(error as ChatDeliveryError).phase === "pre_send"
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
}
|