@richhardry/codor 0.10.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/LICENSE +21 -0
- package/README.md +293 -0
- package/bin/codor.mjs +7 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts +41 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.js +275 -0
- package/node_modules/@codor/adapter-antigravity/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.d.ts +2 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.js +2 -0
- package/node_modules/@codor/adapter-antigravity/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-antigravity/package.json +18 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts +94 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.js +785 -0
- package/node_modules/@codor/adapter-claude-code/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.d.ts +8 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.js +5 -0
- package/node_modules/@codor/adapter-claude-code/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts +18 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js +67 -0
- package/node_modules/@codor/adapter-claude-code/dist/limits-probe.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts +3 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.js +128 -0
- package/node_modules/@codor/adapter-claude-code/dist/peek.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.d.ts +9 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.js +7 -0
- package/node_modules/@codor/adapter-claude-code/dist/query.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts +40 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.js +505 -0
- package/node_modules/@codor/adapter-claude-code/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-claude-code/package.json +18 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.d.ts +105 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.js +625 -0
- package/node_modules/@codor/adapter-codex/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts +39 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.js +186 -0
- package/node_modules/@codor/adapter-codex/dist/app-server-transport.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/index.d.ts +8 -0
- package/node_modules/@codor/adapter-codex/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/index.js +5 -0
- package/node_modules/@codor/adapter-codex/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts +18 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.js +87 -0
- package/node_modules/@codor/adapter-codex/dist/limits-probe.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/peek.d.ts +3 -0
- package/node_modules/@codor/adapter-codex/dist/peek.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/peek.js +98 -0
- package/node_modules/@codor/adapter-codex/dist/peek.js.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/translate.d.ts +17 -0
- package/node_modules/@codor/adapter-codex/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-codex/dist/translate.js +286 -0
- package/node_modules/@codor/adapter-codex/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-codex/package.json +17 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts +40 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.js +190 -0
- package/node_modules/@codor/adapter-copilot/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-copilot/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/index.js +3 -0
- package/node_modules/@codor/adapter-copilot/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.d.ts +11 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.js +184 -0
- package/node_modules/@codor/adapter-copilot/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-copilot/package.json +18 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts +32 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.js +179 -0
- package/node_modules/@codor/adapter-cursor/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/index.d.ts +9 -0
- package/node_modules/@codor/adapter-cursor/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/index.js +8 -0
- package/node_modules/@codor/adapter-cursor/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.d.ts +15 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.js +164 -0
- package/node_modules/@codor/adapter-cursor/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-cursor/package.json +17 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts +36 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.js +245 -0
- package/node_modules/@codor/adapter-gemini/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-gemini/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/index.js +3 -0
- package/node_modules/@codor/adapter-gemini/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.d.ts +12 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.js +123 -0
- package/node_modules/@codor/adapter-gemini/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-gemini/package.json +18 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts +42 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.js +240 -0
- package/node_modules/@codor/adapter-opencode/dist/adapter.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/index.d.ts +4 -0
- package/node_modules/@codor/adapter-opencode/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/index.js +3 -0
- package/node_modules/@codor/adapter-opencode/dist/index.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.d.ts +12 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.d.ts.map +1 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.js +144 -0
- package/node_modules/@codor/adapter-opencode/dist/translate.js.map +1 -0
- package/node_modules/@codor/adapter-opencode/package.json +18 -0
- package/node_modules/@codor/cli/dist/artifact.d.ts +33 -0
- package/node_modules/@codor/cli/dist/artifact.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/artifact.js +183 -0
- package/node_modules/@codor/cli/dist/artifact.js.map +1 -0
- package/node_modules/@codor/cli/dist/attach.d.ts +25 -0
- package/node_modules/@codor/cli/dist/attach.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/attach.js +133 -0
- package/node_modules/@codor/cli/dist/attach.js.map +1 -0
- package/node_modules/@codor/cli/dist/connection.d.ts +21 -0
- package/node_modules/@codor/cli/dist/connection.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/connection.js +130 -0
- package/node_modules/@codor/cli/dist/connection.js.map +1 -0
- package/node_modules/@codor/cli/dist/detect.d.ts +14 -0
- package/node_modules/@codor/cli/dist/detect.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/detect.js +99 -0
- package/node_modules/@codor/cli/dist/detect.js.map +1 -0
- package/node_modules/@codor/cli/dist/index.d.ts +17 -0
- package/node_modules/@codor/cli/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/index.js +24 -0
- package/node_modules/@codor/cli/dist/index.js.map +1 -0
- package/node_modules/@codor/cli/dist/mirror.d.ts +4 -0
- package/node_modules/@codor/cli/dist/mirror.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/mirror.js +93 -0
- package/node_modules/@codor/cli/dist/mirror.js.map +1 -0
- package/node_modules/@codor/cli/dist/program.d.ts +17 -0
- package/node_modules/@codor/cli/dist/program.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/program.js +1068 -0
- package/node_modules/@codor/cli/dist/program.js.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-install.d.ts +61 -0
- package/node_modules/@codor/cli/dist/runtime-install.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-install.js +122 -0
- package/node_modules/@codor/cli/dist/runtime-install.js.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-paths.d.ts +24 -0
- package/node_modules/@codor/cli/dist/runtime-paths.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/runtime-paths.js +69 -0
- package/node_modules/@codor/cli/dist/runtime-paths.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-flow.d.ts +69 -0
- package/node_modules/@codor/cli/dist/setup-flow.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-flow.js +110 -0
- package/node_modules/@codor/cli/dist/setup-flow.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-session.d.ts +105 -0
- package/node_modules/@codor/cli/dist/setup-session.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-session.js +346 -0
- package/node_modules/@codor/cli/dist/setup-session.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup-ui.d.ts +93 -0
- package/node_modules/@codor/cli/dist/setup-ui.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup-ui.js +249 -0
- package/node_modules/@codor/cli/dist/setup-ui.js.map +1 -0
- package/node_modules/@codor/cli/dist/setup.d.ts +108 -0
- package/node_modules/@codor/cli/dist/setup.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/setup.js +878 -0
- package/node_modules/@codor/cli/dist/setup.js.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-keys.d.ts +26 -0
- package/node_modules/@codor/cli/dist/terminal-keys.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-keys.js +70 -0
- package/node_modules/@codor/cli/dist/terminal-keys.js.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-qr.d.ts +2 -0
- package/node_modules/@codor/cli/dist/terminal-qr.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/terminal-qr.js +30 -0
- package/node_modules/@codor/cli/dist/terminal-qr.js.map +1 -0
- package/node_modules/@codor/cli/dist/up.d.ts +52 -0
- package/node_modules/@codor/cli/dist/up.d.ts.map +1 -0
- package/node_modules/@codor/cli/dist/up.js +178 -0
- package/node_modules/@codor/cli/dist/up.js.map +1 -0
- package/node_modules/@codor/cli/package.json +24 -0
- package/node_modules/@codor/cli/packaging/systemd/codor.service +21 -0
- package/node_modules/@codor/cli/runtime/web/assets/LandingPage-BOmG5kSw.js +31 -0
- package/node_modules/@codor/cli/runtime/web/assets/LedgerPage-CecuTS8-.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/NoChannels-CYiBXGQd.js +11 -0
- package/node_modules/@codor/cli/runtime/web/assets/PairingCodeInput-Cr6XDWx-.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/PairingPage-CQBWNA2L.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/SettingsPage-DGLbSXE7.js +16 -0
- package/node_modules/@codor/cli/runtime/web/assets/StartupUnavailable-DdQBHip_.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/arrow-left-BOpjMuRi.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/browser-BWQYNOsD.js +6 -0
- package/node_modules/@codor/cli/runtime/web/assets/graph-toBZHlC0.js +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/index-Cxi54Xim.js +408 -0
- package/node_modules/@codor/cli/runtime/web/assets/index-DjwJakJC.css +1 -0
- package/node_modules/@codor/cli/runtime/web/assets/qr-DpynCwP9.js +8 -0
- package/node_modules/@codor/cli/runtime/web/assets/react-C8w-UNLI.js +32 -0
- package/node_modules/@codor/cli/runtime/web/assets/sodium-DtsvMzX9.js +1 -0
- package/node_modules/@codor/cli/runtime/web/codor-192.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-512.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-apple-touch-180.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-favicon.svg +86 -0
- package/node_modules/@codor/cli/runtime/web/codor-mark.svg +86 -0
- package/node_modules/@codor/cli/runtime/web/codor-maskable-512.png +0 -0
- package/node_modules/@codor/cli/runtime/web/codor-og.png +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/BitcountPropSingle-latin.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/Geist-Variable.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/GeistMono-Variable.woff2 +0 -0
- package/node_modules/@codor/cli/runtime/web/fonts/OFL.txt +93 -0
- package/node_modules/@codor/cli/runtime/web/index.html +53 -0
- package/node_modules/@codor/cli/runtime/web/manifest.webmanifest +1 -0
- package/node_modules/@codor/cli/runtime/web/sw.js +2 -0
- package/node_modules/@codor/protocol/dist/adapter.d.ts +118 -0
- package/node_modules/@codor/protocol/dist/adapter.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/adapter.js +15 -0
- package/node_modules/@codor/protocol/dist/adapter.js.map +1 -0
- package/node_modules/@codor/protocol/dist/body.d.ts +14 -0
- package/node_modules/@codor/protocol/dist/body.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/body.js +50 -0
- package/node_modules/@codor/protocol/dist/body.js.map +1 -0
- package/node_modules/@codor/protocol/dist/changelog.d.ts +30 -0
- package/node_modules/@codor/protocol/dist/changelog.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/changelog.js +19 -0
- package/node_modules/@codor/protocol/dist/changelog.js.map +1 -0
- package/node_modules/@codor/protocol/dist/default-recipient.d.ts +8 -0
- package/node_modules/@codor/protocol/dist/default-recipient.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/default-recipient.js +19 -0
- package/node_modules/@codor/protocol/dist/default-recipient.js.map +1 -0
- package/node_modules/@codor/protocol/dist/delivery.d.ts +42 -0
- package/node_modules/@codor/protocol/dist/delivery.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/delivery.js +44 -0
- package/node_modules/@codor/protocol/dist/delivery.js.map +1 -0
- package/node_modules/@codor/protocol/dist/events.d.ts +284 -0
- package/node_modules/@codor/protocol/dist/events.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/events.js +150 -0
- package/node_modules/@codor/protocol/dist/events.js.map +1 -0
- package/node_modules/@codor/protocol/dist/ids.d.ts +19 -0
- package/node_modules/@codor/protocol/dist/ids.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/ids.js +27 -0
- package/node_modules/@codor/protocol/dist/ids.js.map +1 -0
- package/node_modules/@codor/protocol/dist/index.d.ts +14 -0
- package/node_modules/@codor/protocol/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/index.js +14 -0
- package/node_modules/@codor/protocol/dist/index.js.map +1 -0
- package/node_modules/@codor/protocol/dist/interaction.d.ts +45 -0
- package/node_modules/@codor/protocol/dist/interaction.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/interaction.js +33 -0
- package/node_modules/@codor/protocol/dist/interaction.js.map +1 -0
- package/node_modules/@codor/protocol/dist/member.d.ts +146 -0
- package/node_modules/@codor/protocol/dist/member.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/member.js +169 -0
- package/node_modules/@codor/protocol/dist/member.js.map +1 -0
- package/node_modules/@codor/protocol/dist/message.d.ts +166 -0
- package/node_modules/@codor/protocol/dist/message.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/message.js +95 -0
- package/node_modules/@codor/protocol/dist/message.js.map +1 -0
- package/node_modules/@codor/protocol/dist/observability.d.ts +80 -0
- package/node_modules/@codor/protocol/dist/observability.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/observability.js +41 -0
- package/node_modules/@codor/protocol/dist/observability.js.map +1 -0
- package/node_modules/@codor/protocol/dist/room.d.ts +430 -0
- package/node_modules/@codor/protocol/dist/room.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/room.js +142 -0
- package/node_modules/@codor/protocol/dist/room.js.map +1 -0
- package/node_modules/@codor/protocol/dist/ws.d.ts +1348 -0
- package/node_modules/@codor/protocol/dist/ws.d.ts.map +1 -0
- package/node_modules/@codor/protocol/dist/ws.js +282 -0
- package/node_modules/@codor/protocol/dist/ws.js.map +1 -0
- package/node_modules/@codor/protocol/package.json +17 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts +14 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.js +175 -0
- package/node_modules/@codor/switchboard/dist/adapter-registry.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/authorization.d.ts +13 -0
- package/node_modules/@codor/switchboard/dist/authorization.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/authorization.js +96 -0
- package/node_modules/@codor/switchboard/dist/authorization.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/blobs.d.ts +17 -0
- package/node_modules/@codor/switchboard/dist/blobs.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/blobs.js +54 -0
- package/node_modules/@codor/switchboard/dist/blobs.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/collaboration.d.ts +115 -0
- package/node_modules/@codor/switchboard/dist/collaboration.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/collaboration.js +123 -0
- package/node_modules/@codor/switchboard/dist/collaboration.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/continuation.d.ts +33 -0
- package/node_modules/@codor/switchboard/dist/continuation.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/continuation.js +177 -0
- package/node_modules/@codor/switchboard/dist/continuation.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts +22 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js +92 -0
- package/node_modules/@codor/switchboard/dist/crypto/browser-sessions.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts +37 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.js +132 -0
- package/node_modules/@codor/switchboard/dist/crypto/challenge.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts +41 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.js +145 -0
- package/node_modules/@codor/switchboard/dist/crypto/keys.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts +53 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.js +182 -0
- package/node_modules/@codor/switchboard/dist/crypto/pairing.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts +30 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js +101 -0
- package/node_modules/@codor/switchboard/dist/crypto/roomkeys.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/daemon.d.ts +545 -0
- package/node_modules/@codor/switchboard/dist/daemon.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/daemon.js +3907 -0
- package/node_modules/@codor/switchboard/dist/daemon.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts +103 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.js +228 -0
- package/node_modules/@codor/switchboard/dist/fake-adapter.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/index.d.ts +44 -0
- package/node_modules/@codor/switchboard/dist/index.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/index.js +24 -0
- package/node_modules/@codor/switchboard/dist/index.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts +12 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.js +17 -0
- package/node_modules/@codor/switchboard/dist/ledger/resolve.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts +47 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.js +175 -0
- package/node_modules/@codor/switchboard/dist/ledger/vault.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts +73 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.js +232 -0
- package/node_modules/@codor/switchboard/dist/ledger/watch.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.d.ts +14 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.js +40 -0
- package/node_modules/@codor/switchboard/dist/local-dirs.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-socket.d.ts +3 -0
- package/node_modules/@codor/switchboard/dist/local-socket.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/local-socket.js +16 -0
- package/node_modules/@codor/switchboard/dist/local-socket.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/pricing.d.ts +32 -0
- package/node_modules/@codor/switchboard/dist/pricing.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/pricing.js +64 -0
- package/node_modules/@codor/switchboard/dist/pricing.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.d.ts +2 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.js +9 -0
- package/node_modules/@codor/switchboard/dist/process-liveness.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/producer.d.ts +54 -0
- package/node_modules/@codor/switchboard/dist/push/producer.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/producer.js +168 -0
- package/node_modules/@codor/switchboard/dist/push/producer.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts +29 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.js +83 -0
- package/node_modules/@codor/switchboard/dist/push/subscriptions.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/redact.d.ts +11 -0
- package/node_modules/@codor/switchboard/dist/redact.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/redact.js +65 -0
- package/node_modules/@codor/switchboard/dist/redact.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/residency.d.ts +110 -0
- package/node_modules/@codor/switchboard/dist/residency.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/residency.js +587 -0
- package/node_modules/@codor/switchboard/dist/residency.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/router.d.ts +120 -0
- package/node_modules/@codor/switchboard/dist/router.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/router.js +198 -0
- package/node_modules/@codor/switchboard/dist/router.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/server.d.ts +50 -0
- package/node_modules/@codor/switchboard/dist/server.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/server.js +1425 -0
- package/node_modules/@codor/switchboard/dist/server.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/store.d.ts +534 -0
- package/node_modules/@codor/switchboard/dist/store.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/store.js +2625 -0
- package/node_modules/@codor/switchboard/dist/store.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts +59 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js +183 -0
- package/node_modules/@codor/switchboard/dist/transport/hyperswarm.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.d.ts +87 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.js +316 -0
- package/node_modules/@codor/switchboard/dist/transport/peer.js.map +1 -0
- package/node_modules/@codor/switchboard/dist/working-directory.d.ts +2 -0
- package/node_modules/@codor/switchboard/dist/working-directory.d.ts.map +1 -0
- package/node_modules/@codor/switchboard/dist/working-directory.js +26 -0
- package/node_modules/@codor/switchboard/dist/working-directory.js.map +1 -0
- package/node_modules/@codor/switchboard/package.json +33 -0
- package/package.json +58 -0
|
@@ -0,0 +1,2625 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import Database from 'better-sqlite3';
|
|
3
|
+
import { AttachLeaseSchema, BridgeOriginSchema, ChangeLogEntrySchema, DeliverySchema, MemberSchema, MessageSchema, PendingInteractionSchema, RoomConfigSchema, RoomMeterSchema, RoomSchema, RoomSupportSchema, deriveRoomColor, } from '@codor/protocol';
|
|
4
|
+
import { redactText } from './redact.js';
|
|
5
|
+
import { CollaborationGroupSchema, CollaborationParticipantSchema, CollaborationRoundSchema, } from './collaboration.js';
|
|
6
|
+
// harn:assume run-journals-own-evidence-across-output-messages ref=store-schema-no-blobs
|
|
7
|
+
// The DB persists pointers (RunSummary.events_ref) — never run event payloads.
|
|
8
|
+
// One lifecycle root points at the JSONL journal for all of its lightweight
|
|
9
|
+
// continuation rows; the store has no table or column for event payloads.
|
|
10
|
+
// harn:assume attach-custody-lease-tracks-child-pid ref=attach-lease-store
|
|
11
|
+
// harn:assume collaboration-groups-are-durable-state ref=collaboration-store-schema
|
|
12
|
+
// harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
|
|
13
|
+
const SCHEMA = `
|
|
14
|
+
CREATE TABLE IF NOT EXISTS rooms (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
created_ts TEXT NOT NULL,
|
|
18
|
+
config TEXT NOT NULL, -- RoomConfig JSON
|
|
19
|
+
seq INTEGER NOT NULL DEFAULT 0
|
|
20
|
+
);
|
|
21
|
+
CREATE TABLE IF NOT EXISTS members (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
24
|
+
kind TEXT NOT NULL,
|
|
25
|
+
handle TEXT NOT NULL,
|
|
26
|
+
display_name TEXT NOT NULL,
|
|
27
|
+
purpose TEXT,
|
|
28
|
+
harness TEXT,
|
|
29
|
+
session_ref TEXT,
|
|
30
|
+
cwd TEXT,
|
|
31
|
+
policy TEXT,
|
|
32
|
+
model TEXT,
|
|
33
|
+
thinking TEXT,
|
|
34
|
+
credential_hash TEXT,
|
|
35
|
+
host TEXT,
|
|
36
|
+
state TEXT,
|
|
37
|
+
custody TEXT,
|
|
38
|
+
parent TEXT,
|
|
39
|
+
role TEXT,
|
|
40
|
+
conventions_sent INTEGER NOT NULL DEFAULT 0,
|
|
41
|
+
misaddressed INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
roster_stale INTEGER NOT NULL DEFAULT 1,
|
|
43
|
+
removed_ts TEXT
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
46
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
47
|
+
id INTEGER NOT NULL,
|
|
48
|
+
author TEXT NOT NULL,
|
|
49
|
+
kind TEXT NOT NULL,
|
|
50
|
+
body TEXT NOT NULL,
|
|
51
|
+
mentions TEXT NOT NULL, -- MentionSpan[] JSON (member ids, never handles)
|
|
52
|
+
refs TEXT NOT NULL, -- number[] JSON
|
|
53
|
+
ledger_refs TEXT NOT NULL, -- string[] JSON
|
|
54
|
+
reply_to INTEGER,
|
|
55
|
+
run TEXT, -- RunSummary JSON: events_ref pointer only, no events
|
|
56
|
+
-- harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-message-storage
|
|
57
|
+
run_parent_id INTEGER, -- lifecycle root for a permanent continuation row
|
|
58
|
+
-- harn:end continuation-writer-follows-journaled-output-ownership
|
|
59
|
+
ask TEXT, -- AskCard JSON
|
|
60
|
+
origin TEXT, -- BridgeOrigin JSON
|
|
61
|
+
attachments TEXT, -- Attachment[] JSON (metadata; files live under the data dir)
|
|
62
|
+
ack INTEGER NOT NULL DEFAULT 0,
|
|
63
|
+
pinned INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
deleted INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
ts TEXT NOT NULL,
|
|
66
|
+
seq INTEGER NOT NULL,
|
|
67
|
+
activity_seq INTEGER,
|
|
68
|
+
PRIMARY KEY (room, id)
|
|
69
|
+
);
|
|
70
|
+
CREATE TABLE IF NOT EXISTS deliveries (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
73
|
+
message_id INTEGER NOT NULL,
|
|
74
|
+
recipient TEXT NOT NULL,
|
|
75
|
+
state TEXT NOT NULL,
|
|
76
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
batch_id TEXT,
|
|
78
|
+
run_msg_id INTEGER,
|
|
79
|
+
read_ts TEXT,
|
|
80
|
+
interaction_resolved_ts TEXT,
|
|
81
|
+
payload_snapshot TEXT, -- immutable routed prompt context; never run events
|
|
82
|
+
process_id INTEGER, -- bounded attempt evidence, never run event payloads
|
|
83
|
+
process_group_id INTEGER,
|
|
84
|
+
queue_seq INTEGER NOT NULL, -- durable FIFO order; timestamps can tie
|
|
85
|
+
group_id TEXT,
|
|
86
|
+
group_round INTEGER,
|
|
87
|
+
ts TEXT NOT NULL
|
|
88
|
+
);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS collaboration_groups (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
room TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
|
|
92
|
+
root_message_id INTEGER NOT NULL,
|
|
93
|
+
state TEXT NOT NULL CHECK (state IN ('open', 'completed', 'cancelled')),
|
|
94
|
+
created_ts TEXT NOT NULL,
|
|
95
|
+
completed_ts TEXT,
|
|
96
|
+
UNIQUE (room, root_message_id),
|
|
97
|
+
FOREIGN KEY (room, root_message_id) REFERENCES messages(room, id) ON DELETE CASCADE
|
|
98
|
+
);
|
|
99
|
+
CREATE TABLE IF NOT EXISTS collaboration_rounds (
|
|
100
|
+
group_id TEXT NOT NULL REFERENCES collaboration_groups(id) ON DELETE CASCADE,
|
|
101
|
+
round_number INTEGER NOT NULL CHECK (round_number > 0),
|
|
102
|
+
state TEXT NOT NULL CHECK (state IN ('collecting', 'released', 'closed')),
|
|
103
|
+
created_ts TEXT NOT NULL,
|
|
104
|
+
released_ts TEXT,
|
|
105
|
+
PRIMARY KEY (group_id, round_number)
|
|
106
|
+
);
|
|
107
|
+
CREATE TABLE IF NOT EXISTS collaboration_participants (
|
|
108
|
+
group_id TEXT NOT NULL,
|
|
109
|
+
round_number INTEGER NOT NULL,
|
|
110
|
+
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
|
111
|
+
member_id TEXT NOT NULL REFERENCES members(id),
|
|
112
|
+
delivery_id TEXT NOT NULL UNIQUE REFERENCES deliveries(id),
|
|
113
|
+
terminal_status TEXT CHECK (
|
|
114
|
+
terminal_status IS NULL OR terminal_status IN ('completed', 'failed', 'interrupted', 'skipped')
|
|
115
|
+
),
|
|
116
|
+
result_message_id INTEGER,
|
|
117
|
+
completed_ts TEXT,
|
|
118
|
+
PRIMARY KEY (group_id, round_number, member_id),
|
|
119
|
+
UNIQUE (group_id, round_number, ordinal),
|
|
120
|
+
FOREIGN KEY (group_id, round_number)
|
|
121
|
+
REFERENCES collaboration_rounds(group_id, round_number) ON DELETE CASCADE
|
|
122
|
+
);
|
|
123
|
+
CREATE TABLE IF NOT EXISTS pending_interactions (
|
|
124
|
+
id TEXT PRIMARY KEY,
|
|
125
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
126
|
+
member_id TEXT NOT NULL,
|
|
127
|
+
message_id INTEGER NOT NULL,
|
|
128
|
+
native_id TEXT NOT NULL,
|
|
129
|
+
kind TEXT NOT NULL,
|
|
130
|
+
targets TEXT NOT NULL, -- MemberId[] JSON
|
|
131
|
+
state TEXT NOT NULL,
|
|
132
|
+
answer TEXT, -- JSON
|
|
133
|
+
answered_by TEXT,
|
|
134
|
+
answered_ts TEXT
|
|
135
|
+
);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS meters (
|
|
137
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
138
|
+
day TEXT NOT NULL,
|
|
139
|
+
turns INTEGER NOT NULL DEFAULT 0,
|
|
140
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
141
|
+
estimated_cost_usd REAL NOT NULL DEFAULT 0,
|
|
142
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
143
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
144
|
+
PRIMARY KEY (room, day)
|
|
145
|
+
);
|
|
146
|
+
CREATE TABLE IF NOT EXISTS mirrored_turns (
|
|
147
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
148
|
+
member_id TEXT NOT NULL,
|
|
149
|
+
native_turn_id TEXT NOT NULL,
|
|
150
|
+
message_id INTEGER NOT NULL,
|
|
151
|
+
PRIMARY KEY (room, member_id, native_turn_id)
|
|
152
|
+
);
|
|
153
|
+
CREATE TABLE IF NOT EXISTS attach_leases (
|
|
154
|
+
id TEXT PRIMARY KEY,
|
|
155
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
156
|
+
member_id TEXT NOT NULL UNIQUE,
|
|
157
|
+
cli_pid INTEGER NOT NULL,
|
|
158
|
+
child_pid INTEGER,
|
|
159
|
+
process_group_id INTEGER,
|
|
160
|
+
heartbeat_ts INTEGER NOT NULL
|
|
161
|
+
);
|
|
162
|
+
CREATE TABLE IF NOT EXISTS changes (
|
|
163
|
+
room_id TEXT NOT NULL REFERENCES rooms(id),
|
|
164
|
+
seq INTEGER NOT NULL,
|
|
165
|
+
entity TEXT NOT NULL, -- message|member|inbox|meter|room
|
|
166
|
+
entity_id TEXT NOT NULL,
|
|
167
|
+
PRIMARY KEY (room_id, seq)
|
|
168
|
+
);
|
|
169
|
+
`;
|
|
170
|
+
// harn:end substantive-output-messages-drive-unread
|
|
171
|
+
// harn:end collaboration-groups-are-durable-state
|
|
172
|
+
// harn:end attach-custody-lease-tracks-child-pid
|
|
173
|
+
// harn:end run-journals-own-evidence-across-output-messages
|
|
174
|
+
// harn:assume delivery-payload-snapshotted ref=delivery-payload-storage
|
|
175
|
+
function migrateDeliveryPayloadSnapshot(db) {
|
|
176
|
+
const columns = db.pragma('table_info(deliveries)');
|
|
177
|
+
if (!columns.some((column) => column.name === 'payload_snapshot')) {
|
|
178
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN payload_snapshot TEXT');
|
|
179
|
+
}
|
|
180
|
+
if (!columns.some((column) => column.name === 'process_id')) {
|
|
181
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN process_id INTEGER');
|
|
182
|
+
}
|
|
183
|
+
if (!columns.some((column) => column.name === 'process_group_id')) {
|
|
184
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN process_group_id INTEGER');
|
|
185
|
+
}
|
|
186
|
+
if (!columns.some((column) => column.name === 'queue_seq')) {
|
|
187
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN queue_seq INTEGER');
|
|
188
|
+
}
|
|
189
|
+
db.exec('UPDATE deliveries SET queue_seq = rowid WHERE queue_seq IS NULL');
|
|
190
|
+
// harn:assume collaboration-groups-are-durable-state ref=collaboration-store-migration
|
|
191
|
+
if (!columns.some((column) => column.name === 'group_id')) {
|
|
192
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN group_id TEXT');
|
|
193
|
+
}
|
|
194
|
+
if (!columns.some((column) => column.name === 'group_round')) {
|
|
195
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN group_round INTEGER');
|
|
196
|
+
}
|
|
197
|
+
db.exec(`
|
|
198
|
+
CREATE INDEX IF NOT EXISTS collaboration_groups_room_state
|
|
199
|
+
ON collaboration_groups (room, state, created_ts);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS collaboration_rounds_state
|
|
201
|
+
ON collaboration_rounds (group_id, state, round_number);
|
|
202
|
+
CREATE INDEX IF NOT EXISTS collaboration_participants_terminal
|
|
203
|
+
ON collaboration_participants (group_id, round_number, terminal_status, ordinal);
|
|
204
|
+
CREATE INDEX IF NOT EXISTS delivery_group_round_lookup
|
|
205
|
+
ON deliveries (room, group_id, group_round, state, queue_seq);
|
|
206
|
+
CREATE UNIQUE INDEX IF NOT EXISTS delivery_group_round_recipient_unique
|
|
207
|
+
ON deliveries (group_id, group_round, recipient)
|
|
208
|
+
WHERE group_id IS NOT NULL;
|
|
209
|
+
`);
|
|
210
|
+
// harn:end collaboration-groups-are-durable-state
|
|
211
|
+
}
|
|
212
|
+
// harn:end delivery-payload-snapshotted
|
|
213
|
+
function migrateMemberCustody(db) {
|
|
214
|
+
const columns = db.pragma('table_info(members)');
|
|
215
|
+
if (!columns.some((column) => column.name === 'custody')) {
|
|
216
|
+
db.exec('ALTER TABLE members ADD COLUMN custody TEXT');
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
// harn:assume agent-model-and-thinking-are-durable ref=durable-agent-config-storage
|
|
220
|
+
// An existing database has members whose model and thinking were only ever held in
|
|
221
|
+
// memory, and are already gone. Null is the honest value for them: it means the
|
|
222
|
+
// harness default, which is exactly what they have been silently getting.
|
|
223
|
+
function migrateMemberAgentConfig(db) {
|
|
224
|
+
const columns = db.pragma('table_info(members)');
|
|
225
|
+
if (!columns.some((column) => column.name === 'model')) {
|
|
226
|
+
db.exec('ALTER TABLE members ADD COLUMN model TEXT');
|
|
227
|
+
}
|
|
228
|
+
if (!columns.some((column) => column.name === 'thinking')) {
|
|
229
|
+
db.exec('ALTER TABLE members ADD COLUMN thinking TEXT');
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// harn:end agent-model-and-thinking-are-durable
|
|
233
|
+
function migrateMemberLimits(db) {
|
|
234
|
+
const columns = db.pragma('table_info(members)');
|
|
235
|
+
if (!columns.some((column) => column.name === 'limits')) {
|
|
236
|
+
db.exec('ALTER TABLE members ADD COLUMN limits TEXT');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function migrateMemberContextWindow(db) {
|
|
240
|
+
const columns = db.pragma('table_info(members)');
|
|
241
|
+
if (!columns.some((column) => column.name === 'context_window')) {
|
|
242
|
+
db.exec('ALTER TABLE members ADD COLUMN context_window INTEGER');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// harn:assume agent-member-credentials-stay-secret ref=member-credential-storage
|
|
246
|
+
function migrateMemberCredential(db) {
|
|
247
|
+
const columns = db.pragma('table_info(members)');
|
|
248
|
+
if (!columns.some((column) => column.name === 'credential_hash')) {
|
|
249
|
+
db.exec('ALTER TABLE members ADD COLUMN credential_hash TEXT');
|
|
250
|
+
}
|
|
251
|
+
db.exec(`
|
|
252
|
+
CREATE UNIQUE INDEX IF NOT EXISTS member_credential_hash_unique
|
|
253
|
+
ON members (credential_hash) WHERE credential_hash IS NOT NULL
|
|
254
|
+
`);
|
|
255
|
+
}
|
|
256
|
+
// harn:end agent-member-credentials-stay-secret
|
|
257
|
+
// harn:assume roster-briefing-refreshes-on-membership ref=active-roster-storage
|
|
258
|
+
function migrateMemberLifecycle(db) {
|
|
259
|
+
const columns = db.pragma('table_info(members)');
|
|
260
|
+
if (!columns.some((column) => column.name === 'purpose')) {
|
|
261
|
+
db.exec('ALTER TABLE members ADD COLUMN purpose TEXT');
|
|
262
|
+
}
|
|
263
|
+
if (!columns.some((column) => column.name === 'roster_stale')) {
|
|
264
|
+
db.exec('ALTER TABLE members ADD COLUMN roster_stale INTEGER NOT NULL DEFAULT 1');
|
|
265
|
+
}
|
|
266
|
+
if (!columns.some((column) => column.name === 'removed_ts')) {
|
|
267
|
+
db.exec('ALTER TABLE members ADD COLUMN removed_ts TEXT');
|
|
268
|
+
}
|
|
269
|
+
const table = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'members'").get();
|
|
270
|
+
if (/UNIQUE\s*\(\s*room\s*,\s*handle\s*\)/i.test(table.sql)) {
|
|
271
|
+
db.pragma('foreign_keys = OFF');
|
|
272
|
+
db.exec(`
|
|
273
|
+
ALTER TABLE members RENAME TO members_with_global_handle_unique;
|
|
274
|
+
CREATE TABLE members (
|
|
275
|
+
id TEXT PRIMARY KEY,
|
|
276
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
277
|
+
kind TEXT NOT NULL,
|
|
278
|
+
handle TEXT NOT NULL,
|
|
279
|
+
display_name TEXT NOT NULL,
|
|
280
|
+
purpose TEXT,
|
|
281
|
+
harness TEXT,
|
|
282
|
+
session_ref TEXT,
|
|
283
|
+
cwd TEXT,
|
|
284
|
+
policy TEXT,
|
|
285
|
+
host TEXT,
|
|
286
|
+
state TEXT,
|
|
287
|
+
custody TEXT,
|
|
288
|
+
parent TEXT,
|
|
289
|
+
role TEXT,
|
|
290
|
+
conventions_sent INTEGER NOT NULL DEFAULT 0,
|
|
291
|
+
misaddressed INTEGER NOT NULL DEFAULT 0,
|
|
292
|
+
roster_stale INTEGER NOT NULL DEFAULT 1,
|
|
293
|
+
removed_ts TEXT
|
|
294
|
+
);
|
|
295
|
+
INSERT INTO members SELECT id, room, kind, handle, display_name, purpose, harness,
|
|
296
|
+
session_ref, cwd, policy, host, state, custody, parent, role, conventions_sent,
|
|
297
|
+
misaddressed, roster_stale, removed_ts
|
|
298
|
+
FROM members_with_global_handle_unique;
|
|
299
|
+
DROP TABLE members_with_global_handle_unique;
|
|
300
|
+
`);
|
|
301
|
+
db.pragma('foreign_keys = ON');
|
|
302
|
+
}
|
|
303
|
+
db.exec(`
|
|
304
|
+
CREATE UNIQUE INDEX IF NOT EXISTS member_active_handle_unique
|
|
305
|
+
ON members (room, handle) WHERE removed_ts IS NULL
|
|
306
|
+
`);
|
|
307
|
+
}
|
|
308
|
+
// harn:end roster-briefing-refreshes-on-membership
|
|
309
|
+
function migrateMessageAck(db) {
|
|
310
|
+
const columns = db.pragma('table_info(messages)');
|
|
311
|
+
if (!columns.some((column) => column.name === 'ack')) {
|
|
312
|
+
db.exec('ALTER TABLE messages ADD COLUMN ack INTEGER NOT NULL DEFAULT 0');
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function migrateMessagePinned(db) {
|
|
316
|
+
const columns = db.pragma('table_info(messages)');
|
|
317
|
+
if (!columns.some((column) => column.name === 'pinned')) {
|
|
318
|
+
db.exec('ALTER TABLE messages ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function migrateMessageDeleted(db) {
|
|
322
|
+
const columns = db.pragma('table_info(messages)');
|
|
323
|
+
if (!columns.some((column) => column.name === 'deleted')) {
|
|
324
|
+
db.exec('ALTER TABLE messages ADD COLUMN deleted INTEGER NOT NULL DEFAULT 0');
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function migrateMessageAttachments(db) {
|
|
328
|
+
const columns = db.pragma('table_info(messages)');
|
|
329
|
+
if (!columns.some((column) => column.name === 'attachments')) {
|
|
330
|
+
db.exec('ALTER TABLE messages ADD COLUMN attachments TEXT');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-message-storage
|
|
334
|
+
function migrateMessageContinuations(db) {
|
|
335
|
+
const columns = db.pragma('table_info(messages)');
|
|
336
|
+
if (!columns.some((column) => column.name === 'run_parent_id')) {
|
|
337
|
+
db.exec('ALTER TABLE messages ADD COLUMN run_parent_id INTEGER');
|
|
338
|
+
}
|
|
339
|
+
db.exec(`
|
|
340
|
+
CREATE INDEX IF NOT EXISTS message_run_continuations
|
|
341
|
+
ON messages (room, run_parent_id, id) WHERE run_parent_id IS NOT NULL
|
|
342
|
+
`);
|
|
343
|
+
}
|
|
344
|
+
// harn:end continuation-writer-follows-journaled-output-ownership
|
|
345
|
+
// harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
|
|
346
|
+
function migrateMessageActivity(db) {
|
|
347
|
+
const columns = db.pragma('table_info(messages)');
|
|
348
|
+
if (!columns.some((column) => column.name === 'activity_seq')) {
|
|
349
|
+
db.exec('ALTER TABLE messages ADD COLUMN activity_seq INTEGER');
|
|
350
|
+
}
|
|
351
|
+
db.exec(`
|
|
352
|
+
UPDATE messages
|
|
353
|
+
SET activity_seq = seq
|
|
354
|
+
WHERE activity_seq IS NULL
|
|
355
|
+
AND deleted = 0
|
|
356
|
+
AND ack = 0
|
|
357
|
+
AND (
|
|
358
|
+
kind = 'chat'
|
|
359
|
+
OR (
|
|
360
|
+
kind = 'run'
|
|
361
|
+
AND run IS NOT NULL
|
|
362
|
+
AND json_extract(run, '$.status') <> 'running'
|
|
363
|
+
)
|
|
364
|
+
);
|
|
365
|
+
CREATE INDEX IF NOT EXISTS message_unread_activity
|
|
366
|
+
ON messages (room, activity_seq) WHERE activity_seq IS NOT NULL;
|
|
367
|
+
`);
|
|
368
|
+
}
|
|
369
|
+
// harn:end substantive-output-messages-drive-unread
|
|
370
|
+
// harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
|
|
371
|
+
function migrateRoomReadCursors(db) {
|
|
372
|
+
db.exec(`
|
|
373
|
+
CREATE TABLE IF NOT EXISTS room_read_cursors (
|
|
374
|
+
room TEXT NOT NULL REFERENCES rooms(id),
|
|
375
|
+
member_id TEXT NOT NULL REFERENCES members(id),
|
|
376
|
+
read_seq INTEGER NOT NULL,
|
|
377
|
+
updated_ts TEXT NOT NULL,
|
|
378
|
+
PRIMARY KEY (room, member_id)
|
|
379
|
+
);
|
|
380
|
+
INSERT OR IGNORE INTO room_read_cursors (room, member_id, read_seq, updated_ts)
|
|
381
|
+
SELECT members.room, members.id, rooms.seq, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
382
|
+
FROM members
|
|
383
|
+
JOIN rooms ON rooms.id = members.room
|
|
384
|
+
WHERE members.kind = 'human';
|
|
385
|
+
`);
|
|
386
|
+
}
|
|
387
|
+
// harn:end human-room-read-cursors-are-durable-and-monotonic
|
|
388
|
+
// harn:assume approval-deliveries-project-resolution-separately ref=approval-resolution-migration
|
|
389
|
+
function migrateApprovalDeliveryResolution(db) {
|
|
390
|
+
const columns = db.pragma('table_info(deliveries)');
|
|
391
|
+
if (!columns.some((column) => column.name === 'interaction_resolved_ts')) {
|
|
392
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN interaction_resolved_ts TEXT');
|
|
393
|
+
}
|
|
394
|
+
db.exec(`
|
|
395
|
+
UPDATE deliveries
|
|
396
|
+
SET interaction_resolved_ts = COALESCE(
|
|
397
|
+
interaction_resolved_ts,
|
|
398
|
+
(
|
|
399
|
+
SELECT interaction.answered_ts
|
|
400
|
+
FROM pending_interactions AS interaction
|
|
401
|
+
WHERE interaction.room = deliveries.room
|
|
402
|
+
AND interaction.message_id = deliveries.message_id
|
|
403
|
+
AND interaction.kind = 'approval'
|
|
404
|
+
AND interaction.state <> 'pending'
|
|
405
|
+
LIMIT 1
|
|
406
|
+
),
|
|
407
|
+
read_ts,
|
|
408
|
+
ts
|
|
409
|
+
),
|
|
410
|
+
read_ts = COALESCE(
|
|
411
|
+
read_ts,
|
|
412
|
+
(
|
|
413
|
+
SELECT interaction.answered_ts
|
|
414
|
+
FROM pending_interactions AS interaction
|
|
415
|
+
WHERE interaction.room = deliveries.room
|
|
416
|
+
AND interaction.message_id = deliveries.message_id
|
|
417
|
+
AND interaction.kind = 'approval'
|
|
418
|
+
AND interaction.state <> 'pending'
|
|
419
|
+
LIMIT 1
|
|
420
|
+
),
|
|
421
|
+
ts
|
|
422
|
+
)
|
|
423
|
+
WHERE (interaction_resolved_ts IS NULL OR read_ts IS NULL)
|
|
424
|
+
AND EXISTS (
|
|
425
|
+
SELECT 1
|
|
426
|
+
FROM pending_interactions AS interaction
|
|
427
|
+
JOIN members AS recipient
|
|
428
|
+
ON recipient.room = interaction.room
|
|
429
|
+
AND recipient.id = deliveries.recipient
|
|
430
|
+
AND recipient.kind = 'human'
|
|
431
|
+
WHERE interaction.room = deliveries.room
|
|
432
|
+
AND interaction.message_id = deliveries.message_id
|
|
433
|
+
AND interaction.kind = 'approval'
|
|
434
|
+
AND interaction.state <> 'pending'
|
|
435
|
+
AND EXISTS (
|
|
436
|
+
SELECT 1 FROM json_each(interaction.targets) AS target
|
|
437
|
+
WHERE target.value = deliveries.recipient
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
`);
|
|
441
|
+
}
|
|
442
|
+
// harn:end approval-deliveries-project-resolution-separately
|
|
443
|
+
function migrateDeliveryHopCount(db) {
|
|
444
|
+
const columns = db.pragma('table_info(deliveries)');
|
|
445
|
+
if (!columns.some((column) => column.name === 'hop_count')) {
|
|
446
|
+
db.exec('ALTER TABLE deliveries ADD COLUMN hop_count INTEGER NOT NULL DEFAULT 0');
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
function migrateMeterUncostedTokens(db) {
|
|
450
|
+
const columns = db.pragma('table_info(meters)');
|
|
451
|
+
if (!columns.some((column) => column.name === 'uncosted_tokens')) {
|
|
452
|
+
db.exec('ALTER TABLE meters ADD COLUMN uncosted_tokens INTEGER NOT NULL DEFAULT 0');
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
function migrateMeterEstimatedCost(db) {
|
|
456
|
+
const columns = db.pragma('table_info(meters)');
|
|
457
|
+
if (!columns.some((column) => column.name === 'estimated_cost_usd')) {
|
|
458
|
+
db.exec('ALTER TABLE meters ADD COLUMN estimated_cost_usd REAL NOT NULL DEFAULT 0');
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
// harn:assume bridge-enable-admin-or-owner ref=bridge-origin-uniqueness
|
|
462
|
+
function migrateBridgeOriginUniqueness(db) {
|
|
463
|
+
db.exec(`
|
|
464
|
+
CREATE UNIQUE INDEX IF NOT EXISTS message_bridge_origin_unique
|
|
465
|
+
ON messages (
|
|
466
|
+
author,
|
|
467
|
+
json_extract(origin, '$.platform'),
|
|
468
|
+
json_extract(origin, '$.external_id')
|
|
469
|
+
)
|
|
470
|
+
WHERE origin IS NOT NULL
|
|
471
|
+
`);
|
|
472
|
+
}
|
|
473
|
+
// harn:end bridge-enable-admin-or-owner
|
|
474
|
+
const toBool = (n) => n !== 0;
|
|
475
|
+
const fromBool = (b) => (b ? 1 : 0);
|
|
476
|
+
const orNull = (v) => (v === undefined ? null : v);
|
|
477
|
+
const jsonOrNull = (v) => (v === undefined ? null : JSON.stringify(v));
|
|
478
|
+
const messageDrivesUnread = (message) => message.deleted !== true
|
|
479
|
+
&& message.ack !== true
|
|
480
|
+
&& (message.kind === 'chat'
|
|
481
|
+
|| (message.kind === 'run' && message.run !== undefined && message.run.status !== 'running'));
|
|
482
|
+
function nextActivitySeq(message, mode, seq, stored = null) {
|
|
483
|
+
if (message.deleted === true || message.ack === true || mode === 'defer')
|
|
484
|
+
return null;
|
|
485
|
+
if (mode === 'force')
|
|
486
|
+
return seq;
|
|
487
|
+
if (!messageDrivesUnread(message))
|
|
488
|
+
return null;
|
|
489
|
+
return message.kind === 'chat' ? (stored ?? seq) : seq;
|
|
490
|
+
}
|
|
491
|
+
function memberFromRow(row) {
|
|
492
|
+
return MemberSchema.parse({
|
|
493
|
+
id: row.id,
|
|
494
|
+
kind: row.kind,
|
|
495
|
+
handle: row.handle,
|
|
496
|
+
display_name: row.display_name,
|
|
497
|
+
purpose: row.purpose ?? undefined,
|
|
498
|
+
harness: row.harness ?? undefined,
|
|
499
|
+
session_ref: row.session_ref ?? undefined,
|
|
500
|
+
cwd: row.cwd ?? undefined,
|
|
501
|
+
policy: row.policy ?? undefined,
|
|
502
|
+
model: row.model ?? undefined,
|
|
503
|
+
thinking: row.thinking ?? undefined,
|
|
504
|
+
host: row.host ?? undefined,
|
|
505
|
+
state: row.state ?? undefined,
|
|
506
|
+
custody: row.custody ?? undefined,
|
|
507
|
+
parent: row.parent ?? undefined,
|
|
508
|
+
role: row.role ?? undefined,
|
|
509
|
+
conventions_sent: toBool(row.conventions_sent),
|
|
510
|
+
misaddressed: toBool(row.misaddressed),
|
|
511
|
+
roster_stale: toBool(row.roster_stale),
|
|
512
|
+
removed_ts: row.removed_ts ?? undefined,
|
|
513
|
+
limits: row.limits ? JSON.parse(row.limits) : undefined,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function messageFromRow(row) {
|
|
517
|
+
return MessageSchema.parse({
|
|
518
|
+
id: row.id,
|
|
519
|
+
room: row.room,
|
|
520
|
+
author: row.author,
|
|
521
|
+
kind: row.kind,
|
|
522
|
+
body: row.body,
|
|
523
|
+
mentions: JSON.parse(row.mentions),
|
|
524
|
+
refs: JSON.parse(row.refs),
|
|
525
|
+
ledger_refs: JSON.parse(row.ledger_refs),
|
|
526
|
+
reply_to: row.reply_to ?? undefined,
|
|
527
|
+
run: row.run ? JSON.parse(row.run) : undefined,
|
|
528
|
+
run_parent_id: row.run_parent_id ?? undefined,
|
|
529
|
+
ask: row.ask ? JSON.parse(row.ask) : undefined,
|
|
530
|
+
origin: row.origin ? JSON.parse(row.origin) : undefined,
|
|
531
|
+
ack: toBool(row.ack) ? true : undefined,
|
|
532
|
+
pinned: toBool(row.pinned) ? true : undefined,
|
|
533
|
+
deleted: toBool(row.deleted) ? true : undefined,
|
|
534
|
+
attachments: row.attachments ? JSON.parse(row.attachments) : undefined,
|
|
535
|
+
ts: row.ts,
|
|
536
|
+
seq: row.seq,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
function deliveryFromRow(row) {
|
|
540
|
+
return DeliverySchema.parse({
|
|
541
|
+
id: row.id,
|
|
542
|
+
room: row.room,
|
|
543
|
+
message_id: row.message_id,
|
|
544
|
+
recipient: row.recipient,
|
|
545
|
+
state: row.state,
|
|
546
|
+
hop_count: row.hop_count,
|
|
547
|
+
attempt_count: row.attempt_count,
|
|
548
|
+
batch_id: row.batch_id ?? undefined,
|
|
549
|
+
run_msg_id: row.run_msg_id ?? undefined,
|
|
550
|
+
read_ts: row.read_ts ?? undefined,
|
|
551
|
+
interaction_resolved_ts: row.interaction_resolved_ts ?? undefined,
|
|
552
|
+
group_id: row.group_id ?? undefined,
|
|
553
|
+
group_round: row.group_round ?? undefined,
|
|
554
|
+
ts: row.ts,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
function collaborationGroupFromRow(row) {
|
|
558
|
+
return CollaborationGroupSchema.parse({
|
|
559
|
+
...row,
|
|
560
|
+
completed_ts: row.completed_ts ?? undefined,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
function collaborationRoundFromRow(row) {
|
|
564
|
+
return CollaborationRoundSchema.parse({
|
|
565
|
+
...row,
|
|
566
|
+
released_ts: row.released_ts ?? undefined,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
function collaborationParticipantFromRow(row) {
|
|
570
|
+
return CollaborationParticipantSchema.parse({
|
|
571
|
+
...row,
|
|
572
|
+
terminal_status: row.terminal_status ?? undefined,
|
|
573
|
+
result_message_id: row.result_message_id ?? undefined,
|
|
574
|
+
completed_ts: row.completed_ts ?? undefined,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
function interactionFromRow(row) {
|
|
578
|
+
return PendingInteractionSchema.parse({
|
|
579
|
+
id: row.id,
|
|
580
|
+
room: row.room,
|
|
581
|
+
member_id: row.member_id,
|
|
582
|
+
message_id: row.message_id,
|
|
583
|
+
native_id: row.native_id,
|
|
584
|
+
kind: row.kind,
|
|
585
|
+
targets: JSON.parse(row.targets),
|
|
586
|
+
state: row.state,
|
|
587
|
+
answer: row.answer === null ? undefined : JSON.parse(row.answer),
|
|
588
|
+
answered_by: row.answered_by ?? undefined,
|
|
589
|
+
answered_ts: row.answered_ts ?? undefined,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
// harn:assume every-channel-has-a-visible-accent ref=channel-accent-persistence
|
|
593
|
+
function roomFromRow(row) {
|
|
594
|
+
const config = JSON.parse(row.config);
|
|
595
|
+
return RoomSchema.parse({
|
|
596
|
+
id: row.id,
|
|
597
|
+
name: row.name,
|
|
598
|
+
created_ts: row.created_ts,
|
|
599
|
+
// Channels the CLI made (the boot-seeded unit among them) carry no colour.
|
|
600
|
+
// Deriving on read gives every existing channel an accent without a migration.
|
|
601
|
+
config: { ...config, color: config.color ?? deriveRoomColor(row.id) },
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
// harn:end every-channel-has-a-visible-accent
|
|
605
|
+
function meterFromRow(row) {
|
|
606
|
+
return RoomMeterSchema.parse(row);
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* The room store: better-sqlite3, synchronous, one file per switchboard.
|
|
610
|
+
* Every mutation of a client-visible entity appends to the change log inside
|
|
611
|
+
* the same transaction — sync hydrates exclusively from that log.
|
|
612
|
+
*/
|
|
613
|
+
export class Store {
|
|
614
|
+
db;
|
|
615
|
+
constructor(path) {
|
|
616
|
+
this.db = new Database(path);
|
|
617
|
+
this.db.pragma('journal_mode = WAL');
|
|
618
|
+
this.db.pragma('foreign_keys = ON');
|
|
619
|
+
this.db.exec(SCHEMA);
|
|
620
|
+
migrateDeliveryPayloadSnapshot(this.db);
|
|
621
|
+
migrateMemberCustody(this.db);
|
|
622
|
+
migrateMemberLifecycle(this.db);
|
|
623
|
+
// MUST run after migrateMemberLifecycle: on a legacy database that one REBUILDS the
|
|
624
|
+
// members table from an explicit column list, which would silently drop these two
|
|
625
|
+
// again — and then every insert would fail on a column that no longer exists.
|
|
626
|
+
migrateMemberAgentConfig(this.db);
|
|
627
|
+
migrateMemberLimits(this.db);
|
|
628
|
+
migrateMemberContextWindow(this.db);
|
|
629
|
+
migrateMemberCredential(this.db);
|
|
630
|
+
migrateMessageAck(this.db);
|
|
631
|
+
migrateMessagePinned(this.db);
|
|
632
|
+
migrateMessageDeleted(this.db);
|
|
633
|
+
migrateMessageAttachments(this.db);
|
|
634
|
+
migrateMessageContinuations(this.db);
|
|
635
|
+
migrateMessageActivity(this.db);
|
|
636
|
+
migrateRoomReadCursors(this.db);
|
|
637
|
+
migrateApprovalDeliveryResolution(this.db);
|
|
638
|
+
migrateDeliveryHopCount(this.db);
|
|
639
|
+
migrateMeterUncostedTokens(this.db);
|
|
640
|
+
migrateMeterEstimatedCost(this.db);
|
|
641
|
+
migrateBridgeOriginUniqueness(this.db);
|
|
642
|
+
}
|
|
643
|
+
close() {
|
|
644
|
+
this.db.close();
|
|
645
|
+
}
|
|
646
|
+
// harn:assume changelog-covers-every-visible-entity ref=changelog-append
|
|
647
|
+
/**
|
|
648
|
+
* Allocates the room's next seq and appends one change row — called by
|
|
649
|
+
* EVERY mutating method below, inside its transaction. Returns the seq so
|
|
650
|
+
* the mutation can stamp it on the entity (messages carry their seq).
|
|
651
|
+
*/
|
|
652
|
+
appendChange(room, entity, entityId) {
|
|
653
|
+
const bumped = this.db
|
|
654
|
+
.prepare('UPDATE rooms SET seq = seq + 1 WHERE id = ? RETURNING seq')
|
|
655
|
+
.get(room);
|
|
656
|
+
if (!bumped)
|
|
657
|
+
throw new Error(`no such room: ${room}`);
|
|
658
|
+
this.db
|
|
659
|
+
.prepare('INSERT INTO changes (room_id, seq, entity, entity_id) VALUES (?, ?, ?, ?)')
|
|
660
|
+
.run(room, bumped.seq, entity, entityId);
|
|
661
|
+
return bumped.seq;
|
|
662
|
+
}
|
|
663
|
+
// harn:end changelog-covers-every-visible-entity
|
|
664
|
+
// ── rooms ─────────────────────────────────────────────────────────────
|
|
665
|
+
// harn:assume owner-and-system-members-seeded ref=room-seeding
|
|
666
|
+
/**
|
|
667
|
+
* Creates a room and atomically seeds its two structural members: the
|
|
668
|
+
* owner human (the authenticated principal's author identity) and the
|
|
669
|
+
* non-addressable system member holding the reserved 'switchboard' handle.
|
|
670
|
+
*/
|
|
671
|
+
createRoom(opts) {
|
|
672
|
+
const config = RoomConfigSchema.parse({
|
|
673
|
+
...opts.config,
|
|
674
|
+
color: opts.config?.color ?? deriveRoomColor(opts.id),
|
|
675
|
+
});
|
|
676
|
+
const ts = new Date().toISOString();
|
|
677
|
+
const result = this.db.transaction(() => {
|
|
678
|
+
this.db
|
|
679
|
+
.prepare('INSERT INTO rooms (id, name, created_ts, config, seq) VALUES (?, ?, ?, ?, 0)')
|
|
680
|
+
.run(opts.id, opts.name, ts, JSON.stringify(config));
|
|
681
|
+
this.appendChange(opts.id, 'room', opts.id);
|
|
682
|
+
const owner = this.insertMember(opts.id, {
|
|
683
|
+
kind: 'human',
|
|
684
|
+
handle: opts.owner.handle,
|
|
685
|
+
display_name: opts.owner.display_name,
|
|
686
|
+
role: 'owner',
|
|
687
|
+
});
|
|
688
|
+
const system = this.insertMember(opts.id, {
|
|
689
|
+
kind: 'system',
|
|
690
|
+
handle: 'switchboard',
|
|
691
|
+
display_name: 'Switchboard',
|
|
692
|
+
});
|
|
693
|
+
return { owner, system };
|
|
694
|
+
})();
|
|
695
|
+
return { room: this.getRoom(opts.id), ...result };
|
|
696
|
+
}
|
|
697
|
+
// harn:end owner-and-system-members-seeded
|
|
698
|
+
getRoom(id) {
|
|
699
|
+
const row = this.db.prepare('SELECT * FROM rooms WHERE id = ?').get(id);
|
|
700
|
+
return row ? roomFromRow(row) : undefined;
|
|
701
|
+
}
|
|
702
|
+
listRooms() {
|
|
703
|
+
const rows = this.db.prepare('SELECT * FROM rooms ORDER BY id').all();
|
|
704
|
+
return rows.map(roomFromRow);
|
|
705
|
+
}
|
|
706
|
+
updateRoomConfig(room, patch) {
|
|
707
|
+
return this.db.transaction(() => {
|
|
708
|
+
const current = this.getRoom(room);
|
|
709
|
+
if (!current)
|
|
710
|
+
throw new Error(`no such room: ${room}`);
|
|
711
|
+
const config = RoomConfigSchema.parse({ ...current.config, ...patch });
|
|
712
|
+
this.db
|
|
713
|
+
.prepare('UPDATE rooms SET config = ? WHERE id = ?')
|
|
714
|
+
.run(JSON.stringify(config), room);
|
|
715
|
+
this.appendChange(room, 'room', room);
|
|
716
|
+
return this.getRoom(room);
|
|
717
|
+
})();
|
|
718
|
+
}
|
|
719
|
+
// ── members ───────────────────────────────────────────────────────────
|
|
720
|
+
insertMember(room, member) {
|
|
721
|
+
const validated = MemberSchema.parse({ id: this.newUlid(), ...member });
|
|
722
|
+
this.db
|
|
723
|
+
.prepare(`INSERT INTO members (id, room, kind, handle, display_name, purpose, harness, session_ref,
|
|
724
|
+
cwd, policy, model, thinking, host, state, custody, parent, role, conventions_sent,
|
|
725
|
+
misaddressed, roster_stale, removed_ts)
|
|
726
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
727
|
+
.run(validated.id, room, validated.kind, validated.handle, validated.display_name, orNull(validated.purpose), orNull(validated.harness), orNull(validated.session_ref), orNull(validated.cwd), orNull(validated.policy), orNull(validated.model), orNull(validated.thinking), orNull(validated.host), orNull(validated.state), orNull(validated.custody), orNull(validated.parent), orNull(validated.role), fromBool(validated.conventions_sent), fromBool(validated.misaddressed), fromBool(validated.roster_stale), orNull(validated.removed_ts));
|
|
728
|
+
const seq = this.appendChange(room, 'member', validated.id);
|
|
729
|
+
// harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
|
|
730
|
+
if (validated.kind === 'human') {
|
|
731
|
+
this.db.prepare(`INSERT INTO room_read_cursors (room, member_id, read_seq, updated_ts)
|
|
732
|
+
VALUES (?, ?, ?, ?)
|
|
733
|
+
ON CONFLICT (room, member_id) DO NOTHING`).run(room, validated.id, seq, new Date().toISOString());
|
|
734
|
+
}
|
|
735
|
+
// harn:end human-room-read-cursors-are-durable-and-monotonic
|
|
736
|
+
return validated;
|
|
737
|
+
}
|
|
738
|
+
addMember(room, member) {
|
|
739
|
+
return this.db.transaction(() => this.insertMember(room, member))();
|
|
740
|
+
}
|
|
741
|
+
updateMember(room, memberId, patch) {
|
|
742
|
+
return this.db.transaction(() => {
|
|
743
|
+
const existing = this.getMember(room, memberId);
|
|
744
|
+
if (!existing)
|
|
745
|
+
throw new Error(`no such member: ${memberId}`);
|
|
746
|
+
const merged = MemberSchema.parse({ ...existing, ...patch });
|
|
747
|
+
this.db
|
|
748
|
+
.prepare(
|
|
749
|
+
// harn:assume member-config-is-changed-not-respawned ref=member-config-storage
|
|
750
|
+
// A2 gave members a model and a thinking level and taught the INSERT and the
|
|
751
|
+
// READ about them — but not this. Nothing changed them after spawn, so nothing
|
|
752
|
+
// noticed. A configure that called this would have reported success and
|
|
753
|
+
// persisted nothing at all.
|
|
754
|
+
`UPDATE members SET handle = ?, display_name = ?, purpose = ?, harness = ?, session_ref = ?,
|
|
755
|
+
cwd = ?, policy = ?, model = ?, thinking = ?, host = ?, state = ?, custody = ?,
|
|
756
|
+
parent = ?, role = ?, conventions_sent = ?, misaddressed = ?, roster_stale = ?,
|
|
757
|
+
removed_ts = ?, limits = ?
|
|
758
|
+
WHERE room = ? AND id = ?`)
|
|
759
|
+
.run(merged.handle, merged.display_name, orNull(merged.purpose), orNull(merged.harness), orNull(merged.session_ref), orNull(merged.cwd), orNull(merged.policy), orNull(merged.model), orNull(merged.thinking), orNull(merged.host), orNull(merged.state), orNull(merged.custody), orNull(merged.parent), orNull(merged.role), fromBool(merged.conventions_sent), fromBool(merged.misaddressed), fromBool(merged.roster_stale), orNull(merged.removed_ts), merged.limits === undefined ? null : JSON.stringify(merged.limits), room, memberId);
|
|
760
|
+
// harn:end member-config-is-changed-not-respawned
|
|
761
|
+
this.appendChange(room, 'member', memberId);
|
|
762
|
+
return merged;
|
|
763
|
+
})();
|
|
764
|
+
}
|
|
765
|
+
// harn:assume agent-member-credentials-stay-secret ref=member-credential-storage
|
|
766
|
+
setAgentCredentialHash(room, memberId, credentialHash) {
|
|
767
|
+
if (!/^[a-f0-9]{64}$/.test(credentialHash)) {
|
|
768
|
+
throw new Error('member credential hash must be a SHA-256 digest');
|
|
769
|
+
}
|
|
770
|
+
const member = this.getMember(room, memberId);
|
|
771
|
+
if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
|
|
772
|
+
throw new Error(`no active agent member: ${memberId}`);
|
|
773
|
+
}
|
|
774
|
+
this.db
|
|
775
|
+
.prepare('UPDATE members SET credential_hash = ? WHERE room = ? AND id = ?')
|
|
776
|
+
.run(credentialHash, room, memberId);
|
|
777
|
+
}
|
|
778
|
+
findAgentByCredentialHash(credentialHash) {
|
|
779
|
+
const row = this.db
|
|
780
|
+
.prepare(`SELECT * FROM members
|
|
781
|
+
WHERE credential_hash = ? AND kind = 'agent' AND removed_ts IS NULL
|
|
782
|
+
AND state <> 'dead'`)
|
|
783
|
+
.get(credentialHash);
|
|
784
|
+
return row ? { room: row.room, member: memberFromRow(row) } : undefined;
|
|
785
|
+
}
|
|
786
|
+
// harn:end agent-member-credentials-stay-secret
|
|
787
|
+
getMember(room, memberId) {
|
|
788
|
+
const row = this.db
|
|
789
|
+
.prepare('SELECT * FROM members WHERE room = ? AND id = ?')
|
|
790
|
+
.get(room, memberId);
|
|
791
|
+
return row ? memberFromRow(row) : undefined;
|
|
792
|
+
}
|
|
793
|
+
getMemberByHandle(room, handle) {
|
|
794
|
+
const row = this.db
|
|
795
|
+
.prepare('SELECT * FROM members WHERE room = ? AND handle = ? AND removed_ts IS NULL')
|
|
796
|
+
.get(room, handle);
|
|
797
|
+
return row ? memberFromRow(row) : undefined;
|
|
798
|
+
}
|
|
799
|
+
findMemberBySessionRef(harness, sessionRef) {
|
|
800
|
+
const row = this.db
|
|
801
|
+
.prepare('SELECT * FROM members WHERE harness = ? AND session_ref = ? AND removed_ts IS NULL ORDER BY room LIMIT 1')
|
|
802
|
+
.get(harness, sessionRef);
|
|
803
|
+
return row ? { room: row.room, member: memberFromRow(row) } : undefined;
|
|
804
|
+
}
|
|
805
|
+
getExtensionByNativeId(room, parentId, nativeId) {
|
|
806
|
+
const row = this.db
|
|
807
|
+
.prepare(`SELECT * FROM members
|
|
808
|
+
WHERE room = ? AND kind = 'extension' AND parent = ? AND session_ref = ?
|
|
809
|
+
ORDER BY id LIMIT 1`)
|
|
810
|
+
.get(room, parentId, nativeId);
|
|
811
|
+
return row ? memberFromRow(row) : undefined;
|
|
812
|
+
}
|
|
813
|
+
listMembers(room, options = {}) {
|
|
814
|
+
const rows = this.db
|
|
815
|
+
.prepare(`SELECT * FROM members WHERE room = ?
|
|
816
|
+
AND (? = 1 OR removed_ts IS NULL) ORDER BY id`)
|
|
817
|
+
.all(room, options.includeRemoved ? 1 : 0);
|
|
818
|
+
return rows.map(memberFromRow);
|
|
819
|
+
}
|
|
820
|
+
markAgentRostersStale(room) {
|
|
821
|
+
this.db.prepare(`UPDATE members SET roster_stale = 1
|
|
822
|
+
WHERE room = ? AND kind = 'agent' AND removed_ts IS NULL`).run(room);
|
|
823
|
+
}
|
|
824
|
+
clearAgentRosterStale(room, memberId) {
|
|
825
|
+
this.db.prepare(`UPDATE members SET roster_stale = 0
|
|
826
|
+
WHERE room = ? AND id = ? AND kind = 'agent' AND removed_ts IS NULL`).run(room, memberId);
|
|
827
|
+
}
|
|
828
|
+
getMirroredMessageId(room, memberId, nativeTurnId) {
|
|
829
|
+
const row = this.db
|
|
830
|
+
.prepare(`SELECT message_id FROM mirrored_turns
|
|
831
|
+
WHERE room = ? AND member_id = ? AND native_turn_id = ?`)
|
|
832
|
+
.get(room, memberId, nativeTurnId);
|
|
833
|
+
return row?.message_id;
|
|
834
|
+
}
|
|
835
|
+
recordMirroredTurn(room, memberId, nativeTurnId, messageId) {
|
|
836
|
+
this.db
|
|
837
|
+
.prepare(`INSERT INTO mirrored_turns (room, member_id, native_turn_id, message_id)
|
|
838
|
+
VALUES (?, ?, ?, ?)`)
|
|
839
|
+
.run(room, memberId, nativeTurnId, messageId);
|
|
840
|
+
}
|
|
841
|
+
// harn:assume mirrored-turn-commit-transactional ref=atomic-mirrored-turn
|
|
842
|
+
commitMirroredTurn(room, opts) {
|
|
843
|
+
return this.db.transaction(() => {
|
|
844
|
+
const existingId = this.getMirroredMessageId(room, opts.memberId, opts.nativeTurnId);
|
|
845
|
+
if (existingId !== undefined) {
|
|
846
|
+
const message = this.getMessage(room, existingId);
|
|
847
|
+
if (!message)
|
|
848
|
+
throw new Error(`mirrored turn points to missing message #${existingId}`);
|
|
849
|
+
return { message, deliveries: [], deduped: true };
|
|
850
|
+
}
|
|
851
|
+
const placeholder = this.postMessage(room, {
|
|
852
|
+
author: opts.memberId,
|
|
853
|
+
kind: 'run',
|
|
854
|
+
body: '',
|
|
855
|
+
});
|
|
856
|
+
const finalized = opts.finalize(placeholder);
|
|
857
|
+
const message = this.updateMessage(room, placeholder.id, finalized.message);
|
|
858
|
+
const member = finalized.markMisaddressed
|
|
859
|
+
? this.updateMember(room, opts.memberId, { misaddressed: true })
|
|
860
|
+
: undefined;
|
|
861
|
+
const deliveries = finalized.fanout.map((delivery) => this.createDelivery(room, {
|
|
862
|
+
message_id: message.id,
|
|
863
|
+
recipient: delivery.recipient,
|
|
864
|
+
state: delivery.state,
|
|
865
|
+
payload_snapshot: delivery.payload_snapshot,
|
|
866
|
+
hop_count: delivery.hop_count,
|
|
867
|
+
}));
|
|
868
|
+
const collaboration = finalized.collaboration === undefined
|
|
869
|
+
? undefined
|
|
870
|
+
: this.createCollaborationGroup(room, {
|
|
871
|
+
groupId: finalized.collaboration.groupId,
|
|
872
|
+
rootMessageId: message.id,
|
|
873
|
+
participants: finalized.collaboration.participants,
|
|
874
|
+
});
|
|
875
|
+
if (collaboration)
|
|
876
|
+
deliveries.push(...collaboration.deliveries);
|
|
877
|
+
this.recordMirroredTurn(room, opts.memberId, opts.nativeTurnId, message.id);
|
|
878
|
+
return { message, deliveries, member, collaboration, deduped: false };
|
|
879
|
+
})();
|
|
880
|
+
}
|
|
881
|
+
// harn:end mirrored-turn-commit-transactional
|
|
882
|
+
createAttachLease(input) {
|
|
883
|
+
const lease = AttachLeaseSchema.parse({ id: this.newUlid(), ...input });
|
|
884
|
+
this.db
|
|
885
|
+
.prepare(`INSERT INTO attach_leases
|
|
886
|
+
(id, room, member_id, cli_pid, child_pid, process_group_id, heartbeat_ts)
|
|
887
|
+
VALUES (?, ?, ?, ?, NULL, NULL, ?)`)
|
|
888
|
+
.run(lease.id, lease.room, lease.member_id, lease.cli_pid, lease.heartbeat_ts);
|
|
889
|
+
return lease;
|
|
890
|
+
}
|
|
891
|
+
getAttachLease(id) {
|
|
892
|
+
const row = this.db.prepare('SELECT * FROM attach_leases WHERE id = ?').get(id);
|
|
893
|
+
return row ? this.attachLeaseFromRow(row) : undefined;
|
|
894
|
+
}
|
|
895
|
+
getAttachLeaseForMember(memberId) {
|
|
896
|
+
const row = this.db.prepare('SELECT * FROM attach_leases WHERE member_id = ?').get(memberId);
|
|
897
|
+
return row ? this.attachLeaseFromRow(row) : undefined;
|
|
898
|
+
}
|
|
899
|
+
listAttachLeases() {
|
|
900
|
+
return this.db.prepare('SELECT * FROM attach_leases ORDER BY id').all()
|
|
901
|
+
.map((row) => this.attachLeaseFromRow(row));
|
|
902
|
+
}
|
|
903
|
+
setAttachLeaseChild(id, childPid, processGroupId, heartbeatTs) {
|
|
904
|
+
const result = this.db
|
|
905
|
+
.prepare(`UPDATE attach_leases
|
|
906
|
+
SET child_pid = ?, process_group_id = ?, heartbeat_ts = ?
|
|
907
|
+
WHERE id = ?`)
|
|
908
|
+
.run(childPid, processGroupId, heartbeatTs, id);
|
|
909
|
+
if (result.changes !== 1)
|
|
910
|
+
throw new Error(`no such attach lease ${id}`);
|
|
911
|
+
return this.getAttachLease(id);
|
|
912
|
+
}
|
|
913
|
+
heartbeatAttachLease(id, heartbeatTs) {
|
|
914
|
+
const result = this.db
|
|
915
|
+
.prepare('UPDATE attach_leases SET heartbeat_ts = ? WHERE id = ?')
|
|
916
|
+
.run(heartbeatTs, id);
|
|
917
|
+
if (result.changes !== 1)
|
|
918
|
+
throw new Error(`no such attach lease ${id}`);
|
|
919
|
+
return this.getAttachLease(id);
|
|
920
|
+
}
|
|
921
|
+
deleteAttachLease(id) {
|
|
922
|
+
this.db.prepare('DELETE FROM attach_leases WHERE id = ?').run(id);
|
|
923
|
+
}
|
|
924
|
+
attachLeaseFromRow(row) {
|
|
925
|
+
return AttachLeaseSchema.parse({
|
|
926
|
+
id: row.id,
|
|
927
|
+
room: row.room,
|
|
928
|
+
member_id: row.member_id,
|
|
929
|
+
cli_pid: row.cli_pid,
|
|
930
|
+
child_pid: row.child_pid ?? undefined,
|
|
931
|
+
process_group_id: row.process_group_id ?? undefined,
|
|
932
|
+
heartbeat_ts: row.heartbeat_ts,
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
// ── messages ──────────────────────────────────────────────────────────
|
|
936
|
+
// harn:assume message-id-txn-allocation ref=message-id-allocation
|
|
937
|
+
/**
|
|
938
|
+
* Message ids are per-room dense monotonic ints allocated as MAX(id)+1
|
|
939
|
+
* INSIDE the same synchronous transaction as the insert — ids are permanent
|
|
940
|
+
* (#N refs), so allocation can never race or leave gaps.
|
|
941
|
+
*/
|
|
942
|
+
postMessage(room, message, options = {}) {
|
|
943
|
+
return this.db.transaction(() => {
|
|
944
|
+
const next = this.db
|
|
945
|
+
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS id FROM messages WHERE room = ?')
|
|
946
|
+
.get(room);
|
|
947
|
+
const seq = this.appendChange(room, 'message', String(next.id));
|
|
948
|
+
const validated = MessageSchema.parse({
|
|
949
|
+
id: next.id,
|
|
950
|
+
room,
|
|
951
|
+
author: message.author,
|
|
952
|
+
kind: message.kind,
|
|
953
|
+
body: message.body,
|
|
954
|
+
mentions: message.mentions ?? [],
|
|
955
|
+
refs: message.refs ?? [],
|
|
956
|
+
ledger_refs: message.ledger_refs ?? [],
|
|
957
|
+
reply_to: message.reply_to,
|
|
958
|
+
run: message.run,
|
|
959
|
+
run_parent_id: message.run_parent_id,
|
|
960
|
+
ask: message.ask,
|
|
961
|
+
origin: message.origin,
|
|
962
|
+
ack: message.ack,
|
|
963
|
+
attachments: message.attachments,
|
|
964
|
+
ts: new Date().toISOString(),
|
|
965
|
+
seq,
|
|
966
|
+
});
|
|
967
|
+
// harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
|
|
968
|
+
const activitySeq = nextActivitySeq(validated, options.activity ?? 'auto', seq);
|
|
969
|
+
this.db
|
|
970
|
+
.prepare(`INSERT INTO messages (room, id, author, kind, body, mentions, refs, ledger_refs,
|
|
971
|
+
reply_to, run, run_parent_id, ask, origin, attachments, ack, pinned, deleted, ts, seq, activity_seq)
|
|
972
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
973
|
+
.run(room, validated.id, validated.author, validated.kind, validated.body, JSON.stringify(validated.mentions), JSON.stringify(validated.refs), JSON.stringify(validated.ledger_refs), orNull(validated.reply_to), jsonOrNull(validated.run), orNull(validated.run_parent_id), jsonOrNull(validated.ask), jsonOrNull(validated.origin), jsonOrNull(validated.attachments), fromBool(validated.ack === true), fromBool(validated.pinned === true), fromBool(validated.deleted === true), validated.ts, validated.seq, activitySeq);
|
|
974
|
+
// harn:end substantive-output-messages-drive-unread
|
|
975
|
+
return validated;
|
|
976
|
+
})();
|
|
977
|
+
}
|
|
978
|
+
// harn:end message-id-txn-allocation
|
|
979
|
+
// harn:assume eligible-multi-agent-routing-starts-one-group ref=atomic-routed-message-commit
|
|
980
|
+
commitRoutedMessage(room, opts) {
|
|
981
|
+
return this.db.transaction(() => {
|
|
982
|
+
const message = this.postMessage(room, opts.message);
|
|
983
|
+
const plan = opts.plan(message);
|
|
984
|
+
const member = plan.markMisaddressed
|
|
985
|
+
? this.updateMember(room, message.author, { misaddressed: true })
|
|
986
|
+
: undefined;
|
|
987
|
+
const deliveries = plan.fanout.map((delivery) => this.createDelivery(room, {
|
|
988
|
+
message_id: message.id,
|
|
989
|
+
recipient: delivery.recipient,
|
|
990
|
+
state: delivery.state,
|
|
991
|
+
payload_snapshot: delivery.payload_snapshot,
|
|
992
|
+
hop_count: delivery.hop_count,
|
|
993
|
+
}));
|
|
994
|
+
const collaboration = plan.collaboration === undefined
|
|
995
|
+
? undefined
|
|
996
|
+
: this.createCollaborationGroup(room, {
|
|
997
|
+
groupId: plan.collaboration.groupId,
|
|
998
|
+
rootMessageId: message.id,
|
|
999
|
+
participants: plan.collaboration.participants,
|
|
1000
|
+
});
|
|
1001
|
+
if (collaboration)
|
|
1002
|
+
deliveries.push(...collaboration.deliveries);
|
|
1003
|
+
return { message, deliveries, member, collaboration };
|
|
1004
|
+
})();
|
|
1005
|
+
}
|
|
1006
|
+
// harn:end eligible-multi-agent-routing-starts-one-group
|
|
1007
|
+
postBridgeMessage(room, bridgeMemberId, body, origin, parsed, plan) {
|
|
1008
|
+
const member = this.getMember(room, bridgeMemberId);
|
|
1009
|
+
if (member?.kind !== 'bridge')
|
|
1010
|
+
throw new Error(`no such bridge member: ${bridgeMemberId}`);
|
|
1011
|
+
const validOrigin = BridgeOriginSchema.parse(origin);
|
|
1012
|
+
return this.db.transaction(() => {
|
|
1013
|
+
const existing = this.db.prepare(`SELECT * FROM messages
|
|
1014
|
+
WHERE room = ? AND author = ?
|
|
1015
|
+
AND json_extract(origin, '$.platform') = ?
|
|
1016
|
+
AND json_extract(origin, '$.external_id') = ?
|
|
1017
|
+
LIMIT 1`).get(room, bridgeMemberId, validOrigin.platform, validOrigin.external_id);
|
|
1018
|
+
if (existing) {
|
|
1019
|
+
return { message: messageFromRow(existing), deliveries: [], deduped: true };
|
|
1020
|
+
}
|
|
1021
|
+
const message = this.postMessage(room, {
|
|
1022
|
+
author: bridgeMemberId,
|
|
1023
|
+
kind: 'chat',
|
|
1024
|
+
body,
|
|
1025
|
+
...parsed,
|
|
1026
|
+
origin: validOrigin,
|
|
1027
|
+
});
|
|
1028
|
+
const routed = plan?.(message);
|
|
1029
|
+
const memberPatch = routed?.markMisaddressed
|
|
1030
|
+
? this.updateMember(room, bridgeMemberId, { misaddressed: true })
|
|
1031
|
+
: undefined;
|
|
1032
|
+
const deliveries = (routed?.fanout ?? []).map((delivery) => this.createDelivery(room, {
|
|
1033
|
+
message_id: message.id,
|
|
1034
|
+
recipient: delivery.recipient,
|
|
1035
|
+
state: delivery.state,
|
|
1036
|
+
payload_snapshot: delivery.payload_snapshot,
|
|
1037
|
+
hop_count: delivery.hop_count,
|
|
1038
|
+
}));
|
|
1039
|
+
const collaboration = routed?.collaboration === undefined
|
|
1040
|
+
? undefined
|
|
1041
|
+
: this.createCollaborationGroup(room, {
|
|
1042
|
+
groupId: routed.collaboration.groupId,
|
|
1043
|
+
rootMessageId: message.id,
|
|
1044
|
+
participants: routed.collaboration.participants,
|
|
1045
|
+
});
|
|
1046
|
+
if (collaboration)
|
|
1047
|
+
deliveries.push(...collaboration.deliveries);
|
|
1048
|
+
return {
|
|
1049
|
+
message,
|
|
1050
|
+
deliveries,
|
|
1051
|
+
member: memberPatch,
|
|
1052
|
+
collaboration,
|
|
1053
|
+
deduped: false,
|
|
1054
|
+
};
|
|
1055
|
+
})();
|
|
1056
|
+
}
|
|
1057
|
+
latestMessageId(room) {
|
|
1058
|
+
const row = this.db.prepare('SELECT COALESCE(MAX(id), 0) AS id FROM messages WHERE room = ?').get(room);
|
|
1059
|
+
return row.id;
|
|
1060
|
+
}
|
|
1061
|
+
// harn:assume continuation-writer-follows-journaled-output-ownership ref=continuation-message-storage
|
|
1062
|
+
/** Allocate and insert a permanent empty continuation before its id is journaled. */
|
|
1063
|
+
createRunContinuation(room, rootMessageId) {
|
|
1064
|
+
const root = this.getMessage(room, rootMessageId);
|
|
1065
|
+
if (root?.kind !== 'run' || root.run === undefined || root.run_parent_id !== undefined) {
|
|
1066
|
+
throw new Error(`#${rootMessageId} is not a lifecycle run root`);
|
|
1067
|
+
}
|
|
1068
|
+
if (root.run.status !== 'running' || root.run.output_mode !== 'messages') {
|
|
1069
|
+
throw new Error(`#${rootMessageId} is not accepting continuation output`);
|
|
1070
|
+
}
|
|
1071
|
+
return this.postMessage(room, {
|
|
1072
|
+
author: root.author,
|
|
1073
|
+
kind: 'run',
|
|
1074
|
+
body: '',
|
|
1075
|
+
run_parent_id: root.id,
|
|
1076
|
+
}, { activity: 'defer' });
|
|
1077
|
+
}
|
|
1078
|
+
listRunContinuations(room, rootMessageId) {
|
|
1079
|
+
const rows = this.db.prepare('SELECT * FROM messages WHERE room = ? AND run_parent_id = ? ORDER BY id').all(room, rootMessageId);
|
|
1080
|
+
return rows.map(messageFromRow);
|
|
1081
|
+
}
|
|
1082
|
+
/** Resolve either a root or one of its result fragments to lifecycle truth. */
|
|
1083
|
+
getRunRoot(room, message) {
|
|
1084
|
+
return message.run_parent_id === undefined
|
|
1085
|
+
? (message.run === undefined ? undefined : message)
|
|
1086
|
+
: this.getMessage(room, message.run_parent_id);
|
|
1087
|
+
}
|
|
1088
|
+
// harn:end continuation-writer-follows-journaled-output-ownership
|
|
1089
|
+
// harn:assume default-recipient-fallback-chain ref=substantive-default-recipient
|
|
1090
|
+
latestFinalizedAgentAuthor(room) {
|
|
1091
|
+
const row = this.db.prepare(`SELECT messages.author
|
|
1092
|
+
FROM messages
|
|
1093
|
+
JOIN members ON members.room = messages.room AND members.id = messages.author
|
|
1094
|
+
WHERE messages.room = ?
|
|
1095
|
+
AND messages.kind = 'run'
|
|
1096
|
+
AND members.kind = 'agent'
|
|
1097
|
+
AND members.removed_ts IS NULL
|
|
1098
|
+
AND messages.ack = 0
|
|
1099
|
+
AND json_extract(messages.run, '$.status') <> 'running'
|
|
1100
|
+
ORDER BY messages.id DESC
|
|
1101
|
+
LIMIT 1`).get(room);
|
|
1102
|
+
return row?.author;
|
|
1103
|
+
}
|
|
1104
|
+
// harn:end default-recipient-fallback-chain
|
|
1105
|
+
listMessagesAfter(room, after, limit = 100) {
|
|
1106
|
+
const rows = this.db.prepare(`SELECT * FROM messages WHERE room = ? AND id > ? ORDER BY id ASC LIMIT ?`).all(room, after, limit);
|
|
1107
|
+
return rows.map(messageFromRow);
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* In-place update of a message (run finalization: body becomes final_text,
|
|
1111
|
+
* mentions/refs re-parsed, run summary updated). Same id, new seq.
|
|
1112
|
+
*/
|
|
1113
|
+
updateMessage(room, id, patch, options = {}) {
|
|
1114
|
+
return this.db.transaction(() => {
|
|
1115
|
+
const existing = this.getMessage(room, id);
|
|
1116
|
+
if (!existing)
|
|
1117
|
+
throw new Error(`no such message: #${id}`);
|
|
1118
|
+
const seq = this.appendChange(room, 'message', String(id));
|
|
1119
|
+
const merged = MessageSchema.parse({ ...existing, ...patch, seq });
|
|
1120
|
+
// harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
|
|
1121
|
+
const storedActivity = this.db.prepare('SELECT activity_seq FROM messages WHERE room = ? AND id = ?').get(room, id);
|
|
1122
|
+
const activitySeq = nextActivitySeq(merged, options.activity ?? 'auto', seq, storedActivity.activity_seq);
|
|
1123
|
+
this.db
|
|
1124
|
+
.prepare(`UPDATE messages SET body = ?, mentions = ?, refs = ?, ledger_refs = ?,
|
|
1125
|
+
run = ?, ask = ?, ack = ?, seq = ?, activity_seq = ?
|
|
1126
|
+
WHERE room = ? AND id = ?`)
|
|
1127
|
+
.run(merged.body, JSON.stringify(merged.mentions), JSON.stringify(merged.refs), JSON.stringify(merged.ledger_refs), jsonOrNull(merged.run), jsonOrNull(merged.ask), fromBool(merged.ack === true), seq, activitySeq, room, id);
|
|
1128
|
+
// harn:end substantive-output-messages-drive-unread
|
|
1129
|
+
return merged;
|
|
1130
|
+
})();
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Flip a message's pinned flag through the change log, so live frames and
|
|
1134
|
+
* reconnect sync both carry it. Same id, new seq; nothing else on the row
|
|
1135
|
+
* moves.
|
|
1136
|
+
*/
|
|
1137
|
+
setMessagePinned(room, id, pinned) {
|
|
1138
|
+
return this.db.transaction(() => {
|
|
1139
|
+
const existing = this.getMessage(room, id);
|
|
1140
|
+
if (!existing)
|
|
1141
|
+
throw new Error(`no such message: #${id}`);
|
|
1142
|
+
const seq = this.appendChange(room, 'message', String(id));
|
|
1143
|
+
const merged = MessageSchema.parse({ ...existing, pinned: pinned || undefined, seq });
|
|
1144
|
+
this.db
|
|
1145
|
+
.prepare('UPDATE messages SET pinned = ?, seq = ? WHERE room = ? AND id = ?')
|
|
1146
|
+
.run(fromBool(pinned), seq, room, id);
|
|
1147
|
+
return merged;
|
|
1148
|
+
})();
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Purge a message in place through the change log: body emptied, payload
|
|
1152
|
+
* columns nulled, pin cleared (a tombstone cannot stay pinned), the deleted
|
|
1153
|
+
* flag set. Same id and new seq keep ordering, attribution, and permalinks
|
|
1154
|
+
* coherent; the purge is irreversible.
|
|
1155
|
+
*/
|
|
1156
|
+
deleteMessage(room, id) {
|
|
1157
|
+
return this.db.transaction(() => {
|
|
1158
|
+
const existing = this.getMessage(room, id);
|
|
1159
|
+
if (!existing)
|
|
1160
|
+
throw new Error(`no such message: #${id}`);
|
|
1161
|
+
const seq = this.appendChange(room, 'message', String(id));
|
|
1162
|
+
const merged = MessageSchema.parse({
|
|
1163
|
+
...existing,
|
|
1164
|
+
body: '',
|
|
1165
|
+
mentions: [],
|
|
1166
|
+
refs: [],
|
|
1167
|
+
ledger_refs: [],
|
|
1168
|
+
ask: undefined,
|
|
1169
|
+
origin: undefined,
|
|
1170
|
+
attachments: undefined,
|
|
1171
|
+
pinned: undefined,
|
|
1172
|
+
deleted: true,
|
|
1173
|
+
seq,
|
|
1174
|
+
});
|
|
1175
|
+
this.db
|
|
1176
|
+
.prepare(`UPDATE messages SET body = '', mentions = '[]', refs = '[]', ledger_refs = '[]',
|
|
1177
|
+
ask = NULL, origin = NULL, attachments = NULL, pinned = 0, deleted = 1, seq = ?
|
|
1178
|
+
WHERE room = ? AND id = ?`)
|
|
1179
|
+
.run(seq, room, id);
|
|
1180
|
+
return merged;
|
|
1181
|
+
})();
|
|
1182
|
+
}
|
|
1183
|
+
getMessage(room, id) {
|
|
1184
|
+
const row = this.db
|
|
1185
|
+
.prepare('SELECT * FROM messages WHERE room = ? AND id = ?')
|
|
1186
|
+
.get(room, id);
|
|
1187
|
+
return row ? messageFromRow(row) : undefined;
|
|
1188
|
+
}
|
|
1189
|
+
/** Every pinned message, id-ascending — the strip hydrates from this whole
|
|
1190
|
+
* set (pins are few) so a pin older than the loaded page still shows. */
|
|
1191
|
+
listPinnedMessages(room) {
|
|
1192
|
+
const rows = this.db
|
|
1193
|
+
.prepare('SELECT * FROM messages WHERE room = ? AND pinned = 1 ORDER BY id')
|
|
1194
|
+
.all(room);
|
|
1195
|
+
return rows.map(messageFromRow);
|
|
1196
|
+
}
|
|
1197
|
+
/** Engine-reported context window, persisted so gauge estimates outlive
|
|
1198
|
+
* restarts (daemon-internal; not part of the protocol Member shape). */
|
|
1199
|
+
getMemberContextWindow(room, id) {
|
|
1200
|
+
const row = this.db
|
|
1201
|
+
.prepare('SELECT context_window FROM members WHERE room = ? AND id = ?')
|
|
1202
|
+
.get(room, id);
|
|
1203
|
+
return row?.context_window ?? undefined;
|
|
1204
|
+
}
|
|
1205
|
+
setMemberContextWindow(room, id, contextWindow) {
|
|
1206
|
+
this.db
|
|
1207
|
+
.prepare('UPDATE members SET context_window = ? WHERE room = ? AND id = ?')
|
|
1208
|
+
.run(contextWindow, room, id);
|
|
1209
|
+
}
|
|
1210
|
+
// harn:assume durable-room-summaries-stream-and-fallback ref=durable-room-summary
|
|
1211
|
+
/** Newest message in a room — the rail preview's single source. */
|
|
1212
|
+
latestMessage(room, options = {}) {
|
|
1213
|
+
const row = this.db
|
|
1214
|
+
.prepare(`SELECT * FROM messages
|
|
1215
|
+
WHERE room = ? AND (? = 0 OR ack = 0)
|
|
1216
|
+
ORDER BY id DESC LIMIT 1`)
|
|
1217
|
+
.get(room, options.ignoreAcks ? 1 : 0);
|
|
1218
|
+
return row ? messageFromRow(row) : undefined;
|
|
1219
|
+
}
|
|
1220
|
+
/** Legacy caller-cursor arithmetic retained until every client opts into
|
|
1221
|
+
* durable room support. */
|
|
1222
|
+
countMessagesAfter(room, afterId) {
|
|
1223
|
+
const row = this.db
|
|
1224
|
+
.prepare('SELECT COUNT(*) AS n FROM messages WHERE room = ? AND id > ?')
|
|
1225
|
+
.get(room, afterId);
|
|
1226
|
+
return row.n;
|
|
1227
|
+
}
|
|
1228
|
+
// harn:end durable-room-summaries-stream-and-fallback
|
|
1229
|
+
// harn:assume human-room-read-cursors-are-durable-and-monotonic ref=durable-room-read-storage
|
|
1230
|
+
getRoomReadSeq(room, memberId) {
|
|
1231
|
+
const member = this.getMember(room, memberId);
|
|
1232
|
+
if (member?.kind !== 'human')
|
|
1233
|
+
throw new Error(`no human member ${memberId} in ${room}`);
|
|
1234
|
+
const row = this.db.prepare('SELECT read_seq FROM room_read_cursors WHERE room = ? AND member_id = ?').get(room, memberId);
|
|
1235
|
+
if (!row)
|
|
1236
|
+
throw new Error(`human member ${memberId} has no room read cursor`);
|
|
1237
|
+
return row.read_seq;
|
|
1238
|
+
}
|
|
1239
|
+
markRoomRead(room, memberId, throughSeq) {
|
|
1240
|
+
return this.db.transaction(() => {
|
|
1241
|
+
if (!Number.isInteger(throughSeq) || throughSeq < 0) {
|
|
1242
|
+
throw new Error('room read seq must be a non-negative integer');
|
|
1243
|
+
}
|
|
1244
|
+
const roomSeq = this.currentSeq(room);
|
|
1245
|
+
if (throughSeq > roomSeq) {
|
|
1246
|
+
throw new Error(`room read seq ${throughSeq} is ahead of current seq ${roomSeq}`);
|
|
1247
|
+
}
|
|
1248
|
+
const previous = this.getRoomReadSeq(room, memberId);
|
|
1249
|
+
const readSeq = Math.max(previous, throughSeq);
|
|
1250
|
+
if (readSeq > previous) {
|
|
1251
|
+
this.db.prepare(`UPDATE room_read_cursors SET read_seq = ?, updated_ts = ?
|
|
1252
|
+
WHERE room = ? AND member_id = ?`).run(readSeq, new Date().toISOString(), room, memberId);
|
|
1253
|
+
}
|
|
1254
|
+
const candidates = this.db.prepare(`SELECT deliveries.id
|
|
1255
|
+
FROM deliveries
|
|
1256
|
+
JOIN messages ON messages.room = deliveries.room
|
|
1257
|
+
AND messages.id = deliveries.message_id
|
|
1258
|
+
WHERE deliveries.room = ?
|
|
1259
|
+
AND deliveries.recipient = ?
|
|
1260
|
+
AND deliveries.state = 'consumed'
|
|
1261
|
+
AND deliveries.read_ts IS NULL
|
|
1262
|
+
AND messages.seq <= ?
|
|
1263
|
+
ORDER BY deliveries.queue_seq, deliveries.id`).all(room, memberId, readSeq);
|
|
1264
|
+
const readTs = new Date().toISOString();
|
|
1265
|
+
const deliveries = candidates.map(({ id }) => this.updateDelivery(room, id, { read_ts: readTs }));
|
|
1266
|
+
return { read_seq: readSeq, deliveries };
|
|
1267
|
+
})();
|
|
1268
|
+
}
|
|
1269
|
+
// harn:end human-room-read-cursors-are-durable-and-monotonic
|
|
1270
|
+
// harn:assume substantive-output-messages-drive-unread ref=message-activity-storage
|
|
1271
|
+
countUnreadMessages(room, memberId) {
|
|
1272
|
+
const readSeq = this.getRoomReadSeq(room, memberId);
|
|
1273
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n
|
|
1274
|
+
FROM messages
|
|
1275
|
+
WHERE room = ?
|
|
1276
|
+
AND deleted = 0
|
|
1277
|
+
AND activity_seq IS NOT NULL
|
|
1278
|
+
AND activity_seq > ?
|
|
1279
|
+
AND author <> ?`).get(room, readSeq, memberId);
|
|
1280
|
+
return row.n;
|
|
1281
|
+
}
|
|
1282
|
+
// harn:end substantive-output-messages-drive-unread
|
|
1283
|
+
// harn:assume room-support-is-bounded-recipient-scoped-state ref=room-support-projection
|
|
1284
|
+
// harn:assume actionable-inbox-clears-on-read-or-reply ref=actionable-inbox-projection
|
|
1285
|
+
actionableInbox(room, memberId) {
|
|
1286
|
+
const rows = this.db.prepare(`SELECT deliveries.*
|
|
1287
|
+
FROM deliveries
|
|
1288
|
+
JOIN messages ON messages.room = deliveries.room
|
|
1289
|
+
AND messages.id = deliveries.message_id
|
|
1290
|
+
WHERE deliveries.room = ?
|
|
1291
|
+
AND deliveries.recipient = ?
|
|
1292
|
+
AND deliveries.state = 'consumed'
|
|
1293
|
+
AND deliveries.read_ts IS NULL
|
|
1294
|
+
AND messages.deleted = 0
|
|
1295
|
+
AND (
|
|
1296
|
+
messages.kind IN ('ask', 'approval')
|
|
1297
|
+
OR EXISTS (
|
|
1298
|
+
SELECT 1 FROM json_each(messages.mentions) AS mention
|
|
1299
|
+
WHERE json_extract(mention.value, '$.member_id') = ?
|
|
1300
|
+
)
|
|
1301
|
+
)
|
|
1302
|
+
AND NOT EXISTS (
|
|
1303
|
+
SELECT 1 FROM messages AS reply
|
|
1304
|
+
WHERE reply.room = messages.room
|
|
1305
|
+
AND reply.reply_to = messages.id
|
|
1306
|
+
AND reply.author = ?
|
|
1307
|
+
AND reply.deleted = 0
|
|
1308
|
+
)
|
|
1309
|
+
ORDER BY deliveries.queue_seq DESC, deliveries.id DESC`).all(room, memberId, memberId, memberId);
|
|
1310
|
+
return rows.flatMap((row) => {
|
|
1311
|
+
const delivery = deliveryFromRow(row);
|
|
1312
|
+
const message = this.getMessage(room, delivery.message_id);
|
|
1313
|
+
if (!message)
|
|
1314
|
+
return [];
|
|
1315
|
+
const author = this.getMember(room, message.author);
|
|
1316
|
+
if (!author)
|
|
1317
|
+
return [];
|
|
1318
|
+
return [{
|
|
1319
|
+
delivery,
|
|
1320
|
+
author_id: author.id,
|
|
1321
|
+
author_handle: author.handle,
|
|
1322
|
+
author_kind: author.kind,
|
|
1323
|
+
message_kind: message.kind,
|
|
1324
|
+
preview: (message.body.split('\n', 1)[0] ?? '').slice(0, 140),
|
|
1325
|
+
ts: message.ts,
|
|
1326
|
+
}];
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
// harn:end actionable-inbox-clears-on-read-or-reply
|
|
1330
|
+
roomSupport(room, memberId) {
|
|
1331
|
+
const viewer = this.getMember(room, memberId);
|
|
1332
|
+
if (viewer?.kind !== 'human')
|
|
1333
|
+
throw new Error(`no human member ${memberId} in ${room}`);
|
|
1334
|
+
const roomRow = this.getRoom(room);
|
|
1335
|
+
if (!roomRow)
|
|
1336
|
+
throw new Error(`no such room: ${room}`);
|
|
1337
|
+
const members = this.listMembers(room);
|
|
1338
|
+
const latest = this.latestMessage(room, { ignoreAcks: true });
|
|
1339
|
+
const latestRun = this.listRunMessages(room, { limit: 1 })[0];
|
|
1340
|
+
const latestAuthor = latest ? this.getMember(room, latest.author) : undefined;
|
|
1341
|
+
const latestRunAuthor = latestRun
|
|
1342
|
+
? this.getMember(room, latestRun.author)
|
|
1343
|
+
: undefined;
|
|
1344
|
+
const working = members.some((member) => member.kind === 'agent'
|
|
1345
|
+
&& (member.state === 'running' || member.state === 'queued'));
|
|
1346
|
+
const activeRows = this.db.prepare(`SELECT * FROM messages
|
|
1347
|
+
WHERE room = ? AND deleted = 0 AND kind = 'run'
|
|
1348
|
+
AND json_extract(run, '$.status') = 'running'
|
|
1349
|
+
ORDER BY id`).all(room);
|
|
1350
|
+
const interactionRows = this.db.prepare(`SELECT messages.*
|
|
1351
|
+
FROM pending_interactions
|
|
1352
|
+
JOIN messages ON messages.room = pending_interactions.room
|
|
1353
|
+
AND messages.id = pending_interactions.message_id
|
|
1354
|
+
WHERE pending_interactions.room = ?
|
|
1355
|
+
AND pending_interactions.state = 'pending'
|
|
1356
|
+
AND messages.deleted = 0
|
|
1357
|
+
AND EXISTS (
|
|
1358
|
+
SELECT 1 FROM json_each(pending_interactions.targets) AS target
|
|
1359
|
+
WHERE target.value = ?
|
|
1360
|
+
)
|
|
1361
|
+
ORDER BY messages.id`).all(room, memberId);
|
|
1362
|
+
return RoomSupportSchema.parse({
|
|
1363
|
+
room,
|
|
1364
|
+
summary: {
|
|
1365
|
+
id: roomRow.id,
|
|
1366
|
+
name: roomRow.name,
|
|
1367
|
+
created_ts: roomRow.created_ts,
|
|
1368
|
+
color: roomRow.config.color,
|
|
1369
|
+
working,
|
|
1370
|
+
attention: !working
|
|
1371
|
+
&& latestRun?.run?.status === 'failed'
|
|
1372
|
+
&& latestRunAuthor?.kind === 'agent'
|
|
1373
|
+
&& latestRunAuthor.state === 'dead',
|
|
1374
|
+
...(latest !== undefined && {
|
|
1375
|
+
latest: {
|
|
1376
|
+
id: latest.id,
|
|
1377
|
+
ts: latest.ts,
|
|
1378
|
+
kind: latest.kind,
|
|
1379
|
+
author_handle: latestAuthor?.handle ?? '',
|
|
1380
|
+
author_kind: latestAuthor?.kind ?? 'human',
|
|
1381
|
+
preview: (latest.body.split('\n', 1)[0] ?? '').slice(0, 140),
|
|
1382
|
+
},
|
|
1383
|
+
}),
|
|
1384
|
+
unread: this.countUnreadMessages(room, memberId),
|
|
1385
|
+
},
|
|
1386
|
+
latest_finalized_agent_id: this.latestFinalizedAgentAuthor(room),
|
|
1387
|
+
active_runs: activeRows.map(messageFromRow),
|
|
1388
|
+
interactions: interactionRows.map(messageFromRow),
|
|
1389
|
+
inbox: this.actionableInbox(room, memberId),
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
// harn:end room-support-is-bounded-recipient-scoped-state
|
|
1393
|
+
// harn:assume permalink-ids-stable ref=message-history-search
|
|
1394
|
+
listMessages(room, opts = {}) {
|
|
1395
|
+
const rows = this.db
|
|
1396
|
+
.prepare(`SELECT * FROM messages WHERE room = ? AND id < ?
|
|
1397
|
+
ORDER BY id DESC LIMIT ?`)
|
|
1398
|
+
.all(room, opts.before ?? Number.MAX_SAFE_INTEGER, opts.limit ?? 100);
|
|
1399
|
+
return rows.reverse().map(messageFromRow);
|
|
1400
|
+
}
|
|
1401
|
+
// harn:assume run-evidence-search-is-bounded-and-redacted ref=bounded-run-message-listing
|
|
1402
|
+
listRunMessages(room, opts = {}) {
|
|
1403
|
+
const rows = this.db
|
|
1404
|
+
.prepare(`SELECT * FROM messages
|
|
1405
|
+
WHERE room = ? AND kind = 'run' AND run IS NOT NULL
|
|
1406
|
+
AND (? IS NULL OR author = ?)
|
|
1407
|
+
ORDER BY id DESC LIMIT ?`)
|
|
1408
|
+
.all(room, opts.author ?? null, opts.author ?? null, opts.limit ?? 50);
|
|
1409
|
+
return rows.map(messageFromRow);
|
|
1410
|
+
}
|
|
1411
|
+
// harn:end run-evidence-search-is-bounded-and-redacted
|
|
1412
|
+
// harn:assume member-status-is-bounded-and-identity-safe ref=bounded-live-post-listing
|
|
1413
|
+
listChatMessagesByAuthorWithin(room, author, startedTs, endedTs, limit = 5) {
|
|
1414
|
+
const rows = this.db
|
|
1415
|
+
.prepare(`SELECT * FROM messages
|
|
1416
|
+
WHERE room = ? AND author = ? AND kind = 'chat' AND ts >= ?
|
|
1417
|
+
AND (? IS NULL OR ts <= ?)
|
|
1418
|
+
ORDER BY id DESC LIMIT ?`)
|
|
1419
|
+
.all(room, author, startedTs, endedTs ?? null, endedTs ?? null, limit);
|
|
1420
|
+
return rows.map(messageFromRow);
|
|
1421
|
+
}
|
|
1422
|
+
// harn:end member-status-is-bounded-and-identity-safe
|
|
1423
|
+
// harn:assume search-does-not-reveal-redacted-text ref=redacted-message-search-match
|
|
1424
|
+
searchMessages(room, query, opts = {}) {
|
|
1425
|
+
const limit = opts.limit ?? 50;
|
|
1426
|
+
if (this.getRoom(room)?.config.redaction_enabled !== false) {
|
|
1427
|
+
const needle = query.toLowerCase();
|
|
1428
|
+
const rows = this.db
|
|
1429
|
+
.prepare('SELECT * FROM messages WHERE room = ? ORDER BY id DESC')
|
|
1430
|
+
.all(room);
|
|
1431
|
+
return rows
|
|
1432
|
+
.map(messageFromRow)
|
|
1433
|
+
.filter((message) => redactText(message.body).toLowerCase().includes(needle))
|
|
1434
|
+
.slice(0, limit);
|
|
1435
|
+
}
|
|
1436
|
+
const literal = query.replace(/[\\%_]/g, '\\$&');
|
|
1437
|
+
const rows = this.db
|
|
1438
|
+
.prepare(`SELECT * FROM messages
|
|
1439
|
+
WHERE room = ? AND body LIKE ? ESCAPE '\\'
|
|
1440
|
+
ORDER BY id DESC LIMIT ?`)
|
|
1441
|
+
.all(room, `%${literal}%`, limit);
|
|
1442
|
+
return rows.map(messageFromRow);
|
|
1443
|
+
}
|
|
1444
|
+
// harn:end search-does-not-reveal-redacted-text
|
|
1445
|
+
// harn:end permalink-ids-stable
|
|
1446
|
+
// ── deliveries ────────────────────────────────────────────────────────
|
|
1447
|
+
// harn:assume collaboration-groups-are-durable-state ref=collaboration-store-projection
|
|
1448
|
+
createDelivery(room, delivery) {
|
|
1449
|
+
return this.db.transaction(() => {
|
|
1450
|
+
const nextQueueSeq = this.db
|
|
1451
|
+
.prepare('SELECT COALESCE(MAX(queue_seq), 0) + 1 AS seq FROM deliveries WHERE room = ?')
|
|
1452
|
+
.get(room);
|
|
1453
|
+
const validated = DeliverySchema.parse({
|
|
1454
|
+
id: randomUUID(),
|
|
1455
|
+
room,
|
|
1456
|
+
message_id: delivery.message_id,
|
|
1457
|
+
recipient: delivery.recipient,
|
|
1458
|
+
state: delivery.state ?? 'queued',
|
|
1459
|
+
hop_count: delivery.hop_count ?? 0,
|
|
1460
|
+
group_id: delivery.group_id,
|
|
1461
|
+
group_round: delivery.group_round,
|
|
1462
|
+
ts: new Date().toISOString(),
|
|
1463
|
+
});
|
|
1464
|
+
this.db
|
|
1465
|
+
.prepare(`INSERT INTO deliveries (id, room, message_id, recipient, state, attempt_count,
|
|
1466
|
+
batch_id, run_msg_id, read_ts, interaction_resolved_ts, payload_snapshot,
|
|
1467
|
+
process_id, process_group_id, hop_count, queue_seq, group_id, group_round, ts)
|
|
1468
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1469
|
+
.run(validated.id, room, validated.message_id, validated.recipient, validated.state, validated.attempt_count, orNull(validated.batch_id), orNull(validated.run_msg_id), orNull(validated.read_ts), orNull(validated.interaction_resolved_ts), orNull(delivery.payload_snapshot), null, null, validated.hop_count ?? 0, nextQueueSeq.seq, orNull(validated.group_id), orNull(validated.group_round), validated.ts);
|
|
1470
|
+
// Human inbox records are client-visible; recipient kind decides.
|
|
1471
|
+
const recipient = this.getMember(room, validated.recipient);
|
|
1472
|
+
if (recipient?.kind === 'human' || validated.state === 'held') {
|
|
1473
|
+
this.appendChange(room, 'inbox', validated.id);
|
|
1474
|
+
}
|
|
1475
|
+
return validated;
|
|
1476
|
+
})();
|
|
1477
|
+
}
|
|
1478
|
+
updateDelivery(room, deliveryId, patch) {
|
|
1479
|
+
return this.db.transaction(() => {
|
|
1480
|
+
const existing = this.getDelivery(room, deliveryId);
|
|
1481
|
+
if (!existing)
|
|
1482
|
+
throw new Error(`no such delivery: ${deliveryId}`);
|
|
1483
|
+
const merged = DeliverySchema.parse({ ...existing, ...patch });
|
|
1484
|
+
this.db
|
|
1485
|
+
.prepare(`UPDATE deliveries SET state = ?, attempt_count = ?, batch_id = ?,
|
|
1486
|
+
run_msg_id = ?, read_ts = ?, interaction_resolved_ts = ?
|
|
1487
|
+
WHERE room = ? AND id = ?`)
|
|
1488
|
+
.run(merged.state, merged.attempt_count, orNull(merged.batch_id), orNull(merged.run_msg_id), orNull(merged.read_ts), orNull(merged.interaction_resolved_ts), room, deliveryId);
|
|
1489
|
+
const recipient = this.getMember(room, merged.recipient);
|
|
1490
|
+
// Client-visible inbox records: human deliveries always; agent
|
|
1491
|
+
// deliveries once they need operator attention (held ⇄ released).
|
|
1492
|
+
if (recipient?.kind === 'human' || merged.state === 'held' || existing.state === 'held') {
|
|
1493
|
+
this.appendChange(room, 'inbox', deliveryId);
|
|
1494
|
+
}
|
|
1495
|
+
return merged;
|
|
1496
|
+
})();
|
|
1497
|
+
}
|
|
1498
|
+
getDelivery(room, deliveryId) {
|
|
1499
|
+
const row = this.db
|
|
1500
|
+
.prepare('SELECT * FROM deliveries WHERE room = ? AND id = ?')
|
|
1501
|
+
.get(room, deliveryId);
|
|
1502
|
+
return row ? deliveryFromRow(row) : undefined;
|
|
1503
|
+
}
|
|
1504
|
+
// harn:assume live-delivery-consumption-is-idempotent ref=consume-queued-transaction
|
|
1505
|
+
consumeQueuedDelivery(room, deliveryId, recipientId) {
|
|
1506
|
+
return this.db.transaction(() => {
|
|
1507
|
+
const existing = this.getDelivery(room, deliveryId);
|
|
1508
|
+
if (!existing)
|
|
1509
|
+
throw new Error(`no such delivery: ${deliveryId}`);
|
|
1510
|
+
if (existing.recipient !== recipientId) {
|
|
1511
|
+
throw new Error(`delivery ${deliveryId} is not addressed to member ${recipientId}`);
|
|
1512
|
+
}
|
|
1513
|
+
this.db
|
|
1514
|
+
.prepare(`UPDATE deliveries SET state = 'consumed'
|
|
1515
|
+
WHERE room = ? AND id = ? AND recipient = ? AND state = 'queued'`)
|
|
1516
|
+
.run(room, deliveryId, recipientId);
|
|
1517
|
+
const delivery = this.getDelivery(room, deliveryId);
|
|
1518
|
+
const message = this.getMessage(room, delivery.message_id);
|
|
1519
|
+
if (!message)
|
|
1520
|
+
throw new Error(`delivery ${deliveryId} has no source message`);
|
|
1521
|
+
return { delivery, message };
|
|
1522
|
+
})();
|
|
1523
|
+
}
|
|
1524
|
+
// harn:end live-delivery-consumption-is-idempotent
|
|
1525
|
+
getDeliveryPayloadSnapshot(room, deliveryId) {
|
|
1526
|
+
const row = this.db
|
|
1527
|
+
.prepare('SELECT payload_snapshot FROM deliveries WHERE room = ? AND id = ?')
|
|
1528
|
+
.get(room, deliveryId);
|
|
1529
|
+
return row?.payload_snapshot ?? undefined;
|
|
1530
|
+
}
|
|
1531
|
+
// harn:assume attempt-start-evidence-persisted ref=attempt-process-evidence
|
|
1532
|
+
/** Internal process evidence for one delivery attempt; not client-visible. */
|
|
1533
|
+
getDeliveryAttemptProcess(room, deliveryId) {
|
|
1534
|
+
const row = this.db
|
|
1535
|
+
.prepare('SELECT process_id, process_group_id FROM deliveries WHERE room = ? AND id = ?')
|
|
1536
|
+
.get(room, deliveryId);
|
|
1537
|
+
if (!row || (row.process_id === null && row.process_group_id === null))
|
|
1538
|
+
return undefined;
|
|
1539
|
+
return {
|
|
1540
|
+
...(row.process_id !== null && { pid: row.process_id }),
|
|
1541
|
+
...(row.process_group_id !== null && { process_group_id: row.process_group_id }),
|
|
1542
|
+
};
|
|
1543
|
+
}
|
|
1544
|
+
setDeliveryAttemptProcess(room, deliveryIds, process) {
|
|
1545
|
+
this.db.transaction(() => {
|
|
1546
|
+
const update = this.db.prepare(`UPDATE deliveries SET process_id = ?, process_group_id = ?
|
|
1547
|
+
WHERE room = ? AND id = ?`);
|
|
1548
|
+
for (const deliveryId of deliveryIds) {
|
|
1549
|
+
const result = update.run(process?.pid ?? null, process?.process_group_id ?? null, room, deliveryId);
|
|
1550
|
+
if (result.changes !== 1)
|
|
1551
|
+
throw new Error(`no such delivery: ${deliveryId}`);
|
|
1552
|
+
}
|
|
1553
|
+
})();
|
|
1554
|
+
}
|
|
1555
|
+
// harn:end attempt-start-evidence-persisted
|
|
1556
|
+
// harn:assume delivery-fifo-has-durable-sequence ref=delivery-queue-sequence
|
|
1557
|
+
listDeliveries(room, filter = {}) {
|
|
1558
|
+
const rows = this.db
|
|
1559
|
+
.prepare(`SELECT * FROM deliveries WHERE room = ?
|
|
1560
|
+
AND (? IS NULL OR recipient = ?)
|
|
1561
|
+
AND (? IS NULL OR state = ?)
|
|
1562
|
+
ORDER BY queue_seq, id`)
|
|
1563
|
+
.all(room, filter.recipient ?? null, filter.recipient ?? null, filter.state ?? null, filter.state ?? null);
|
|
1564
|
+
return rows.map(deliveryFromRow);
|
|
1565
|
+
}
|
|
1566
|
+
// harn:end delivery-fifo-has-durable-sequence
|
|
1567
|
+
// harn:assume turn-start-transactional ref=atomic-turn-start
|
|
1568
|
+
/** Creates/reuses the run and binds the complete batch in one transaction. */
|
|
1569
|
+
beginTurn(room, opts) {
|
|
1570
|
+
return this.db.transaction(() => {
|
|
1571
|
+
// harn:assume only-an-admissible-delivery-becomes-delivering ref=turn-admission-guard
|
|
1572
|
+
// Admission is decided HERE, inside the transaction that binds them — not by a
|
|
1573
|
+
// filter that ran in an earlier statement. Between selecting a delivery and
|
|
1574
|
+
// admitting it, anything may have consumed it: the end of a turn, the removal of
|
|
1575
|
+
// the member, or (in live-collab) the agent itself. Trusting the earlier filter is
|
|
1576
|
+
// how a consumed delivery gets resurrected and handed to an agent as work.
|
|
1577
|
+
//
|
|
1578
|
+
// A FRESH turn admits only what is `queued`. Nothing else may be swept into it: a
|
|
1579
|
+
// held delivery stays held, and a consumed one is gone.
|
|
1580
|
+
//
|
|
1581
|
+
// A REUSED run — a reconciled retry, or an ambiguous turn the operator has just
|
|
1582
|
+
// released — re-admits the deliveries ALREADY BOUND TO THAT RUN, whatever state the
|
|
1583
|
+
// interruption left them in: `delivering` after a crash, `held` after an ambiguous
|
|
1584
|
+
// turn was parked. They are not being swept in; this run already claimed them, and
|
|
1585
|
+
// the operator asked for the retry. Restricting admission to `queued` alone would
|
|
1586
|
+
// silently kill both crash recovery and release_hold.
|
|
1587
|
+
//
|
|
1588
|
+
// `consumed` is admissible in NO case. That is the whole point: it is the state a
|
|
1589
|
+
// delivery reaches when its work is done, or when the member it was addressed to no
|
|
1590
|
+
// longer exists, and resurrecting it hands an agent work that was already taken.
|
|
1591
|
+
const boundToReusedRun = (delivery) => opts.reuseRunMsgId !== undefined && delivery.run_msg_id === opts.reuseRunMsgId;
|
|
1592
|
+
const admissible = opts.deliveryIds
|
|
1593
|
+
.map((deliveryId) => {
|
|
1594
|
+
const delivery = this.getDelivery(room, deliveryId);
|
|
1595
|
+
if (!delivery)
|
|
1596
|
+
throw new Error(`no such delivery: ${deliveryId}`);
|
|
1597
|
+
if (delivery.recipient !== opts.memberId) {
|
|
1598
|
+
throw new Error(`delivery ${deliveryId} does not belong to member ${opts.memberId}`);
|
|
1599
|
+
}
|
|
1600
|
+
return delivery;
|
|
1601
|
+
})
|
|
1602
|
+
.filter((delivery) => delivery.state !== 'consumed' &&
|
|
1603
|
+
(delivery.state === 'queued' || boundToReusedRun(delivery)));
|
|
1604
|
+
// harn:assume unresolved-delivery-fences-fresh-member-turns ref=durable-delivery-turn-fence
|
|
1605
|
+
// The in-memory inflight guard cannot survive a process boundary. Refuse a
|
|
1606
|
+
// FRESH run while this member still owns another durably active attempt;
|
|
1607
|
+
// runtime/boot reconciliation moves that attempt to held or consumed, and
|
|
1608
|
+
// an explicit reused-run recovery is allowed to reclaim its own binding.
|
|
1609
|
+
const ownsUnresolvedAttempt = opts.reuseRunMsgId === undefined && this
|
|
1610
|
+
.listDeliveries(room, { recipient: opts.memberId, state: 'delivering' })
|
|
1611
|
+
.some((delivery) => {
|
|
1612
|
+
if (delivery.run_msg_id === undefined)
|
|
1613
|
+
return false;
|
|
1614
|
+
const run = this.getMessage(room, delivery.run_msg_id);
|
|
1615
|
+
return run?.kind === 'run' && run.run?.status === 'running';
|
|
1616
|
+
});
|
|
1617
|
+
if (ownsUnresolvedAttempt)
|
|
1618
|
+
return undefined;
|
|
1619
|
+
// harn:end unresolved-delivery-fences-fresh-member-turns
|
|
1620
|
+
// harn:assume only-an-admissible-delivery-becomes-delivering ref=turn-start-with-nothing-admissible
|
|
1621
|
+
// Nothing left to say. An empty run message would be a defect of its own, so the
|
|
1622
|
+
// turn does not begin at all — no message, no attempt, and the caller idles the
|
|
1623
|
+
// member.
|
|
1624
|
+
if (admissible.length === 0)
|
|
1625
|
+
return undefined;
|
|
1626
|
+
// harn:end only-an-admissible-delivery-becomes-delivering
|
|
1627
|
+
let runMessage;
|
|
1628
|
+
if (opts.reuseRunMsgId !== undefined) {
|
|
1629
|
+
const existing = this.getMessage(room, opts.reuseRunMsgId);
|
|
1630
|
+
if (!existing?.run ||
|
|
1631
|
+
existing.kind !== 'run' ||
|
|
1632
|
+
existing.author !== opts.memberId ||
|
|
1633
|
+
existing.run.status !== 'running') {
|
|
1634
|
+
throw new Error(`run #${opts.reuseRunMsgId} is not reusable`);
|
|
1635
|
+
}
|
|
1636
|
+
runMessage = existing.run.output_mode === 'messages'
|
|
1637
|
+
? existing
|
|
1638
|
+
: this.updateMessage(room, existing.id, {
|
|
1639
|
+
run: { ...existing.run, output_mode: 'messages' },
|
|
1640
|
+
}, { activity: 'defer' });
|
|
1641
|
+
}
|
|
1642
|
+
else {
|
|
1643
|
+
const posted = this.postMessage(room, { author: opts.memberId, kind: 'run', body: '' });
|
|
1644
|
+
// harn:assume run-cost-estimates-are-finalization-snapshots ref=run-model-snapshot
|
|
1645
|
+
runMessage = this.updateMessage(room, posted.id, {
|
|
1646
|
+
run: {
|
|
1647
|
+
status: 'running',
|
|
1648
|
+
started_ts: opts.startedTs,
|
|
1649
|
+
...(opts.model !== undefined && { model: opts.model }),
|
|
1650
|
+
tool_calls: 0,
|
|
1651
|
+
events_ref: opts.eventsRef(posted.id),
|
|
1652
|
+
output_mode: 'messages',
|
|
1653
|
+
},
|
|
1654
|
+
});
|
|
1655
|
+
// harn:end run-cost-estimates-are-finalization-snapshots
|
|
1656
|
+
}
|
|
1657
|
+
const deliveries = admissible.map((delivery) => {
|
|
1658
|
+
const updated = this.updateDelivery(room, delivery.id, {
|
|
1659
|
+
state: 'delivering',
|
|
1660
|
+
attempt_count: delivery.attempt_count + 1,
|
|
1661
|
+
run_msg_id: runMessage.id,
|
|
1662
|
+
batch_id: `batch-${runMessage.id}`,
|
|
1663
|
+
});
|
|
1664
|
+
this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
|
|
1665
|
+
return updated;
|
|
1666
|
+
});
|
|
1667
|
+
// harn:end only-an-admissible-delivery-becomes-delivering
|
|
1668
|
+
return { runMessage, deliveries };
|
|
1669
|
+
})();
|
|
1670
|
+
}
|
|
1671
|
+
// harn:end turn-start-transactional
|
|
1672
|
+
// harn:assume turn-output-finalization-is-atomic ref=atomic-turn-finalization
|
|
1673
|
+
/** Commits lifecycle truth, every output row, custody, accounting, and fanout together. */
|
|
1674
|
+
completeTurn(room, opts) {
|
|
1675
|
+
return this.db.transaction(() => {
|
|
1676
|
+
const rootOutput = opts.outputs.find((output) => output.id === opts.runMsgId);
|
|
1677
|
+
if (!rootOutput)
|
|
1678
|
+
throw new Error(`turn #${opts.runMsgId} has no root output patch`);
|
|
1679
|
+
const message = this.updateMessage(room, opts.runMsgId, {
|
|
1680
|
+
...opts.message,
|
|
1681
|
+
body: rootOutput.body,
|
|
1682
|
+
mentions: rootOutput.mentions,
|
|
1683
|
+
refs: rootOutput.refs,
|
|
1684
|
+
ledger_refs: rootOutput.ledger_refs,
|
|
1685
|
+
}, { activity: rootOutput.substantive && rootOutput.ack !== true ? 'force' : 'defer' });
|
|
1686
|
+
const outputMessages = [message];
|
|
1687
|
+
for (const output of opts.outputs) {
|
|
1688
|
+
if (output.id === opts.runMsgId)
|
|
1689
|
+
continue;
|
|
1690
|
+
const current = this.getMessage(room, output.id);
|
|
1691
|
+
if (current?.run_parent_id !== opts.runMsgId) {
|
|
1692
|
+
throw new Error(`#${output.id} is not a continuation of turn #${opts.runMsgId}`);
|
|
1693
|
+
}
|
|
1694
|
+
outputMessages.push(this.updateMessage(room, output.id, {
|
|
1695
|
+
body: output.body,
|
|
1696
|
+
mentions: output.mentions,
|
|
1697
|
+
refs: output.refs,
|
|
1698
|
+
ledger_refs: output.ledger_refs,
|
|
1699
|
+
ack: output.ack,
|
|
1700
|
+
}, { activity: output.substantive && output.ack !== true ? 'force' : 'defer' }));
|
|
1701
|
+
}
|
|
1702
|
+
const referenced = new Set(opts.outputs.map((output) => output.id));
|
|
1703
|
+
for (const continuation of this.listRunContinuations(room, opts.runMsgId)) {
|
|
1704
|
+
if (referenced.has(continuation.id) || continuation.deleted === true)
|
|
1705
|
+
continue;
|
|
1706
|
+
outputMessages.push(this.deleteMessage(room, continuation.id));
|
|
1707
|
+
}
|
|
1708
|
+
if (!referenced.has(opts.resultMessageId)) {
|
|
1709
|
+
throw new Error(`turn #${opts.runMsgId} result #${opts.resultMessageId} is not journal-owned`);
|
|
1710
|
+
}
|
|
1711
|
+
for (const deliveryId of opts.inputDeliveryIds) {
|
|
1712
|
+
this.updateDelivery(room, deliveryId, { state: 'consumed' });
|
|
1713
|
+
}
|
|
1714
|
+
const member = this.updateMember(room, opts.memberId, opts.memberPatch);
|
|
1715
|
+
const meter = this.bumpMeter(room, opts.meterDay, opts.meterDelta);
|
|
1716
|
+
const deliveries = opts.fanout.map((delivery) => this.createDelivery(room, {
|
|
1717
|
+
message_id: opts.resultMessageId,
|
|
1718
|
+
recipient: delivery.recipient,
|
|
1719
|
+
state: delivery.state,
|
|
1720
|
+
payload_snapshot: delivery.payload_snapshot,
|
|
1721
|
+
hop_count: delivery.hop_count,
|
|
1722
|
+
}));
|
|
1723
|
+
if (opts.participantTerminal !== undefined) {
|
|
1724
|
+
this.recordCollaborationParticipantTerminal(room, {
|
|
1725
|
+
deliveryId: opts.participantTerminal.deliveryId,
|
|
1726
|
+
status: opts.participantTerminal.status,
|
|
1727
|
+
resultMessageId: opts.resultMessageId,
|
|
1728
|
+
completedTs: opts.participantTerminal.completedTs,
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
const collaboration = opts.collaboration === undefined
|
|
1732
|
+
? undefined
|
|
1733
|
+
: this.createCollaborationGroup(room, {
|
|
1734
|
+
groupId: opts.collaboration.groupId,
|
|
1735
|
+
rootMessageId: opts.resultMessageId,
|
|
1736
|
+
participants: opts.collaboration.participants,
|
|
1737
|
+
});
|
|
1738
|
+
if (collaboration)
|
|
1739
|
+
deliveries.push(...collaboration.deliveries);
|
|
1740
|
+
return {
|
|
1741
|
+
message,
|
|
1742
|
+
outputMessages: outputMessages.sort((left, right) => left.id - right.id),
|
|
1743
|
+
member,
|
|
1744
|
+
meter,
|
|
1745
|
+
deliveries,
|
|
1746
|
+
collaboration,
|
|
1747
|
+
};
|
|
1748
|
+
})();
|
|
1749
|
+
}
|
|
1750
|
+
// harn:end turn-output-finalization-is-atomic
|
|
1751
|
+
getCollaborationGroup(room, groupId) {
|
|
1752
|
+
const row = this.db.prepare('SELECT * FROM collaboration_groups WHERE room = ? AND id = ?').get(room, groupId);
|
|
1753
|
+
return row ? collaborationGroupFromRow(row) : undefined;
|
|
1754
|
+
}
|
|
1755
|
+
getCollaborationGroupByRoot(room, rootMessageId) {
|
|
1756
|
+
const row = this.db.prepare('SELECT * FROM collaboration_groups WHERE room = ? AND root_message_id = ?').get(room, rootMessageId);
|
|
1757
|
+
return row ? collaborationGroupFromRow(row) : undefined;
|
|
1758
|
+
}
|
|
1759
|
+
listCollaborationGroups(room, state) {
|
|
1760
|
+
const rows = this.db.prepare(`SELECT * FROM collaboration_groups
|
|
1761
|
+
WHERE room = ? AND (? IS NULL OR state = ?)
|
|
1762
|
+
ORDER BY created_ts, id`).all(room, state ?? null, state ?? null);
|
|
1763
|
+
return rows.map(collaborationGroupFromRow);
|
|
1764
|
+
}
|
|
1765
|
+
getCollaborationRound(room, groupId, roundNumber) {
|
|
1766
|
+
const row = this.db.prepare(`SELECT collaboration_rounds.* FROM collaboration_rounds
|
|
1767
|
+
JOIN collaboration_groups ON collaboration_groups.id = collaboration_rounds.group_id
|
|
1768
|
+
WHERE collaboration_groups.room = ?
|
|
1769
|
+
AND collaboration_rounds.group_id = ?
|
|
1770
|
+
AND collaboration_rounds.round_number = ?`).get(room, groupId, roundNumber);
|
|
1771
|
+
return row ? collaborationRoundFromRow(row) : undefined;
|
|
1772
|
+
}
|
|
1773
|
+
listCollaborationRounds(room, groupId) {
|
|
1774
|
+
const rows = this.db.prepare(`SELECT collaboration_rounds.* FROM collaboration_rounds
|
|
1775
|
+
JOIN collaboration_groups ON collaboration_groups.id = collaboration_rounds.group_id
|
|
1776
|
+
WHERE collaboration_groups.room = ? AND collaboration_rounds.group_id = ?
|
|
1777
|
+
ORDER BY collaboration_rounds.round_number`).all(room, groupId);
|
|
1778
|
+
return rows.map(collaborationRoundFromRow);
|
|
1779
|
+
}
|
|
1780
|
+
listCollaborationParticipants(room, groupId, roundNumber) {
|
|
1781
|
+
const rows = this.db.prepare(`SELECT collaboration_participants.* FROM collaboration_participants
|
|
1782
|
+
JOIN collaboration_groups ON collaboration_groups.id = collaboration_participants.group_id
|
|
1783
|
+
WHERE collaboration_groups.room = ?
|
|
1784
|
+
AND collaboration_participants.group_id = ?
|
|
1785
|
+
AND collaboration_participants.round_number = ?
|
|
1786
|
+
ORDER BY collaboration_participants.ordinal`).all(room, groupId, roundNumber);
|
|
1787
|
+
return rows.map(collaborationParticipantFromRow);
|
|
1788
|
+
}
|
|
1789
|
+
findCollaborationParticipantByDelivery(room, deliveryId) {
|
|
1790
|
+
const row = this.db.prepare(`SELECT collaboration_participants.* FROM collaboration_participants
|
|
1791
|
+
JOIN collaboration_groups ON collaboration_groups.id = collaboration_participants.group_id
|
|
1792
|
+
WHERE collaboration_groups.room = ? AND collaboration_participants.delivery_id = ?`).get(room, deliveryId);
|
|
1793
|
+
return row ? collaborationParticipantFromRow(row) : undefined;
|
|
1794
|
+
}
|
|
1795
|
+
getCollaborationRoundProjection(room, groupId, roundNumber) {
|
|
1796
|
+
const group = this.getCollaborationGroup(room, groupId);
|
|
1797
|
+
const round = this.getCollaborationRound(room, groupId, roundNumber);
|
|
1798
|
+
if (!group || !round)
|
|
1799
|
+
return undefined;
|
|
1800
|
+
const participants = this.listCollaborationParticipants(room, groupId, roundNumber);
|
|
1801
|
+
const deliveries = participants.map((participant) => {
|
|
1802
|
+
const delivery = this.getDelivery(room, participant.delivery_id);
|
|
1803
|
+
if (!delivery ||
|
|
1804
|
+
delivery.group_id !== groupId ||
|
|
1805
|
+
delivery.group_round !== roundNumber ||
|
|
1806
|
+
delivery.recipient !== participant.member_id) {
|
|
1807
|
+
throw new Error(`invalid collaboration delivery association: ${participant.delivery_id}`);
|
|
1808
|
+
}
|
|
1809
|
+
return delivery;
|
|
1810
|
+
});
|
|
1811
|
+
return { group, round, participants, deliveries };
|
|
1812
|
+
}
|
|
1813
|
+
updateCollaborationGroup(room, groupId, patch) {
|
|
1814
|
+
const existing = this.getCollaborationGroup(room, groupId);
|
|
1815
|
+
if (!existing)
|
|
1816
|
+
throw new Error(`no such collaboration group: ${groupId}`);
|
|
1817
|
+
const merged = CollaborationGroupSchema.parse({ ...existing, ...patch });
|
|
1818
|
+
this.db.prepare('UPDATE collaboration_groups SET state = ?, completed_ts = ? WHERE room = ? AND id = ?').run(merged.state, orNull(merged.completed_ts), room, groupId);
|
|
1819
|
+
return merged;
|
|
1820
|
+
}
|
|
1821
|
+
updateCollaborationRound(room, groupId, roundNumber, patch) {
|
|
1822
|
+
const existing = this.getCollaborationRound(room, groupId, roundNumber);
|
|
1823
|
+
if (!existing)
|
|
1824
|
+
throw new Error(`no such collaboration round: ${groupId}/${roundNumber}`);
|
|
1825
|
+
const merged = CollaborationRoundSchema.parse({ ...existing, ...patch });
|
|
1826
|
+
this.db.prepare(`UPDATE collaboration_rounds SET state = ?, released_ts = ?
|
|
1827
|
+
WHERE group_id = ? AND round_number = ?`).run(merged.state, orNull(merged.released_ts), groupId, roundNumber);
|
|
1828
|
+
return merged;
|
|
1829
|
+
}
|
|
1830
|
+
updateCollaborationParticipant(room, groupId, roundNumber, memberId, patch) {
|
|
1831
|
+
const existing = this.listCollaborationParticipants(room, groupId, roundNumber)
|
|
1832
|
+
.find((participant) => participant.member_id === memberId);
|
|
1833
|
+
if (!existing) {
|
|
1834
|
+
throw new Error(`no such collaboration participant: ${groupId}/${roundNumber}/${memberId}`);
|
|
1835
|
+
}
|
|
1836
|
+
const merged = CollaborationParticipantSchema.parse({ ...existing, ...patch });
|
|
1837
|
+
this.db.prepare(`UPDATE collaboration_participants
|
|
1838
|
+
SET terminal_status = ?, result_message_id = ?, completed_ts = ?
|
|
1839
|
+
WHERE group_id = ? AND round_number = ? AND member_id = ?`).run(orNull(merged.terminal_status), orNull(merged.result_message_id), orNull(merged.completed_ts), groupId, roundNumber, memberId);
|
|
1840
|
+
return merged;
|
|
1841
|
+
}
|
|
1842
|
+
// harn:assume group-participant-terminality-commits-with-the-turn ref=collaboration-turn-finalization
|
|
1843
|
+
recordCollaborationParticipantTerminal(room, opts) {
|
|
1844
|
+
const participant = this.findCollaborationParticipantByDelivery(room, opts.deliveryId);
|
|
1845
|
+
if (!participant)
|
|
1846
|
+
throw new Error(`delivery ${opts.deliveryId} is not a collaboration participant`);
|
|
1847
|
+
if (participant.terminal_status !== undefined) {
|
|
1848
|
+
if (participant.terminal_status !== opts.status ||
|
|
1849
|
+
participant.result_message_id !== opts.resultMessageId) {
|
|
1850
|
+
throw new Error(`collaboration participant ${opts.deliveryId} already has a different result`);
|
|
1851
|
+
}
|
|
1852
|
+
return participant;
|
|
1853
|
+
}
|
|
1854
|
+
return this.updateCollaborationParticipant(room, participant.group_id, participant.round_number, participant.member_id, {
|
|
1855
|
+
terminal_status: opts.status,
|
|
1856
|
+
result_message_id: opts.resultMessageId,
|
|
1857
|
+
completed_ts: opts.completedTs,
|
|
1858
|
+
});
|
|
1859
|
+
}
|
|
1860
|
+
recoverCollaborationParticipantTerminal(room, opts) {
|
|
1861
|
+
return this.db.transaction(() => {
|
|
1862
|
+
const delivery = this.getDelivery(room, opts.deliveryId);
|
|
1863
|
+
if (!delivery)
|
|
1864
|
+
throw new Error(`no such delivery: ${opts.deliveryId}`);
|
|
1865
|
+
const consumed = delivery.state === 'consumed'
|
|
1866
|
+
? delivery
|
|
1867
|
+
: this.updateDelivery(room, delivery.id, { state: 'consumed' });
|
|
1868
|
+
const participant = this.recordCollaborationParticipantTerminal(room, opts);
|
|
1869
|
+
return { delivery: consumed, participant };
|
|
1870
|
+
})();
|
|
1871
|
+
}
|
|
1872
|
+
// harn:end group-participant-terminality-commits-with-the-turn
|
|
1873
|
+
// harn:assume open-collaboration-groups-reconcile-without-resurrection ref=collaboration-member-skip-transaction
|
|
1874
|
+
skipCollaborationParticipant(room, deliveryId, completedTs) {
|
|
1875
|
+
return this.db.transaction(() => {
|
|
1876
|
+
const participant = this.findCollaborationParticipantByDelivery(room, deliveryId);
|
|
1877
|
+
if (!participant)
|
|
1878
|
+
throw new Error(`delivery ${deliveryId} is not a collaboration participant`);
|
|
1879
|
+
const delivery = this.getDelivery(room, deliveryId);
|
|
1880
|
+
if (!delivery)
|
|
1881
|
+
throw new Error(`no such delivery: ${deliveryId}`);
|
|
1882
|
+
if (participant.terminal_status !== undefined)
|
|
1883
|
+
return { delivery, participant };
|
|
1884
|
+
if ((delivery.state !== 'queued' && delivery.state !== 'held' && delivery.state !== 'consumed') ||
|
|
1885
|
+
delivery.run_msg_id !== undefined) {
|
|
1886
|
+
throw new Error(`collaboration delivery ${deliveryId} already started`);
|
|
1887
|
+
}
|
|
1888
|
+
const consumed = delivery.state === 'consumed'
|
|
1889
|
+
? delivery
|
|
1890
|
+
: this.updateDelivery(room, deliveryId, { state: 'consumed' });
|
|
1891
|
+
const skipped = this.updateCollaborationParticipant(room, participant.group_id, participant.round_number, participant.member_id, { terminal_status: 'skipped', completed_ts: completedTs });
|
|
1892
|
+
return { delivery: consumed, participant: skipped };
|
|
1893
|
+
})();
|
|
1894
|
+
}
|
|
1895
|
+
// harn:end open-collaboration-groups-reconcile-without-resurrection
|
|
1896
|
+
// harn:assume collaboration-round-release-is-one-barrier ref=collaboration-round-release-transaction
|
|
1897
|
+
releaseCollaborationRound(room, opts) {
|
|
1898
|
+
return this.db.transaction(() => {
|
|
1899
|
+
const projection = this.getCollaborationRoundProjection(room, opts.groupId, opts.roundNumber);
|
|
1900
|
+
if (!projection) {
|
|
1901
|
+
throw new Error(`no such collaboration round: ${opts.groupId}/${opts.roundNumber}`);
|
|
1902
|
+
}
|
|
1903
|
+
if (projection.round.state !== 'collecting') {
|
|
1904
|
+
return { status: 'already_released', deliveries: [], projection };
|
|
1905
|
+
}
|
|
1906
|
+
if (projection.participants.some((participant) => participant.terminal_status === undefined)) {
|
|
1907
|
+
return { status: 'pending', deliveries: [], projection };
|
|
1908
|
+
}
|
|
1909
|
+
if (opts.nextParticipants.length === 0) {
|
|
1910
|
+
this.updateCollaborationRound(room, opts.groupId, opts.roundNumber, {
|
|
1911
|
+
state: 'closed',
|
|
1912
|
+
released_ts: opts.releasedTs,
|
|
1913
|
+
});
|
|
1914
|
+
this.updateCollaborationGroup(room, opts.groupId, {
|
|
1915
|
+
state: 'completed',
|
|
1916
|
+
completed_ts: opts.releasedTs,
|
|
1917
|
+
});
|
|
1918
|
+
return {
|
|
1919
|
+
status: 'closed',
|
|
1920
|
+
deliveries: [],
|
|
1921
|
+
projection: this.getCollaborationRoundProjection(room, opts.groupId, opts.roundNumber),
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
this.assertCollaborationParticipantInputShape(opts.nextParticipants, 1);
|
|
1925
|
+
this.assertActiveAgentParticipants(room, opts.nextParticipants);
|
|
1926
|
+
const next = this.materializeCollaborationRound(room, projection.group, opts.roundNumber + 1, opts.releasedTs, opts.nextParticipants);
|
|
1927
|
+
this.updateCollaborationRound(room, opts.groupId, opts.roundNumber, {
|
|
1928
|
+
state: 'released',
|
|
1929
|
+
released_ts: opts.releasedTs,
|
|
1930
|
+
});
|
|
1931
|
+
return { status: 'released', deliveries: next.deliveries, projection: next };
|
|
1932
|
+
})();
|
|
1933
|
+
}
|
|
1934
|
+
// harn:end collaboration-round-release-is-one-barrier
|
|
1935
|
+
// harn:assume group-round-creation-is-atomic-and-idempotent ref=collaboration-round-materialization
|
|
1936
|
+
assertCollaborationParticipantInputShape(participants, minimum) {
|
|
1937
|
+
if (participants.length < minimum) {
|
|
1938
|
+
throw new Error(`collaboration round requires at least ${minimum} participant(s)`);
|
|
1939
|
+
}
|
|
1940
|
+
const seen = new Set();
|
|
1941
|
+
for (const participant of participants) {
|
|
1942
|
+
if (seen.has(participant.memberId)) {
|
|
1943
|
+
throw new Error(`duplicate collaboration participant: ${participant.memberId}`);
|
|
1944
|
+
}
|
|
1945
|
+
seen.add(participant.memberId);
|
|
1946
|
+
if (participant.state !== undefined && participant.state !== 'queued' && participant.state !== 'held') {
|
|
1947
|
+
throw new Error(`invalid initial collaboration delivery state: ${participant.state}`);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
assertActiveAgentParticipants(room, participants) {
|
|
1952
|
+
for (const participant of participants) {
|
|
1953
|
+
const member = this.getMember(room, participant.memberId);
|
|
1954
|
+
if (!member || member.kind !== 'agent' || member.removed_ts !== undefined) {
|
|
1955
|
+
throw new Error(`no active agent member: ${participant.memberId}`);
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
assertExistingCollaborationRound(projection, requested) {
|
|
1960
|
+
const sameMembers = projection.participants.length === requested.length &&
|
|
1961
|
+
projection.participants.every((participant, index) => participant.member_id === requested[index].memberId);
|
|
1962
|
+
const sameSnapshots = sameMembers && projection.deliveries.every((delivery, index) => this.getDeliveryPayloadSnapshot(delivery.room, delivery.id) === requested[index].payloadSnapshot);
|
|
1963
|
+
if (!sameSnapshots) {
|
|
1964
|
+
throw new Error(`collaboration round ${projection.group.id}/${projection.round.round_number}` +
|
|
1965
|
+
' already exists with different participants or payloads');
|
|
1966
|
+
}
|
|
1967
|
+
return projection;
|
|
1968
|
+
}
|
|
1969
|
+
materializeCollaborationRound(room, group, roundNumber, createdTs, participants) {
|
|
1970
|
+
const round = CollaborationRoundSchema.parse({
|
|
1971
|
+
group_id: group.id,
|
|
1972
|
+
round_number: roundNumber,
|
|
1973
|
+
state: 'collecting',
|
|
1974
|
+
created_ts: createdTs,
|
|
1975
|
+
});
|
|
1976
|
+
this.db.prepare(`INSERT INTO collaboration_rounds
|
|
1977
|
+
(group_id, round_number, state, created_ts, released_ts)
|
|
1978
|
+
VALUES (?, ?, ?, ?, ?)`).run(round.group_id, round.round_number, round.state, round.created_ts, null);
|
|
1979
|
+
for (const [ordinal, input] of participants.entries()) {
|
|
1980
|
+
const delivery = this.createDelivery(room, {
|
|
1981
|
+
message_id: group.root_message_id,
|
|
1982
|
+
recipient: input.memberId,
|
|
1983
|
+
state: input.state,
|
|
1984
|
+
payload_snapshot: input.payloadSnapshot,
|
|
1985
|
+
hop_count: input.hopCount,
|
|
1986
|
+
group_id: group.id,
|
|
1987
|
+
group_round: roundNumber,
|
|
1988
|
+
});
|
|
1989
|
+
const participant = CollaborationParticipantSchema.parse({
|
|
1990
|
+
group_id: group.id,
|
|
1991
|
+
round_number: roundNumber,
|
|
1992
|
+
ordinal,
|
|
1993
|
+
member_id: input.memberId,
|
|
1994
|
+
delivery_id: delivery.id,
|
|
1995
|
+
});
|
|
1996
|
+
this.db.prepare(`INSERT INTO collaboration_participants
|
|
1997
|
+
(group_id, round_number, ordinal, member_id, delivery_id,
|
|
1998
|
+
terminal_status, result_message_id, completed_ts)
|
|
1999
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(participant.group_id, participant.round_number, participant.ordinal, participant.member_id, participant.delivery_id, null, null, null);
|
|
2000
|
+
}
|
|
2001
|
+
return this.getCollaborationRoundProjection(room, group.id, roundNumber);
|
|
2002
|
+
}
|
|
2003
|
+
createCollaborationGroup(room, opts) {
|
|
2004
|
+
return this.db.transaction(() => {
|
|
2005
|
+
this.assertCollaborationParticipantInputShape(opts.participants, 2);
|
|
2006
|
+
const existing = this.getCollaborationGroupByRoot(room, opts.rootMessageId);
|
|
2007
|
+
if (existing) {
|
|
2008
|
+
const projection = this.getCollaborationRoundProjection(room, existing.id, 1);
|
|
2009
|
+
return this.assertExistingCollaborationRound(projection, opts.participants);
|
|
2010
|
+
}
|
|
2011
|
+
if (!this.getMessage(room, opts.rootMessageId)) {
|
|
2012
|
+
throw new Error(`no such collaboration root message: #${opts.rootMessageId}`);
|
|
2013
|
+
}
|
|
2014
|
+
this.assertActiveAgentParticipants(room, opts.participants);
|
|
2015
|
+
const createdTs = opts.createdTs ?? new Date().toISOString();
|
|
2016
|
+
const group = CollaborationGroupSchema.parse({
|
|
2017
|
+
id: opts.groupId ?? randomUUID(),
|
|
2018
|
+
room,
|
|
2019
|
+
root_message_id: opts.rootMessageId,
|
|
2020
|
+
state: 'open',
|
|
2021
|
+
created_ts: createdTs,
|
|
2022
|
+
});
|
|
2023
|
+
this.db.prepare(`INSERT INTO collaboration_groups
|
|
2024
|
+
(id, room, root_message_id, state, created_ts, completed_ts)
|
|
2025
|
+
VALUES (?, ?, ?, ?, ?, ?)`).run(group.id, group.room, group.root_message_id, group.state, group.created_ts, null);
|
|
2026
|
+
return this.materializeCollaborationRound(room, group, 1, createdTs, opts.participants);
|
|
2027
|
+
})();
|
|
2028
|
+
}
|
|
2029
|
+
createCollaborationRound(room, opts) {
|
|
2030
|
+
return this.db.transaction(() => {
|
|
2031
|
+
if (!Number.isInteger(opts.roundNumber) || opts.roundNumber < 2) {
|
|
2032
|
+
throw new Error('later collaboration round number must be an integer of at least 2');
|
|
2033
|
+
}
|
|
2034
|
+
this.assertCollaborationParticipantInputShape(opts.participants, 1);
|
|
2035
|
+
const existing = this.getCollaborationRoundProjection(room, opts.groupId, opts.roundNumber);
|
|
2036
|
+
if (existing)
|
|
2037
|
+
return this.assertExistingCollaborationRound(existing, opts.participants);
|
|
2038
|
+
const group = this.getCollaborationGroup(room, opts.groupId);
|
|
2039
|
+
if (!group)
|
|
2040
|
+
throw new Error(`no such collaboration group: ${opts.groupId}`);
|
|
2041
|
+
if (group.state !== 'open')
|
|
2042
|
+
throw new Error(`collaboration group ${opts.groupId} is ${group.state}`);
|
|
2043
|
+
if (!this.getCollaborationRound(room, opts.groupId, opts.roundNumber - 1)) {
|
|
2044
|
+
throw new Error(`collaboration round ${opts.groupId}/${opts.roundNumber - 1} does not exist`);
|
|
2045
|
+
}
|
|
2046
|
+
this.assertActiveAgentParticipants(room, opts.participants);
|
|
2047
|
+
return this.materializeCollaborationRound(room, group, opts.roundNumber, opts.createdTs ?? new Date().toISOString(), opts.participants);
|
|
2048
|
+
})();
|
|
2049
|
+
}
|
|
2050
|
+
// harn:end group-round-creation-is-atomic-and-idempotent
|
|
2051
|
+
// harn:end collaboration-groups-are-durable-state
|
|
2052
|
+
// harn:assume failed-finalization-reconciles-at-runtime ref=finalization-repair-transaction
|
|
2053
|
+
/**
|
|
2054
|
+
* Settle a turn whose journal is terminal but whose NORMAL finalization could
|
|
2055
|
+
* not commit — the #599 shape: a lifecycle retry rebound to a delivery whose
|
|
2056
|
+
* participant already holds a terminal result, so completeTurn's participant
|
|
2057
|
+
* write correctly refused and took the whole transaction down with it, leaving
|
|
2058
|
+
* the row `running` and the delivery `delivering` after the in-memory guard
|
|
2059
|
+
* had already cleared.
|
|
2060
|
+
*
|
|
2061
|
+
* This is deliberately NOT the normal path: it routes nothing, replaces no
|
|
2062
|
+
* participant result, and keeps the original journal so the evidence of what
|
|
2063
|
+
* the engine actually did survives. It only makes the durable state honest —
|
|
2064
|
+
* the attempt failed, its input is settled, the member is not running.
|
|
2065
|
+
*
|
|
2066
|
+
* Idempotent by construction: a run that is no longer `running` has already
|
|
2067
|
+
* been repaired (or finalized normally), so a second runtime or boot pass
|
|
2068
|
+
* returns `repaired: false` and writes nothing — including the meter, which
|
|
2069
|
+
* is why the rolled-back attempt is counted exactly once rather than never.
|
|
2070
|
+
*/
|
|
2071
|
+
repairFailedFinalization(room, opts) {
|
|
2072
|
+
return this.db.transaction(() => {
|
|
2073
|
+
const runMsg = this.getMessage(room, opts.runMsgId);
|
|
2074
|
+
if (!runMsg?.run || runMsg.run.status !== 'running') {
|
|
2075
|
+
return { repaired: false, deliveries: [], held: [] };
|
|
2076
|
+
}
|
|
2077
|
+
const outputPatches = opts.outputs ?? [];
|
|
2078
|
+
const rootOutput = outputPatches.find((output) => output.id === opts.runMsgId) ?? {
|
|
2079
|
+
id: opts.runMsgId,
|
|
2080
|
+
body: '',
|
|
2081
|
+
mentions: [],
|
|
2082
|
+
refs: [],
|
|
2083
|
+
ledger_refs: [],
|
|
2084
|
+
substantive: false,
|
|
2085
|
+
};
|
|
2086
|
+
const message = this.updateMessage(room, opts.runMsgId, {
|
|
2087
|
+
body: rootOutput.body,
|
|
2088
|
+
mentions: rootOutput.mentions,
|
|
2089
|
+
refs: rootOutput.refs,
|
|
2090
|
+
ledger_refs: rootOutput.ledger_refs,
|
|
2091
|
+
run: {
|
|
2092
|
+
...runMsg.run,
|
|
2093
|
+
status: 'failed',
|
|
2094
|
+
ended_ts: opts.endedTs,
|
|
2095
|
+
stalled_since: undefined,
|
|
2096
|
+
final_text: undefined,
|
|
2097
|
+
usage: opts.usage,
|
|
2098
|
+
estimated_cost_usd: opts.estimatedCostUsd,
|
|
2099
|
+
error: opts.error,
|
|
2100
|
+
},
|
|
2101
|
+
}, { activity: rootOutput.substantive ? 'force' : 'defer' });
|
|
2102
|
+
const outputMessages = [message];
|
|
2103
|
+
const referenced = new Set(outputPatches.map((output) => output.id));
|
|
2104
|
+
for (const output of outputPatches) {
|
|
2105
|
+
if (output.id === opts.runMsgId)
|
|
2106
|
+
continue;
|
|
2107
|
+
const current = this.getMessage(room, output.id);
|
|
2108
|
+
if (current?.run_parent_id !== opts.runMsgId)
|
|
2109
|
+
continue;
|
|
2110
|
+
outputMessages.push(this.updateMessage(room, output.id, {
|
|
2111
|
+
body: output.body,
|
|
2112
|
+
mentions: output.mentions,
|
|
2113
|
+
refs: output.refs,
|
|
2114
|
+
ledger_refs: output.ledger_refs,
|
|
2115
|
+
}, { activity: output.substantive ? 'force' : 'defer' }));
|
|
2116
|
+
}
|
|
2117
|
+
for (const continuation of this.listRunContinuations(room, opts.runMsgId)) {
|
|
2118
|
+
if (referenced.has(continuation.id) || continuation.deleted === true)
|
|
2119
|
+
continue;
|
|
2120
|
+
outputMessages.push(this.deleteMessage(room, continuation.id));
|
|
2121
|
+
}
|
|
2122
|
+
const deliveries = [];
|
|
2123
|
+
const held = [];
|
|
2124
|
+
for (const deliveryId of opts.deliveryIds) {
|
|
2125
|
+
const delivery = this.getDelivery(room, deliveryId);
|
|
2126
|
+
if (!delivery || delivery.state !== 'delivering')
|
|
2127
|
+
continue;
|
|
2128
|
+
// Consume only what is PROVABLY obsolete: this delivery's collaboration
|
|
2129
|
+
// work already has a terminal result, or its round/group is closed, so
|
|
2130
|
+
// re-running it could only duplicate an answer that already exists.
|
|
2131
|
+
// Anything else is ambiguous and becomes the operator's call.
|
|
2132
|
+
const obsolete = this.collaborationWorkIsSettled(room, delivery.id);
|
|
2133
|
+
deliveries.push(this.updateDelivery(room, delivery.id, {
|
|
2134
|
+
state: obsolete ? 'consumed' : 'held',
|
|
2135
|
+
batch_id: undefined,
|
|
2136
|
+
}));
|
|
2137
|
+
this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
|
|
2138
|
+
if (!obsolete)
|
|
2139
|
+
held.push(delivery.id);
|
|
2140
|
+
}
|
|
2141
|
+
const current = this.getMember(room, opts.memberId);
|
|
2142
|
+
const member = current === undefined
|
|
2143
|
+
? undefined
|
|
2144
|
+
: this.updateMember(room, opts.memberId, {
|
|
2145
|
+
// A repaired attempt leaves the member reachable, not dead: nothing
|
|
2146
|
+
// about a finalization rollback says the agent itself is gone.
|
|
2147
|
+
state: current.state === 'dead' || current.state === 'paused' ? current.state : 'idle',
|
|
2148
|
+
});
|
|
2149
|
+
// completeTurn's rollback took its meter write with it, so this attempt's
|
|
2150
|
+
// spend is currently recorded NOWHERE. Count it here, exactly once — and
|
|
2151
|
+
// return it, so a connected UI sees the same total the database now holds.
|
|
2152
|
+
const meter = this.bumpMeter(room, opts.meterDay, opts.meterDelta);
|
|
2153
|
+
const system = this.listMembers(room).find((candidate) => candidate.kind === 'system');
|
|
2154
|
+
if (!system)
|
|
2155
|
+
throw new Error(`room ${room} has no system member`);
|
|
2156
|
+
const handle = `@${member?.handle ?? opts.memberId}`;
|
|
2157
|
+
const detail = opts.error.replace(/^finalization could not commit:\s*/, '');
|
|
2158
|
+
// The notice commits with the repair. A crash after this transaction can
|
|
2159
|
+
// neither lose the explanation nor create a duplicate on the next pass.
|
|
2160
|
+
const notice = this.postMessage(room, {
|
|
2161
|
+
author: system.id,
|
|
2162
|
+
kind: 'system',
|
|
2163
|
+
body: held.length > 0
|
|
2164
|
+
? `turn #${String(opts.runMsgId)} for ${handle} could not finalize (${detail}) — its instruction is held; release_hold or redeliver to retry`
|
|
2165
|
+
: `turn #${String(opts.runMsgId)} for ${handle} could not finalize (${detail}) — its work already had a result, so the duplicate instruction was consumed`,
|
|
2166
|
+
});
|
|
2167
|
+
return {
|
|
2168
|
+
repaired: true,
|
|
2169
|
+
message,
|
|
2170
|
+
outputMessages: outputMessages.sort((left, right) => left.id - right.id),
|
|
2171
|
+
member,
|
|
2172
|
+
meter,
|
|
2173
|
+
notice,
|
|
2174
|
+
deliveries,
|
|
2175
|
+
held,
|
|
2176
|
+
};
|
|
2177
|
+
})();
|
|
2178
|
+
}
|
|
2179
|
+
/**
|
|
2180
|
+
* True when this delivery's collaboration work is already settled — a terminal
|
|
2181
|
+
* participant, or a round/group that is no longer accepting work. A delivery
|
|
2182
|
+
* with no collaboration context at all is NOT settled: an ordinary turn that
|
|
2183
|
+
* failed to finalize is exactly the ambiguous case an operator should see.
|
|
2184
|
+
*
|
|
2185
|
+
* Public because it is the SAME predicate on both sides of the seam: the
|
|
2186
|
+
* daemon asks it before attempting normal finalization, and repair asks it
|
|
2187
|
+
* when deciding consume-vs-hold. A second copy of this rule that drifted
|
|
2188
|
+
* would let a closed group accept a new result — `recordCollaboration-
|
|
2189
|
+
* ParticipantTerminal` guards only the participant, never its round or group.
|
|
2190
|
+
*/
|
|
2191
|
+
collaborationWorkIsSettled(room, deliveryId) {
|
|
2192
|
+
const participant = this.findCollaborationParticipantByDelivery(room, deliveryId);
|
|
2193
|
+
if (!participant)
|
|
2194
|
+
return false;
|
|
2195
|
+
if (participant.terminal_status !== undefined)
|
|
2196
|
+
return true;
|
|
2197
|
+
const group = this.getCollaborationGroup(room, participant.group_id);
|
|
2198
|
+
if (group !== undefined && group.state !== 'open')
|
|
2199
|
+
return true;
|
|
2200
|
+
const round = this.getCollaborationRound(room, participant.group_id, participant.round_number);
|
|
2201
|
+
return round !== undefined && round.state !== 'collecting';
|
|
2202
|
+
}
|
|
2203
|
+
// harn:end failed-finalization-reconciles-at-runtime
|
|
2204
|
+
// harn:assume lifecycle-retries-only-live-collaboration-work ref=lifecycle-interruption-settlement
|
|
2205
|
+
/**
|
|
2206
|
+
* Settle a turn the daemon's OWN lifecycle interrupted, deciding retry
|
|
2207
|
+
* eligibility and terminality as ONE fact.
|
|
2208
|
+
*
|
|
2209
|
+
* The #598/#599 bug was these being two facts: the attempt finalized as a
|
|
2210
|
+
* terminal participant result, and only afterwards was its delivery re-queued
|
|
2211
|
+
* — so the retry came back to a participant that already had an answer. Here
|
|
2212
|
+
* a delivery is either durably re-queued (and its participant deliberately
|
|
2213
|
+
* left nonterminal, its round still open, so the eventual retry produces the
|
|
2214
|
+
* one result) or it is settled and its participant recorded `interrupted` so
|
|
2215
|
+
* the barrier releases. Never both, never neither: a nonterminal participant
|
|
2216
|
+
* may exist only with a queued retry behind it.
|
|
2217
|
+
*
|
|
2218
|
+
* Retry is admissible only while the work is genuinely still live — below the
|
|
2219
|
+
* attempt ceiling, trigger not deleted, and collaboration work not settled.
|
|
2220
|
+
* Operator Stop never reaches here; it is terminal by construction.
|
|
2221
|
+
*/
|
|
2222
|
+
settleLifecycleInterruption(room, opts) {
|
|
2223
|
+
return this.db.transaction(() => {
|
|
2224
|
+
const rootOutput = opts.outputs.find((output) => output.id === opts.runMsgId) ?? {
|
|
2225
|
+
id: opts.runMsgId,
|
|
2226
|
+
body: '',
|
|
2227
|
+
mentions: [],
|
|
2228
|
+
refs: [],
|
|
2229
|
+
ledger_refs: [],
|
|
2230
|
+
substantive: false,
|
|
2231
|
+
};
|
|
2232
|
+
const message = this.updateMessage(room, opts.runMsgId, {
|
|
2233
|
+
...opts.message,
|
|
2234
|
+
body: rootOutput.body,
|
|
2235
|
+
mentions: rootOutput.mentions,
|
|
2236
|
+
refs: rootOutput.refs,
|
|
2237
|
+
ledger_refs: rootOutput.ledger_refs,
|
|
2238
|
+
}, { activity: rootOutput.substantive ? 'force' : 'defer' });
|
|
2239
|
+
const outputMessages = [message];
|
|
2240
|
+
const referenced = new Set(opts.outputs.map((output) => output.id));
|
|
2241
|
+
for (const output of opts.outputs) {
|
|
2242
|
+
if (output.id === opts.runMsgId)
|
|
2243
|
+
continue;
|
|
2244
|
+
const current = this.getMessage(room, output.id);
|
|
2245
|
+
if (current?.run_parent_id !== opts.runMsgId)
|
|
2246
|
+
continue;
|
|
2247
|
+
outputMessages.push(this.updateMessage(room, output.id, {
|
|
2248
|
+
body: output.body,
|
|
2249
|
+
mentions: output.mentions,
|
|
2250
|
+
refs: output.refs,
|
|
2251
|
+
ledger_refs: output.ledger_refs,
|
|
2252
|
+
}, { activity: output.substantive ? 'force' : 'defer' }));
|
|
2253
|
+
}
|
|
2254
|
+
for (const continuation of this.listRunContinuations(room, opts.runMsgId)) {
|
|
2255
|
+
if (referenced.has(continuation.id) || continuation.deleted === true)
|
|
2256
|
+
continue;
|
|
2257
|
+
outputMessages.push(this.deleteMessage(room, continuation.id));
|
|
2258
|
+
}
|
|
2259
|
+
const member = this.updateMember(room, opts.memberId, opts.memberPatch);
|
|
2260
|
+
const requeued = [];
|
|
2261
|
+
const settled = [];
|
|
2262
|
+
const refusals = [];
|
|
2263
|
+
for (const deliveryId of opts.deliveryIds) {
|
|
2264
|
+
const delivery = this.getDelivery(room, deliveryId);
|
|
2265
|
+
if (!delivery)
|
|
2266
|
+
continue;
|
|
2267
|
+
if (delivery.state !== 'consumed' && delivery.state !== 'delivering')
|
|
2268
|
+
continue;
|
|
2269
|
+
const atCeiling = delivery.attempt_count >= opts.attemptCeiling;
|
|
2270
|
+
const triggerPurged = this.getMessage(room, delivery.message_id)?.deleted === true;
|
|
2271
|
+
const workSettled = this.collaborationWorkIsSettled(room, delivery.id);
|
|
2272
|
+
if (!atCeiling && !triggerPurged && !workSettled) {
|
|
2273
|
+
// Live work: re-queue WITHOUT touching the participant, so the round
|
|
2274
|
+
// stays open for the retry that will produce its one result.
|
|
2275
|
+
requeued.push(this.updateDelivery(room, delivery.id, {
|
|
2276
|
+
state: 'queued',
|
|
2277
|
+
run_msg_id: undefined,
|
|
2278
|
+
batch_id: undefined,
|
|
2279
|
+
}));
|
|
2280
|
+
this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
|
|
2281
|
+
continue;
|
|
2282
|
+
}
|
|
2283
|
+
// Refused: settle the input and release the barrier in the same commit.
|
|
2284
|
+
// A participant left nonterminal here would block its round forever,
|
|
2285
|
+
// because nothing is coming back to answer it.
|
|
2286
|
+
const consumed = this.updateDelivery(room, delivery.id, {
|
|
2287
|
+
state: 'consumed',
|
|
2288
|
+
batch_id: undefined,
|
|
2289
|
+
});
|
|
2290
|
+
settled.push(consumed);
|
|
2291
|
+
this.setDeliveryAttemptProcess(room, [delivery.id], undefined);
|
|
2292
|
+
refusals.push({
|
|
2293
|
+
delivery: consumed,
|
|
2294
|
+
reason: atCeiling
|
|
2295
|
+
? 'attempt_ceiling'
|
|
2296
|
+
: triggerPurged
|
|
2297
|
+
? 'deleted_trigger'
|
|
2298
|
+
: 'settled_collaboration',
|
|
2299
|
+
});
|
|
2300
|
+
const participant = this.findCollaborationParticipantByDelivery(room, delivery.id);
|
|
2301
|
+
if (participant !== undefined && participant.terminal_status === undefined) {
|
|
2302
|
+
this.recordCollaborationParticipantTerminal(room, {
|
|
2303
|
+
deliveryId: delivery.id,
|
|
2304
|
+
status: 'interrupted',
|
|
2305
|
+
resultMessageId: opts.runMsgId,
|
|
2306
|
+
completedTs: opts.endedTs,
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
const meter = this.bumpMeter(room, opts.meterDay, opts.meterDelta);
|
|
2311
|
+
let notice;
|
|
2312
|
+
if (refusals.length > 0) {
|
|
2313
|
+
const system = this.listMembers(room).find((candidate) => candidate.kind === 'system');
|
|
2314
|
+
if (!system)
|
|
2315
|
+
throw new Error(`room ${room} has no system member`);
|
|
2316
|
+
const recipients = [...new Set(refusals.map(({ delivery }) => delivery.recipient))]
|
|
2317
|
+
.map((recipient) => `@${this.getMember(room, recipient)?.handle ?? recipient}`)
|
|
2318
|
+
.join(', ');
|
|
2319
|
+
const reasons = [...new Set(refusals.map(({ reason }) => reason))]
|
|
2320
|
+
.map((reason) => reason === 'attempt_ceiling'
|
|
2321
|
+
? 'the retry ceiling was reached'
|
|
2322
|
+
: reason === 'deleted_trigger'
|
|
2323
|
+
? 'the instruction was deleted'
|
|
2324
|
+
: 'the collaboration work was already settled')
|
|
2325
|
+
.join('; ');
|
|
2326
|
+
const mayForce = refusals.some(({ reason }) => reason === 'attempt_ceiling');
|
|
2327
|
+
notice = this.postMessage(room, {
|
|
2328
|
+
author: system.id,
|
|
2329
|
+
kind: 'system',
|
|
2330
|
+
body: `delivery to ${recipients} not re-queued after lifecycle interruption (turn #${String(opts.runMsgId)}: ${reasons}) — ${mayForce ? 'retry_run can force a fresh attempt' : 'the instruction was consumed'}`,
|
|
2331
|
+
});
|
|
2332
|
+
}
|
|
2333
|
+
return {
|
|
2334
|
+
message,
|
|
2335
|
+
outputMessages: outputMessages.sort((left, right) => left.id - right.id),
|
|
2336
|
+
member,
|
|
2337
|
+
meter,
|
|
2338
|
+
notice,
|
|
2339
|
+
requeued,
|
|
2340
|
+
settled,
|
|
2341
|
+
refusals,
|
|
2342
|
+
};
|
|
2343
|
+
})();
|
|
2344
|
+
}
|
|
2345
|
+
// harn:end lifecycle-retries-only-live-collaboration-work
|
|
2346
|
+
// ── pending interactions ──────────────────────────────────────────────
|
|
2347
|
+
upsertInteraction(interaction) {
|
|
2348
|
+
return this.db.transaction(() => {
|
|
2349
|
+
const validated = PendingInteractionSchema.parse(interaction);
|
|
2350
|
+
this.db
|
|
2351
|
+
.prepare(`INSERT INTO pending_interactions (id, room, member_id, message_id, native_id,
|
|
2352
|
+
kind, targets, state, answer, answered_by, answered_ts)
|
|
2353
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2354
|
+
ON CONFLICT (id) DO UPDATE SET native_id = excluded.native_id,
|
|
2355
|
+
state = excluded.state, answer = excluded.answer,
|
|
2356
|
+
answered_by = excluded.answered_by, answered_ts = excluded.answered_ts`)
|
|
2357
|
+
.run(validated.id, validated.room, validated.member_id, validated.message_id, validated.native_id, validated.kind, JSON.stringify(validated.targets), validated.state, jsonOrNull(validated.answer), orNull(validated.answered_by), orNull(validated.answered_ts));
|
|
2358
|
+
return validated;
|
|
2359
|
+
})();
|
|
2360
|
+
}
|
|
2361
|
+
getInteraction(id) {
|
|
2362
|
+
const row = this.db
|
|
2363
|
+
.prepare('SELECT * FROM pending_interactions WHERE id = ?')
|
|
2364
|
+
.get(id);
|
|
2365
|
+
return row ? interactionFromRow(row) : undefined;
|
|
2366
|
+
}
|
|
2367
|
+
listInteractions(room, state) {
|
|
2368
|
+
const rows = this.db
|
|
2369
|
+
.prepare(`SELECT * FROM pending_interactions WHERE room = ?
|
|
2370
|
+
AND (? IS NULL OR state = ?) ORDER BY id`)
|
|
2371
|
+
.all(room, state ?? null, state ?? null);
|
|
2372
|
+
return rows.map(interactionFromRow);
|
|
2373
|
+
}
|
|
2374
|
+
// harn:assume approval-deliveries-project-resolution-separately ref=approval-resolution-store
|
|
2375
|
+
resolveApprovalDeliveries(interaction, resolvedTs) {
|
|
2376
|
+
const targets = new Set(interaction.targets);
|
|
2377
|
+
const rows = this.db.prepare(`SELECT deliveries.* FROM deliveries
|
|
2378
|
+
JOIN members
|
|
2379
|
+
ON members.room = deliveries.room
|
|
2380
|
+
AND members.id = deliveries.recipient
|
|
2381
|
+
AND members.kind = 'human'
|
|
2382
|
+
WHERE deliveries.room = ? AND deliveries.message_id = ?
|
|
2383
|
+
ORDER BY deliveries.queue_seq, deliveries.id`).all(interaction.room, interaction.message_id);
|
|
2384
|
+
return rows
|
|
2385
|
+
.map(deliveryFromRow)
|
|
2386
|
+
.filter((delivery) => targets.has(delivery.recipient))
|
|
2387
|
+
.map((delivery) => (delivery.read_ts !== undefined && delivery.interaction_resolved_ts !== undefined
|
|
2388
|
+
? delivery
|
|
2389
|
+
: this.updateDelivery(interaction.room, delivery.id, {
|
|
2390
|
+
read_ts: delivery.read_ts ?? resolvedTs,
|
|
2391
|
+
interaction_resolved_ts: delivery.interaction_resolved_ts ?? resolvedTs,
|
|
2392
|
+
})));
|
|
2393
|
+
}
|
|
2394
|
+
orphanInteraction(room, interactionId, orphanedTs) {
|
|
2395
|
+
return this.db.transaction(() => {
|
|
2396
|
+
const existing = this.getInteraction(interactionId);
|
|
2397
|
+
if (!existing || existing.room !== room)
|
|
2398
|
+
throw new Error(`no such interaction ${interactionId}`);
|
|
2399
|
+
if (existing.state !== 'pending' && existing.state !== 'answered') {
|
|
2400
|
+
throw new Error(`interaction ${interactionId} is ${existing.state}`);
|
|
2401
|
+
}
|
|
2402
|
+
const interaction = this.upsertInteraction({ ...existing, state: 'orphaned' });
|
|
2403
|
+
const deliveries = interaction.kind === 'approval'
|
|
2404
|
+
? this.resolveApprovalDeliveries(interaction, orphanedTs)
|
|
2405
|
+
: [];
|
|
2406
|
+
return { interaction, deliveries };
|
|
2407
|
+
})();
|
|
2408
|
+
}
|
|
2409
|
+
// harn:end approval-deliveries-project-resolution-separately
|
|
2410
|
+
// harn:assume approval-answer-is-atomic-and-chatless ref=approval-answer-transaction
|
|
2411
|
+
answerApproval(room, interactionId, answer, answeredBy, answeredTs) {
|
|
2412
|
+
return this.db.transaction(() => {
|
|
2413
|
+
const existing = this.getInteraction(interactionId);
|
|
2414
|
+
if (!existing || existing.room !== room)
|
|
2415
|
+
throw new Error(`no such interaction ${interactionId}`);
|
|
2416
|
+
if (existing.kind !== 'approval')
|
|
2417
|
+
throw new Error(`interaction ${interactionId} is not an approval`);
|
|
2418
|
+
if (existing.state !== 'pending')
|
|
2419
|
+
throw new Error(`interaction ${interactionId} is ${existing.state}`);
|
|
2420
|
+
if (!existing.targets.includes(answeredBy)) {
|
|
2421
|
+
throw new Error(`interaction ${interactionId} is not addressed to member ${answeredBy}`);
|
|
2422
|
+
}
|
|
2423
|
+
const interaction = this.upsertInteraction({
|
|
2424
|
+
...existing,
|
|
2425
|
+
state: 'answered',
|
|
2426
|
+
answer,
|
|
2427
|
+
answered_by: answeredBy,
|
|
2428
|
+
answered_ts: answeredTs,
|
|
2429
|
+
});
|
|
2430
|
+
const deliveries = this.resolveApprovalDeliveries(interaction, answeredTs);
|
|
2431
|
+
return { interaction, deliveries };
|
|
2432
|
+
})();
|
|
2433
|
+
}
|
|
2434
|
+
// harn:end approval-answer-is-atomic-and-chatless
|
|
2435
|
+
// ── meters ────────────────────────────────────────────────────────────
|
|
2436
|
+
// harn:assume spend-meter-always-on ref=meter-cost-and-token-accounting
|
|
2437
|
+
bumpMeter(room, day, delta) {
|
|
2438
|
+
// harn:assume estimated-cost-is-advisory-not-spend-brake-input ref=separated-meter-accounting
|
|
2439
|
+
return this.db.transaction(() => {
|
|
2440
|
+
this.db
|
|
2441
|
+
.prepare(`INSERT INTO meters
|
|
2442
|
+
(room, day, turns, cost_usd, estimated_cost_usd, input_tokens, output_tokens, uncosted_tokens)
|
|
2443
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
2444
|
+
ON CONFLICT (room, day) DO UPDATE SET
|
|
2445
|
+
turns = turns + excluded.turns,
|
|
2446
|
+
cost_usd = cost_usd + excluded.cost_usd,
|
|
2447
|
+
estimated_cost_usd = estimated_cost_usd + excluded.estimated_cost_usd,
|
|
2448
|
+
input_tokens = input_tokens + excluded.input_tokens,
|
|
2449
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
2450
|
+
uncosted_tokens = uncosted_tokens + excluded.uncosted_tokens`)
|
|
2451
|
+
.run(room, day, delta.turns ?? 0, delta.cost_usd ?? 0, delta.estimated_cost_usd ?? 0, delta.input_tokens ?? 0, delta.output_tokens ?? 0, delta.uncosted_tokens ?? 0);
|
|
2452
|
+
this.appendChange(room, 'meter', day);
|
|
2453
|
+
const row = this.db
|
|
2454
|
+
.prepare('SELECT * FROM meters WHERE room = ? AND day = ?')
|
|
2455
|
+
.get(room, day);
|
|
2456
|
+
return meterFromRow(row);
|
|
2457
|
+
})();
|
|
2458
|
+
// harn:end estimated-cost-is-advisory-not-spend-brake-input
|
|
2459
|
+
}
|
|
2460
|
+
getMeter(room, day) {
|
|
2461
|
+
const row = this.db
|
|
2462
|
+
.prepare('SELECT * FROM meters WHERE room = ? AND day = ?')
|
|
2463
|
+
.get(room, day);
|
|
2464
|
+
return row ? meterFromRow(row) : undefined;
|
|
2465
|
+
}
|
|
2466
|
+
// harn:end spend-meter-always-on
|
|
2467
|
+
// ── sync ──────────────────────────────────────────────────────────────
|
|
2468
|
+
currentSeq(room) {
|
|
2469
|
+
const row = this.db.prepare('SELECT seq FROM rooms WHERE id = ?').get(room);
|
|
2470
|
+
if (!row)
|
|
2471
|
+
throw new Error(`no such room: ${room}`);
|
|
2472
|
+
return row.seq;
|
|
2473
|
+
}
|
|
2474
|
+
getChangesSince(room, sinceSeq) {
|
|
2475
|
+
const rows = this.db
|
|
2476
|
+
.prepare('SELECT room_id, seq, entity, entity_id FROM changes WHERE room_id = ? AND seq > ? ORDER BY seq')
|
|
2477
|
+
.all(room, sinceSeq);
|
|
2478
|
+
return rows.map((row) => ChangeLogEntrySchema.parse({
|
|
2479
|
+
room: row.room_id,
|
|
2480
|
+
seq: row.seq,
|
|
2481
|
+
entity: row.entity,
|
|
2482
|
+
entity_id: row.entity_id,
|
|
2483
|
+
}));
|
|
2484
|
+
}
|
|
2485
|
+
// harn:assume sync-cursor-commits-after-hydration ref=consistent-sync-snapshot
|
|
2486
|
+
/** Delta-sync: hydrate rows and its final cursor from one SQLite snapshot. */
|
|
2487
|
+
sync(room, sinceSeq, opts = {}) {
|
|
2488
|
+
return this.db.transaction(() => {
|
|
2489
|
+
const seq = this.currentSeq(room);
|
|
2490
|
+
// A bound is honoured ONLY on a cold subscribe that asked for one. Every
|
|
2491
|
+
// warm sync replays every change as before, or an in-place finalization
|
|
2492
|
+
// could be missed; a subscriber that sends no bound is untouched too.
|
|
2493
|
+
if (sinceSeq === 0 && opts.hydrateLimit !== undefined) {
|
|
2494
|
+
const roomRow = this.getRoom(room);
|
|
2495
|
+
if (!roomRow)
|
|
2496
|
+
throw new Error(`no such room: ${room}`);
|
|
2497
|
+
return this.boundedColdSnapshot(room, roomRow, seq, opts.hydrateLimit, opts.subscriber, opts.strictTail === true, opts.supportFor);
|
|
2498
|
+
}
|
|
2499
|
+
const changes = this.getChangesSince(room, sinceSeq);
|
|
2500
|
+
const wanted = new Map();
|
|
2501
|
+
for (const change of changes) {
|
|
2502
|
+
let ids = wanted.get(change.entity);
|
|
2503
|
+
if (!ids)
|
|
2504
|
+
wanted.set(change.entity, (ids = new Set()));
|
|
2505
|
+
ids.add(change.entity_id);
|
|
2506
|
+
}
|
|
2507
|
+
const roomRow = this.getRoom(room);
|
|
2508
|
+
if (!roomRow)
|
|
2509
|
+
throw new Error(`no such room: ${room}`);
|
|
2510
|
+
return {
|
|
2511
|
+
seq,
|
|
2512
|
+
room: roomRow,
|
|
2513
|
+
messages: [...(wanted.get('message') ?? [])]
|
|
2514
|
+
.map((id) => this.getMessage(room, Number(id)))
|
|
2515
|
+
.filter((message) => message !== undefined),
|
|
2516
|
+
members: [...(wanted.get('member') ?? [])]
|
|
2517
|
+
.map((id) => this.getMember(room, id))
|
|
2518
|
+
.filter((member) => member !== undefined),
|
|
2519
|
+
inbox: [...(wanted.get('inbox') ?? [])]
|
|
2520
|
+
.map((id) => this.getDelivery(room, id))
|
|
2521
|
+
.filter((delivery) => delivery !== undefined),
|
|
2522
|
+
meters: [...(wanted.get('meter') ?? [])]
|
|
2523
|
+
.map((day) => this.getMeter(room, day))
|
|
2524
|
+
.filter((meter) => meter !== undefined),
|
|
2525
|
+
...(opts.supportFor !== undefined && {
|
|
2526
|
+
support: this.roomSupport(room, opts.supportFor),
|
|
2527
|
+
}),
|
|
2528
|
+
};
|
|
2529
|
+
})();
|
|
2530
|
+
}
|
|
2531
|
+
// harn:end sync-cursor-commits-after-hydration
|
|
2532
|
+
// harn:assume addressed-cold-hydration-is-strict-and-legacy-safe ref=addressed-hydration-contract
|
|
2533
|
+
/**
|
|
2534
|
+
* A cold viewer's bounded snapshot, built INSIDE sync()'s transaction. An
|
|
2535
|
+
* addressed subscriber gets exactly the contiguous tail and a separate
|
|
2536
|
+
* support projection. A legacy subscriber keeps the prior outlier-bearing
|
|
2537
|
+
* shape until the client cutover:
|
|
2538
|
+
* - every running run, so a live turn renders however old it is;
|
|
2539
|
+
* - the cards of unresolved interactions, so an ask that scrolled past the
|
|
2540
|
+
* tail is still answerable;
|
|
2541
|
+
* - the messages this subscriber's unread deliveries point at, because the
|
|
2542
|
+
* inbox filter drops deliveries whose message is absent and the badge
|
|
2543
|
+
* would undercount;
|
|
2544
|
+
* - the latest finalized non-ack agent run, the default-recipient seed.
|
|
2545
|
+
* `history_floor` is the tail's floor ALONE — an outlier must never drag the
|
|
2546
|
+
* client's cursor backwards past a gap it did not receive.
|
|
2547
|
+
*/
|
|
2548
|
+
boundedColdSnapshot(room, roomRow, seq, limit, subscriber, strictTail = false, supportFor) {
|
|
2549
|
+
const tail = this.db
|
|
2550
|
+
.prepare('SELECT * FROM messages WHERE room = ? ORDER BY id DESC LIMIT ?')
|
|
2551
|
+
.all(room, limit)
|
|
2552
|
+
.map(messageFromRow)
|
|
2553
|
+
.reverse();
|
|
2554
|
+
const byId = new Map(tail.map((message) => [message.id, message]));
|
|
2555
|
+
const includeOutliers = (rows) => {
|
|
2556
|
+
for (const row of rows) {
|
|
2557
|
+
const message = messageFromRow(row);
|
|
2558
|
+
if (!byId.has(message.id))
|
|
2559
|
+
byId.set(message.id, message);
|
|
2560
|
+
}
|
|
2561
|
+
};
|
|
2562
|
+
if (!strictTail) {
|
|
2563
|
+
includeOutliers(this.db
|
|
2564
|
+
.prepare(`SELECT * FROM messages
|
|
2565
|
+
WHERE room = ? AND kind = 'run' AND json_extract(run, '$.status') = 'running'`)
|
|
2566
|
+
.all(room));
|
|
2567
|
+
includeOutliers(this.db
|
|
2568
|
+
.prepare(`SELECT messages.* FROM messages
|
|
2569
|
+
JOIN pending_interactions ON pending_interactions.room = messages.room
|
|
2570
|
+
AND pending_interactions.message_id = messages.id
|
|
2571
|
+
WHERE messages.room = ? AND pending_interactions.state IN ('pending', 'answered')`)
|
|
2572
|
+
.all(room));
|
|
2573
|
+
if (subscriber !== undefined) {
|
|
2574
|
+
includeOutliers(this.db
|
|
2575
|
+
.prepare(`SELECT messages.* FROM messages
|
|
2576
|
+
JOIN deliveries ON deliveries.room = messages.room
|
|
2577
|
+
AND deliveries.message_id = messages.id
|
|
2578
|
+
WHERE messages.room = ? AND deliveries.recipient = ?
|
|
2579
|
+
AND deliveries.read_ts IS NULL AND deliveries.state = 'consumed'`)
|
|
2580
|
+
.all(room, subscriber));
|
|
2581
|
+
}
|
|
2582
|
+
includeOutliers(this.db
|
|
2583
|
+
.prepare(`SELECT messages.* FROM messages
|
|
2584
|
+
JOIN members ON members.room = messages.room AND members.id = messages.author
|
|
2585
|
+
WHERE messages.room = ? AND messages.kind = 'run' AND members.kind = 'agent'
|
|
2586
|
+
AND members.removed_ts IS NULL AND messages.ack = 0
|
|
2587
|
+
AND json_extract(messages.run, '$.status') <> 'running'
|
|
2588
|
+
ORDER BY messages.id DESC LIMIT 1`)
|
|
2589
|
+
.all(room));
|
|
2590
|
+
}
|
|
2591
|
+
const latestMeter = this.db
|
|
2592
|
+
.prepare('SELECT * FROM meters WHERE room = ? ORDER BY day DESC LIMIT 1')
|
|
2593
|
+
.get(room);
|
|
2594
|
+
return {
|
|
2595
|
+
seq,
|
|
2596
|
+
room: roomRow,
|
|
2597
|
+
history_floor: tail[0]?.id,
|
|
2598
|
+
messages: [...byId.values()].sort((left, right) => left.id - right.id),
|
|
2599
|
+
// The full roster and the subscriber's inbox stay whole: bounding history
|
|
2600
|
+
// must not shrink who is in the room or what is waiting for them.
|
|
2601
|
+
members: this.listMembers(room, { includeRemoved: true }),
|
|
2602
|
+
inbox: this.listDeliveries(room),
|
|
2603
|
+
meters: latestMeter ? [meterFromRow(latestMeter)] : [],
|
|
2604
|
+
...(supportFor !== undefined && { support: this.roomSupport(room, supportFor) }),
|
|
2605
|
+
};
|
|
2606
|
+
}
|
|
2607
|
+
// harn:end addressed-cold-hydration-is-strict-and-legacy-safe
|
|
2608
|
+
// ── helpers ───────────────────────────────────────────────────────────
|
|
2609
|
+
/** Crockford-base32 ULID (timestamp + randomness), no external dep. */
|
|
2610
|
+
newUlid() {
|
|
2611
|
+
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
2612
|
+
let ts = Date.now();
|
|
2613
|
+
let time = '';
|
|
2614
|
+
for (let i = 0; i < 10; i++) {
|
|
2615
|
+
time = ALPHABET[ts % 32] + time;
|
|
2616
|
+
ts = Math.floor(ts / 32);
|
|
2617
|
+
}
|
|
2618
|
+
let random = '';
|
|
2619
|
+
for (let i = 0; i < 16; i++) {
|
|
2620
|
+
random += ALPHABET[Math.floor(Math.random() * 32)];
|
|
2621
|
+
}
|
|
2622
|
+
return time + random;
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
//# sourceMappingURL=store.js.map
|