borgmcp 1.1.15 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +15 -0
- package/LICENSE +201 -33
- package/NOTICE +2 -0
- package/README.md +85 -36
- package/SECURITY.md +7 -0
- package/dist/agent-runtime.d.ts.map +1 -0
- package/dist/agent-runtime.js +63 -1
- package/dist/agent-runtime.js.map +1 -0
- package/dist/assimilate-cmd.d.ts +96 -17
- package/dist/assimilate-cmd.d.ts.map +1 -0
- package/dist/assimilate-cmd.js +1396 -48
- package/dist/assimilate-cmd.js.map +1 -0
- package/dist/assimilate-deps.d.ts.map +1 -0
- package/dist/assimilate-deps.js +384 -3
- package/dist/assimilate-deps.js.map +1 -0
- package/dist/assimilate-guard.d.ts +5 -7
- package/dist/assimilate-guard.d.ts.map +1 -0
- package/dist/assimilate-guard.js +60 -2
- package/dist/assimilate-guard.js.map +1 -0
- package/dist/assimilate-welcome.d.ts +1 -1
- package/dist/assimilate-welcome.d.ts.map +1 -0
- package/dist/assimilate-welcome.js +32 -2
- package/dist/assimilate-welcome.js.map +1 -0
- package/dist/auth-env.d.ts +7 -50
- package/dist/auth-env.d.ts.map +1 -0
- package/dist/auth-env.js +22 -1
- package/dist/auth-env.js.map +1 -0
- package/dist/backends/launch-all-pastelist.d.ts.map +1 -0
- package/dist/backends/launch-all-pastelist.js +11 -4
- package/dist/backends/launch-all-pastelist.js.map +1 -0
- package/dist/backends/launch-all-tmux.d.ts.map +1 -0
- package/dist/backends/launch-all-tmux.js +49 -1
- package/dist/backends/launch-all-tmux.js.map +1 -0
- package/dist/backends/launch-all-windows.d.ts.map +1 -0
- package/dist/backends/launch-all-windows.js +86 -12
- package/dist/backends/launch-all-windows.js.map +1 -0
- package/dist/bare-launch-menu.d.ts.map +1 -0
- package/dist/bare-launch-menu.js +83 -6
- package/dist/bare-launch-menu.js.map +1 -0
- package/dist/claude-launch-args.d.ts.map +1 -0
- package/dist/claude-launch-args.js +34 -1
- package/dist/claude-launch-args.js.map +1 -0
- package/dist/claude.d.ts.map +1 -0
- package/dist/claude.js +486 -14
- package/dist/claude.js.map +1 -0
- package/dist/cleanup-cmd.d.ts +5 -5
- package/dist/cleanup-cmd.d.ts.map +1 -0
- package/dist/cleanup-cmd.js +453 -14
- package/dist/cleanup-cmd.js.map +1 -0
- package/dist/clear-rewake-core.d.ts.map +1 -0
- package/dist/clear-rewake-core.js +23 -2
- package/dist/clear-rewake-core.js.map +1 -0
- package/dist/clear-rewake.d.ts.map +1 -0
- package/dist/clear-rewake.js +19 -1
- package/dist/clear-rewake.js.map +1 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.d.ts.map +1 -0
- package/dist/cli-help.js +104 -56
- package/dist/cli-help.js.map +1 -0
- package/dist/cli-platform.d.ts.map +1 -0
- package/dist/cli-platform.js +106 -4
- package/dist/cli-platform.js.map +1 -0
- package/dist/cli-tool-approval.d.ts +84 -0
- package/dist/cli-tool-approval.d.ts.map +1 -0
- package/dist/cli-tool-approval.js +583 -0
- package/dist/cli-tool-approval.js.map +1 -0
- package/dist/codex-app-server.d.ts.map +1 -0
- package/dist/codex-app-server.js +228 -4
- package/dist/codex-app-server.js.map +1 -0
- package/dist/codex-app-wake.d.ts.map +1 -0
- package/dist/codex-app-wake.js +432 -2
- package/dist/codex-app-wake.js.map +1 -0
- package/dist/codex-launch.d.ts.map +1 -0
- package/dist/codex-launch.js +102 -2
- package/dist/codex-launch.js.map +1 -0
- package/dist/codex-remote.d.ts +6 -1
- package/dist/codex-remote.d.ts.map +1 -0
- package/dist/codex-remote.js +394 -1
- package/dist/codex-remote.js.map +1 -0
- package/dist/codex-wake-resolve.d.ts.map +1 -0
- package/dist/codex-wake-resolve.js +127 -1
- package/dist/codex-wake-resolve.js.map +1 -0
- package/dist/config-utils.d.ts +2 -2
- package/dist/config-utils.d.ts.map +1 -0
- package/dist/config-utils.js +766 -3
- package/dist/config-utils.js.map +1 -0
- package/dist/config.d.ts +98 -61
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +426 -1
- package/dist/config.js.map +1 -0
- package/dist/console-prefix.d.ts.map +1 -0
- package/dist/console-prefix.js +96 -1
- package/dist/console-prefix.js.map +1 -0
- package/dist/cube-name.d.ts.map +1 -0
- package/dist/cube-name.js +67 -1
- package/dist/cube-name.js.map +1 -0
- package/dist/cubes.d.ts +126 -18
- package/dist/cubes.d.ts.map +1 -0
- package/dist/cubes.js +412 -4
- package/dist/cubes.js.map +1 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +71 -1
- package/dist/debug.js.map +1 -0
- package/dist/direct-log.d.ts.map +1 -0
- package/dist/direct-log.js +11 -1
- package/dist/direct-log.js.map +1 -0
- package/dist/docs-sections.d.ts +11 -10
- package/dist/docs-sections.d.ts.map +1 -0
- package/dist/docs-sections.js +113 -3
- package/dist/docs-sections.js.map +1 -0
- package/dist/drone-lifecycle.d.ts +5 -21
- package/dist/drone-lifecycle.d.ts.map +1 -0
- package/dist/drone-lifecycle.js +57 -8
- package/dist/drone-lifecycle.js.map +1 -0
- package/dist/drone-management.d.ts +29 -0
- package/dist/drone-management.d.ts.map +1 -0
- package/dist/drone-management.js +109 -0
- package/dist/drone-management.js.map +1 -0
- package/dist/ensure-mcp-config.d.ts.map +1 -0
- package/dist/ensure-mcp-config.js +36 -1
- package/dist/ensure-mcp-config.js.map +1 -0
- package/dist/evict-drone.d.ts +10 -2
- package/dist/evict-drone.d.ts.map +1 -0
- package/dist/evict-drone.js +60 -1
- package/dist/evict-drone.js.map +1 -0
- package/dist/gc-orphan-inboxes.d.ts.map +1 -0
- package/dist/gc-orphan-inboxes.js +177 -1
- package/dist/gc-orphan-inboxes.js.map +1 -0
- package/dist/inbox-monitor.d.ts +1 -1
- package/dist/inbox-monitor.d.ts.map +1 -0
- package/dist/inbox-monitor.js +856 -3
- package/dist/inbox-monitor.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1034 -37
- package/dist/index.js.map +1 -0
- package/dist/launch-all-cmd.d.ts.map +1 -0
- package/dist/launch-all-cmd.js +377 -37
- package/dist/launch-all-cmd.js.map +1 -0
- package/dist/launch-all-command.d.ts.map +1 -0
- package/dist/launch-all-command.js +24 -1
- package/dist/launch-all-command.js.map +1 -0
- package/dist/launch-all-deps.d.ts +3 -8
- package/dist/launch-all-deps.d.ts.map +1 -0
- package/dist/launch-all-deps.js +99 -1
- package/dist/launch-all-deps.js.map +1 -0
- package/dist/launch-all-discovery.d.ts +1 -0
- package/dist/launch-all-discovery.d.ts.map +1 -0
- package/dist/launch-all-discovery.js +90 -4
- package/dist/launch-all-discovery.js.map +1 -0
- package/dist/launch-all-locks.d.ts.map +1 -0
- package/dist/launch-all-locks.js +54 -1
- package/dist/launch-all-locks.js.map +1 -0
- package/dist/launch-gate.d.ts +2 -2
- package/dist/launch-gate.d.ts.map +1 -0
- package/dist/launch-gate.js +73 -1
- package/dist/launch-gate.js.map +1 -0
- package/dist/lifecycle-log-guard.d.ts.map +1 -0
- package/dist/lifecycle-log-guard.js +93 -2
- package/dist/lifecycle-log-guard.js.map +1 -0
- package/dist/list-roles-render.d.ts.map +1 -0
- package/dist/list-roles-render.js +40 -6
- package/dist/list-roles-render.js.map +1 -0
- package/dist/local-log-routing.d.ts +19 -0
- package/dist/local-log-routing.d.ts.map +1 -0
- package/dist/local-log-routing.js +67 -0
- package/dist/local-log-routing.js.map +1 -0
- package/dist/local-manage-tool-result.d.ts +8 -0
- package/dist/local-manage-tool-result.d.ts.map +1 -0
- package/dist/local-manage-tool-result.js +10 -0
- package/dist/local-manage-tool-result.js.map +1 -0
- package/dist/local-server-cursor.d.ts +34 -0
- package/dist/local-server-cursor.d.ts.map +1 -0
- package/dist/local-server-cursor.js +151 -0
- package/dist/local-server-cursor.js.map +1 -0
- package/dist/log-audit.d.ts.map +1 -0
- package/dist/log-audit.js +194 -3
- package/dist/log-audit.js.map +1 -0
- package/dist/log-stream.d.ts +31 -18
- package/dist/log-stream.d.ts.map +1 -0
- package/dist/log-stream.js +1214 -12
- package/dist/log-stream.js.map +1 -0
- package/dist/model-presets.d.ts.map +1 -0
- package/dist/model-presets.js +19 -1
- package/dist/model-presets.js.map +1 -0
- package/dist/name-validator.d.ts.map +1 -0
- package/dist/name-validator.js +22 -1
- package/dist/name-validator.js.map +1 -0
- package/dist/opencode-drone.d.ts.map +1 -0
- package/dist/opencode-drone.js +438 -4
- package/dist/opencode-drone.js.map +1 -0
- package/dist/opencode-plugin.d.ts +1 -1
- package/dist/opencode-plugin.d.ts.map +1 -0
- package/dist/opencode-plugin.js +29 -4
- package/dist/opencode-plugin.js.map +1 -0
- package/dist/parse-assimilate-args.d.ts +1 -1
- package/dist/parse-assimilate-args.d.ts.map +1 -0
- package/dist/parse-assimilate-args.js +125 -1
- package/dist/parse-assimilate-args.js.map +1 -0
- package/dist/parse-launch-all-args.d.ts.map +1 -0
- package/dist/parse-launch-all-args.js +70 -1
- package/dist/parse-launch-all-args.js.map +1 -0
- package/dist/postinstall-banner.d.ts.map +1 -0
- package/dist/postinstall-banner.js +27 -2
- package/dist/postinstall-banner.js.map +1 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +32 -5
- package/dist/postinstall.js.map +1 -0
- package/dist/readiness-probe.d.ts.map +1 -0
- package/dist/readiness-probe.js +9 -1
- package/dist/readiness-probe.js.map +1 -0
- package/dist/regen-format.d.ts.map +1 -0
- package/dist/regen-format.js +545 -13
- package/dist/regen-format.js.map +1 -0
- package/dist/regen.d.ts.map +1 -0
- package/dist/regen.js +141 -5
- package/dist/regen.js.map +1 -0
- package/dist/remote-client.d.ts +65 -175
- package/dist/remote-client.d.ts.map +1 -0
- package/dist/remote-client.js +942 -1
- package/dist/remote-client.js.map +1 -0
- package/dist/reset-local-seat-cmd.d.ts +50 -0
- package/dist/reset-local-seat-cmd.d.ts.map +1 -0
- package/dist/reset-local-seat-cmd.js +169 -0
- package/dist/reset-local-seat-cmd.js.map +1 -0
- package/dist/role-match.d.ts.map +1 -0
- package/dist/role-match.js +23 -1
- package/dist/role-match.js.map +1 -0
- package/dist/role-resolver.d.ts.map +1 -0
- package/dist/role-resolver.js +60 -1
- package/dist/role-resolver.js.map +1 -0
- package/dist/roster-render.d.ts.map +1 -0
- package/dist/roster-render.js +121 -3
- package/dist/roster-render.js.map +1 -0
- package/dist/seat-probe.d.ts +32 -13
- package/dist/seat-probe.d.ts.map +1 -0
- package/dist/seat-probe.js +78 -1
- package/dist/seat-probe.js.map +1 -0
- package/dist/seat-store.d.ts +76 -0
- package/dist/seat-store.d.ts.map +1 -0
- package/dist/seat-store.js +309 -0
- package/dist/seat-store.js.map +1 -0
- package/dist/seats.d.ts +303 -0
- package/dist/seats.d.ts.map +1 -0
- package/dist/seats.js +551 -0
- package/dist/seats.js.map +1 -0
- package/dist/self-path.d.ts +27 -0
- package/dist/self-path.d.ts.map +1 -0
- package/dist/self-path.js +43 -0
- package/dist/self-path.js.map +1 -0
- package/dist/server-endpoint.d.ts +9 -0
- package/dist/server-endpoint.d.ts.map +1 -0
- package/dist/server-endpoint.js +38 -0
- package/dist/server-endpoint.js.map +1 -0
- package/dist/server-errors.d.ts +50 -0
- package/dist/server-errors.d.ts.map +1 -0
- package/dist/server-errors.js +79 -0
- package/dist/server-errors.js.map +1 -0
- package/dist/server-handshake.d.ts +197 -0
- package/dist/server-handshake.d.ts.map +1 -0
- package/dist/server-handshake.js +505 -0
- package/dist/server-handshake.js.map +1 -0
- package/dist/server-response.d.ts +3 -0
- package/dist/server-response.d.ts.map +1 -0
- package/dist/server-response.js +67 -0
- package/dist/server-response.js.map +1 -0
- package/dist/server-trust.d.ts +14 -0
- package/dist/server-trust.d.ts.map +1 -0
- package/dist/server-trust.js +225 -0
- package/dist/server-trust.js.map +1 -0
- package/dist/setup-confirm.d.ts +3 -3
- package/dist/setup-confirm.d.ts.map +1 -0
- package/dist/setup-confirm.js +126 -2
- package/dist/setup-confirm.js.map +1 -0
- package/dist/setup.d.ts +5 -3
- package/dist/setup.d.ts.map +1 -0
- package/dist/setup.js +169 -46
- package/dist/setup.js.map +1 -0
- package/dist/shell-escape.d.ts.map +1 -0
- package/dist/shell-escape.js +22 -1
- package/dist/shell-escape.js.map +1 -0
- package/dist/spawn.d.ts +2 -2
- package/dist/spawn.d.ts.map +1 -0
- package/dist/spawn.js +29 -10
- package/dist/spawn.js.map +1 -0
- package/dist/startup-services.d.ts +0 -1
- package/dist/startup-services.d.ts.map +1 -0
- package/dist/startup-services.js +26 -1
- package/dist/startup-services.js.map +1 -0
- package/dist/stream-owner.d.ts +6 -2
- package/dist/stream-owner.d.ts.map +1 -0
- package/dist/stream-owner.js +547 -2
- package/dist/stream-owner.js.map +1 -0
- package/dist/stream-status.d.ts +1 -1
- package/dist/stream-status.d.ts.map +1 -0
- package/dist/stream-status.js +268 -3
- package/dist/stream-status.js.map +1 -0
- package/dist/sync-roles-render.d.ts.map +1 -0
- package/dist/sync-roles-render.js +118 -3
- package/dist/sync-roles-render.js.map +1 -0
- package/dist/sync.d.ts +1 -1
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +286 -22
- package/dist/sync.js.map +1 -0
- package/dist/terminal-title.d.ts.map +1 -0
- package/dist/terminal-title.js +68 -1
- package/dist/terminal-title.js.map +1 -0
- package/dist/token-store.d.ts +17 -111
- package/dist/token-store.d.ts.map +1 -0
- package/dist/token-store.js +81 -1
- package/dist/token-store.js.map +1 -0
- package/dist/tool-manifest.d.ts +4 -8
- package/dist/tool-manifest.d.ts.map +1 -0
- package/dist/tool-manifest.js +554 -1
- package/dist/tool-manifest.js.map +1 -0
- package/dist/tool-scope.d.ts +8 -12
- package/dist/tool-scope.d.ts.map +1 -0
- package/dist/tool-scope.js +97 -1
- package/dist/tool-scope.js.map +1 -0
- package/dist/types.d.ts +1 -5
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/unknown-subcommand.d.ts +1 -1
- package/dist/unknown-subcommand.d.ts.map +1 -0
- package/dist/unknown-subcommand.js +37 -1
- package/dist/unknown-subcommand.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +78 -2
- package/dist/version.js.map +1 -0
- package/dist/working-repo.d.ts.map +1 -0
- package/dist/working-repo.js +81 -1
- package/dist/working-repo.js.map +1 -0
- package/dist/worktree-lifecycle.d.ts.map +1 -0
- package/dist/worktree-lifecycle.js +205 -2
- package/dist/worktree-lifecycle.js.map +1 -0
- package/docs/EXTRACTION_PROVENANCE.md +40 -0
- package/docs/LOCAL_SERVER.md +103 -0
- package/docs/RELEASING.md +120 -0
- package/package.json +52 -34
- package/src/agent-runtime.ts +69 -0
- package/src/assimilate-cmd.ts +1864 -0
- package/src/assimilate-deps.ts +444 -0
- package/src/assimilate-guard.ts +84 -0
- package/src/assimilate-welcome.ts +39 -0
- package/src/auth-env.ts +21 -0
- package/src/backends/launch-all-pastelist.ts +18 -0
- package/src/backends/launch-all-tmux.ts +75 -0
- package/src/backends/launch-all-windows.ts +110 -0
- package/src/bare-launch-menu.ts +148 -0
- package/src/claude-launch-args.ts +35 -0
- package/src/claude.ts +561 -0
- package/src/cleanup-cmd.ts +581 -0
- package/src/clear-rewake-core.ts +34 -0
- package/src/clear-rewake.ts +18 -0
- package/src/cli-help.ts +116 -0
- package/src/cli-platform.ts +127 -0
- package/src/cli-tool-approval.ts +716 -0
- package/src/codex-app-server.ts +238 -0
- package/src/codex-app-wake.ts +512 -0
- package/src/codex-launch.ts +140 -0
- package/src/codex-remote.ts +500 -0
- package/src/codex-wake-resolve.ts +155 -0
- package/src/config-utils.ts +811 -0
- package/src/config.ts +595 -0
- package/src/console-prefix.ts +99 -0
- package/src/cube-name.ts +69 -0
- package/src/cubes.ts +583 -0
- package/src/debug.ts +73 -0
- package/src/direct-log.ts +9 -0
- package/src/docs-sections.ts +129 -0
- package/src/drone-lifecycle.ts +62 -0
- package/src/drone-management.ts +162 -0
- package/src/ensure-mcp-config.ts +54 -0
- package/src/evict-drone.ts +82 -0
- package/src/gc-orphan-inboxes.ts +229 -0
- package/src/inbox-monitor.ts +1005 -0
- package/src/index.ts +1201 -0
- package/src/launch-all-cmd.ts +450 -0
- package/src/launch-all-command.ts +30 -0
- package/src/launch-all-deps.ts +188 -0
- package/src/launch-all-discovery.ts +118 -0
- package/src/launch-all-locks.ts +74 -0
- package/src/launch-gate.ts +80 -0
- package/src/lifecycle-log-guard.ts +144 -0
- package/src/list-roles-render.ts +52 -0
- package/src/local-log-routing.ts +106 -0
- package/src/local-manage-tool-result.ts +10 -0
- package/src/local-server-cursor.ts +188 -0
- package/src/log-audit.ts +207 -0
- package/src/log-stream.ts +1505 -0
- package/src/model-presets.ts +22 -0
- package/src/name-validator.ts +23 -0
- package/src/opencode-drone.ts +528 -0
- package/src/opencode-plugin.ts +38 -0
- package/src/parse-assimilate-args.ts +125 -0
- package/src/parse-launch-all-args.ts +100 -0
- package/src/postinstall-banner.ts +37 -0
- package/src/postinstall.ts +36 -0
- package/src/readiness-probe.ts +12 -0
- package/src/regen-format.ts +651 -0
- package/src/regen.ts +150 -0
- package/src/remote-client.ts +1379 -0
- package/src/reset-local-seat-cmd.ts +227 -0
- package/src/role-match.ts +29 -0
- package/src/role-resolver.ts +93 -0
- package/src/roster-render.ts +196 -0
- package/src/seat-probe.ts +115 -0
- package/src/seat-store.ts +333 -0
- package/src/seats.ts +741 -0
- package/src/self-path.ts +51 -0
- package/src/server-endpoint.ts +38 -0
- package/src/server-errors.ts +93 -0
- package/src/server-handshake.ts +783 -0
- package/src/server-response.ts +76 -0
- package/src/server-trust.ts +245 -0
- package/src/setup-confirm.ts +171 -0
- package/src/setup.ts +186 -0
- package/src/shell-escape.ts +22 -0
- package/src/spawn.ts +31 -0
- package/src/startup-services.ts +36 -0
- package/src/stream-owner.ts +686 -0
- package/src/stream-status.ts +337 -0
- package/src/sync-roles-render.ts +163 -0
- package/src/sync.ts +363 -0
- package/src/terminal-title.ts +76 -0
- package/src/token-store.ts +102 -0
- package/src/tool-manifest.ts +610 -0
- package/src/tool-scope.ts +110 -0
- package/src/types.ts +5 -0
- package/src/unknown-subcommand.ts +35 -0
- package/src/version.ts +82 -0
- package/src/working-repo.ts +99 -0
- package/src/worktree-lifecycle.ts +254 -0
- package/dist/auth-recovery.d.ts +0 -33
- package/dist/auth-recovery.js +0 -1
- package/dist/auth.d.ts +0 -103
- package/dist/auth.js +0 -39
- package/dist/device-auth.d.ts +0 -75
- package/dist/device-auth.js +0 -1
- package/dist/drone-address.d.ts +0 -26
- package/dist/drone-address.js +0 -1
- package/dist/get-started.d.ts +0 -20
- package/dist/get-started.js +0 -2
- package/dist/health-beat.d.ts +0 -134
- package/dist/health-beat.js +0 -1
- package/dist/role-section.d.ts +0 -110
- package/dist/role-section.js +0 -8
- package/dist/setup-action.d.ts +0 -20
- package/dist/setup-action.js +0 -1
- package/dist/stale-version-check.d.ts +0 -40
- package/dist/stale-version-check.js +0 -1
- package/dist/subscription-retry.d.ts +0 -40
- package/dist/subscription-retry.js +0 -1
- package/dist/templates.d.ts +0 -107
- package/dist/templates.js +0 -359
- package/dist/token-crypto.d.ts +0 -50
- package/dist/token-crypto.js +0 -1
package/src/seats.ts
ADDED
|
@@ -0,0 +1,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The unified 0600 seat store (Queen rescope, scope A) — `seats.json`.
|
|
3
|
+
*
|
|
4
|
+
* Collapses the per-seat drone-session CREDENTIAL and its worktree BINDING into
|
|
5
|
+
* ONE atomically-written record, keyed by the deterministic seat ref
|
|
6
|
+
* (origin+trust+cube+role+operation). Because a record's `state` and its
|
|
7
|
+
* `worktree` binding are written together in a single `withStore` commit, the
|
|
8
|
+
* invariant "an ACTIVE credential without a binding" is UNREACHABLE BY
|
|
9
|
+
* CONSTRUCTION: PREPARE mints `{credential, state:'pending'}` with NO worktree;
|
|
10
|
+
* FINALIZE sets `state:'active' + server metadata + worktree + display` in one
|
|
11
|
+
* commit; a reset deletes the whole record so credential+binding vanish together
|
|
12
|
+
* (there is no cross-store 'partial').
|
|
13
|
+
*
|
|
14
|
+
* The single store flock (seat-store.withStore) serializes every read-compare-
|
|
15
|
+
* write; there is no second lock and no composite. The raw bearer rests ONLY in
|
|
16
|
+
* the 0600 file and is never returned past this module except by the sole
|
|
17
|
+
* hydration reader; every other observation is digest-only.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import os from 'os';
|
|
21
|
+
import path from 'path';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { readStoreFile, withStore } from './seat-store.js';
|
|
24
|
+
|
|
25
|
+
export const SEATS_FILE = path.join(os.homedir(), '.config', 'borgmcp', 'seats.json');
|
|
26
|
+
const SEATS_VERSION = 1 as const;
|
|
27
|
+
const REF_RE = /^borg-server-session:[a-f0-9]{64}$/;
|
|
28
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
29
|
+
|
|
30
|
+
export interface SeatOperation {
|
|
31
|
+
projectRoot: string;
|
|
32
|
+
kind: 'seat' | 'sibling';
|
|
33
|
+
operationKey: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One seat = its credential + binding + display, written as one atomic unit. */
|
|
37
|
+
export interface SeatRecord {
|
|
38
|
+
// identity (the ref inputs)
|
|
39
|
+
origin: string;
|
|
40
|
+
trustIdentity: string;
|
|
41
|
+
cubeId: string;
|
|
42
|
+
roleId: string;
|
|
43
|
+
operation: SeatOperation;
|
|
44
|
+
// credential
|
|
45
|
+
credential: string;
|
|
46
|
+
state: 'pending' | 'active';
|
|
47
|
+
// server metadata (active only)
|
|
48
|
+
droneId?: string;
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
expiresAt?: string;
|
|
51
|
+
// binding + display (set atomically at FINALIZE; absent while pending)
|
|
52
|
+
worktree?: string;
|
|
53
|
+
name?: string;
|
|
54
|
+
droneLabel?: string;
|
|
55
|
+
roleName?: string;
|
|
56
|
+
roleClass?: 'queen' | 'worker';
|
|
57
|
+
isHumanSeat?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface SeatsFile {
|
|
61
|
+
version: number;
|
|
62
|
+
seats: Record<string, SeatRecord>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The deterministic per-seat ref (identical algorithm to the retired keychain account). */
|
|
66
|
+
export function seatRef(input: {
|
|
67
|
+
origin: string;
|
|
68
|
+
trustIdentity: string;
|
|
69
|
+
cubeId: string;
|
|
70
|
+
roleId: string;
|
|
71
|
+
operation: SeatOperation;
|
|
72
|
+
}): string {
|
|
73
|
+
const binding = createHash('sha256')
|
|
74
|
+
.update(input.origin)
|
|
75
|
+
.update('\0')
|
|
76
|
+
.update(input.trustIdentity)
|
|
77
|
+
.update('\0')
|
|
78
|
+
.update(input.cubeId)
|
|
79
|
+
.update('\0')
|
|
80
|
+
.update(input.roleId)
|
|
81
|
+
.update('\0')
|
|
82
|
+
.update(input.operation.projectRoot)
|
|
83
|
+
.update('\0')
|
|
84
|
+
.update(input.operation.kind)
|
|
85
|
+
.update('\0')
|
|
86
|
+
.update(input.operation.operationKey)
|
|
87
|
+
.digest('hex');
|
|
88
|
+
return `borg-server-session:${binding}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function digestOf(bearer: string): string {
|
|
92
|
+
return createHash('sha256').update(bearer).digest('hex');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emptyStore(): SeatsFile {
|
|
96
|
+
return { version: SEATS_VERSION, seats: {} };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const ROLE_CLASSES = new Set(['queen', 'worker']);
|
|
100
|
+
const OPERATION_KINDS = new Set(['seat', 'sibling']);
|
|
101
|
+
|
|
102
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
103
|
+
return typeof value === 'string' && value.length > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function isValidOperation(value: unknown): value is SeatOperation {
|
|
107
|
+
if (value === null || typeof value !== 'object') return false;
|
|
108
|
+
const op = value as Record<string, unknown>;
|
|
109
|
+
return (
|
|
110
|
+
isNonEmptyString(op.projectRoot) &&
|
|
111
|
+
typeof op.kind === 'string' &&
|
|
112
|
+
OPERATION_KINDS.has(op.kind) &&
|
|
113
|
+
isNonEmptyString(op.operationKey)
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* CR#2: FULL per-entry validation. Every key/value/invariant of a seat record is
|
|
119
|
+
* checked — the ref is well-formed and self-consistent (the map key equals the
|
|
120
|
+
* record's derived ref), state ∈ {pending,active}, the credential is a non-empty
|
|
121
|
+
* string, the operation is well-shaped, an ACTIVE record carries ALL its required
|
|
122
|
+
* server + binding fields, and a PENDING record carries NO active-only fields
|
|
123
|
+
* (session id / expiry). A single invalid entry ⇒ the whole store is rejected
|
|
124
|
+
* (fail closed at the caller, bytes preserved) — never a silent cast.
|
|
125
|
+
*/
|
|
126
|
+
function isValidSeatRecord(ref: string, value: unknown): value is SeatRecord {
|
|
127
|
+
if (!REF_RE.test(ref)) return false;
|
|
128
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
129
|
+
const r = value as Record<string, unknown>;
|
|
130
|
+
// Identity + credential + operation.
|
|
131
|
+
if (
|
|
132
|
+
!isNonEmptyString(r.origin) ||
|
|
133
|
+
!isNonEmptyString(r.trustIdentity) ||
|
|
134
|
+
!isNonEmptyString(r.cubeId) ||
|
|
135
|
+
!isNonEmptyString(r.roleId) ||
|
|
136
|
+
!isValidOperation(r.operation) ||
|
|
137
|
+
!isNonEmptyString(r.credential)
|
|
138
|
+
) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (r.state !== 'pending' && r.state !== 'active') return false;
|
|
142
|
+
// Optional display/typed fields, validated when present.
|
|
143
|
+
if (r.name !== undefined && typeof r.name !== 'string') return false;
|
|
144
|
+
if (r.droneLabel !== undefined && typeof r.droneLabel !== 'string') return false;
|
|
145
|
+
if (r.roleName !== undefined && typeof r.roleName !== 'string') return false;
|
|
146
|
+
if (r.roleClass !== undefined && (typeof r.roleClass !== 'string' || !ROLE_CLASSES.has(r.roleClass))) return false;
|
|
147
|
+
if (r.isHumanSeat !== undefined && typeof r.isHumanSeat !== 'boolean') return false;
|
|
148
|
+
if (r.worktree !== undefined && typeof r.worktree !== 'string') return false;
|
|
149
|
+
if (r.droneId !== undefined && (typeof r.droneId !== 'string' || !UUID_RE.test(r.droneId))) return false;
|
|
150
|
+
if (r.sessionId !== undefined && (typeof r.sessionId !== 'string' || !UUID_RE.test(r.sessionId))) return false;
|
|
151
|
+
if (
|
|
152
|
+
r.expiresAt !== undefined &&
|
|
153
|
+
(typeof r.expiresAt !== 'string' || !Number.isFinite(Date.parse(r.expiresAt)))
|
|
154
|
+
) {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
// State-consistency invariants (no inconsistent active|pending).
|
|
158
|
+
if (r.state === 'active') {
|
|
159
|
+
// An ACTIVE record MUST carry its full server session + worktree binding.
|
|
160
|
+
if (
|
|
161
|
+
typeof r.droneId !== 'string' ||
|
|
162
|
+
typeof r.sessionId !== 'string' ||
|
|
163
|
+
typeof r.expiresAt !== 'string' ||
|
|
164
|
+
typeof r.worktree !== 'string' ||
|
|
165
|
+
typeof r.name !== 'string' ||
|
|
166
|
+
typeof r.droneLabel !== 'string'
|
|
167
|
+
) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
// A PENDING record must NOT carry active-only server session fields.
|
|
172
|
+
if (r.sessionId !== undefined || r.expiresAt !== undefined) return false;
|
|
173
|
+
}
|
|
174
|
+
// The map key must equal the record's derived ref (no cross-key aliasing).
|
|
175
|
+
return seatRef(value as SeatRecord) === ref;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* CR4 + CR#2: parse + FULL version/schema/per-entry validation. Returns null (→ fail
|
|
180
|
+
* closed at the caller, bytes preserved) for any malformed JSON, wrong version, or
|
|
181
|
+
* ANY invalid entry; never throws and never coerces an invalid file into a
|
|
182
|
+
* valid-looking or empty store.
|
|
183
|
+
*/
|
|
184
|
+
function parseStore(raw: string): SeatsFile | null {
|
|
185
|
+
let parsed: unknown;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(raw);
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
192
|
+
const candidate = parsed as { version?: unknown; seats?: unknown };
|
|
193
|
+
if (
|
|
194
|
+
candidate.version === SEATS_VERSION &&
|
|
195
|
+
candidate.seats &&
|
|
196
|
+
typeof candidate.seats === 'object' &&
|
|
197
|
+
!Array.isArray(candidate.seats)
|
|
198
|
+
) {
|
|
199
|
+
const seats = candidate.seats as Record<string, unknown>;
|
|
200
|
+
for (const [ref, record] of Object.entries(seats)) {
|
|
201
|
+
if (!isValidSeatRecord(ref, record)) return null;
|
|
202
|
+
}
|
|
203
|
+
return { version: SEATS_VERSION, seats: seats as Record<string, SeatRecord> };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function readStore(): Promise<SeatsFile> {
|
|
210
|
+
const raw = await readStoreFile(SEATS_FILE);
|
|
211
|
+
// CR4 fail-closed: ENOENT alone initializes empty. A present-but-malformed /
|
|
212
|
+
// wrong-version / schema-invalid store MUST NOT read as empty (a following commit
|
|
213
|
+
// would erase every seat); throw so the on-disk bytes are preserved.
|
|
214
|
+
if (raw === null) return emptyStore();
|
|
215
|
+
const parsed = parseStore(raw);
|
|
216
|
+
if (parsed === null) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'Borg seat store is malformed or has an unsupported version; refusing to read it',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return parsed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** True iff `record` is a well-formed seat for `ref` + `binding` (origin/trust/cube). */
|
|
225
|
+
function recordMatches(
|
|
226
|
+
record: SeatRecord | undefined,
|
|
227
|
+
ref: string,
|
|
228
|
+
binding: { origin: string; trustIdentity: string; cubeId: string },
|
|
229
|
+
): record is SeatRecord {
|
|
230
|
+
return (
|
|
231
|
+
record !== undefined &&
|
|
232
|
+
record.origin === binding.origin &&
|
|
233
|
+
record.trustIdentity === binding.trustIdentity &&
|
|
234
|
+
record.cubeId === binding.cubeId &&
|
|
235
|
+
(record.state === 'pending' || record.state === 'active') &&
|
|
236
|
+
typeof record.credential === 'string' &&
|
|
237
|
+
seatRef(record) === ref
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Observation (digest-only; raw bearer never returned) ────────────────────
|
|
242
|
+
|
|
243
|
+
export type SeatObservation =
|
|
244
|
+
| { state: 'active'; digest: string; droneId: string }
|
|
245
|
+
| { state: 'pending'; digest: string }
|
|
246
|
+
| { state: 'absent' };
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* TYPED, token-safe observation of the record at `ref` (CR#3). Lock-free read
|
|
250
|
+
* (atomic rename → complete file); the authoritative delete/activate re-reads
|
|
251
|
+
* under the flock. Returns only a digest + drone id, never the raw bearer.
|
|
252
|
+
*/
|
|
253
|
+
export async function observeSeat(
|
|
254
|
+
ref: string,
|
|
255
|
+
binding: { origin: string; trustIdentity: string; cubeId: string },
|
|
256
|
+
): Promise<SeatObservation> {
|
|
257
|
+
if (!REF_RE.test(ref)) return { state: 'absent' };
|
|
258
|
+
const store = await readStore();
|
|
259
|
+
const record = store.seats[ref];
|
|
260
|
+
if (!recordMatches(record, ref, binding)) return { state: 'absent' };
|
|
261
|
+
const digest = digestOf(record.credential);
|
|
262
|
+
if (record.state === 'active') {
|
|
263
|
+
return { state: 'active', digest, droneId: typeof record.droneId === 'string' ? record.droneId : '' };
|
|
264
|
+
}
|
|
265
|
+
return { state: 'pending', digest };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The SOLE raw-bearer reader (SR-seven #5). Returns the ACTIVE bearer for `ref`
|
|
270
|
+
* iff the record is active AND the binding matches — used only to hydrate the
|
|
271
|
+
* live seat for authenticated requests. Every other caller observes digest-only.
|
|
272
|
+
*/
|
|
273
|
+
export async function getActiveSeatCredential(
|
|
274
|
+
ref: string,
|
|
275
|
+
binding: { origin: string; trustIdentity: string; cubeId: string },
|
|
276
|
+
): Promise<string | null> {
|
|
277
|
+
if (!REF_RE.test(ref)) return null;
|
|
278
|
+
const store = await readStore();
|
|
279
|
+
const record = store.seats[ref];
|
|
280
|
+
if (!recordMatches(record, ref, binding) || record.state !== 'active') return null;
|
|
281
|
+
return record.credential;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ─── PREPARE / mint (no worktree yet) ────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Mint the client bearer for one seat, or return the existing record (pending or
|
|
288
|
+
* active) so a lost-response retry re-sends the identical bearer. The minted
|
|
289
|
+
* record has NO worktree binding — FINALIZE adds it atomically with activation,
|
|
290
|
+
* so a pending record is never a live binding.
|
|
291
|
+
*/
|
|
292
|
+
export async function mintPendingSeat(input: {
|
|
293
|
+
origin: string;
|
|
294
|
+
trustIdentity: string;
|
|
295
|
+
cubeId: string;
|
|
296
|
+
roleId: string;
|
|
297
|
+
operation: SeatOperation;
|
|
298
|
+
credential: string;
|
|
299
|
+
}): Promise<SeatRecord> {
|
|
300
|
+
const ref = seatRef(input);
|
|
301
|
+
return withStore<SeatsFile, SeatRecord>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
302
|
+
const existing = txn.data.seats[ref];
|
|
303
|
+
if (recordMatches(existing, ref, { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId })) {
|
|
304
|
+
return existing;
|
|
305
|
+
}
|
|
306
|
+
const record: SeatRecord = {
|
|
307
|
+
origin: input.origin,
|
|
308
|
+
trustIdentity: input.trustIdentity,
|
|
309
|
+
cubeId: input.cubeId,
|
|
310
|
+
roleId: input.roleId,
|
|
311
|
+
operation: input.operation,
|
|
312
|
+
credential: input.credential,
|
|
313
|
+
state: 'pending',
|
|
314
|
+
};
|
|
315
|
+
txn.data.seats[ref] = record;
|
|
316
|
+
await txn.commit();
|
|
317
|
+
return record;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── PREPARE (revalidate typed expectation + mint, under ONE flock) ──────────
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Typed prepare-time expectation for the single-store attach (CR#1). EXACT — the
|
|
325
|
+
* exact prior ACTIVE record must STILL hold at prepare time (its ref, and, when
|
|
326
|
+
* pinned, its drone id and its live-bearer digest). ABSENT — no ACTIVE record may
|
|
327
|
+
* hold this seat (a fresh enroll / a fresh sibling seat). Field name `credentialRef`
|
|
328
|
+
* is kept for call-site parity with the retired cross-store ExpectedBinding.
|
|
329
|
+
*/
|
|
330
|
+
export type SeatExpectation =
|
|
331
|
+
| { kind: 'exact'; credentialRef: string; droneId?: string; sessionDigest?: string }
|
|
332
|
+
| { kind: 'absent' };
|
|
333
|
+
|
|
334
|
+
export type PrepareSeatOutcome =
|
|
335
|
+
| { ok: true; record: SeatRecord }
|
|
336
|
+
| { ok: false; reason: 'expectation-mismatch' };
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* CR#1 PREPARE-time abort in the single-store model. Under ONE store flock:
|
|
340
|
+
* REVALIDATE the typed expectation against the record currently at the seat ref
|
|
341
|
+
* (EXACT: the exact prior ACTIVE record must still hold — ref, optional drone id,
|
|
342
|
+
* optional live-bearer digest; ABSENT: no ACTIVE record may hold this seat), then
|
|
343
|
+
* — only if it holds — MINT the pending record in the SAME lock hold. A pre-existing
|
|
344
|
+
* valid record (pending from a lost-response retry, or the active record being
|
|
345
|
+
* reattached) is REUSED so the identical bearer is re-sent. `scrubBeforeMint`
|
|
346
|
+
* discards a known-invalid saved record (eviction remint) before minting, still
|
|
347
|
+
* under the one flock. On a mismatch NOTHING is minted or scrubbed (abort).
|
|
348
|
+
*/
|
|
349
|
+
export async function prepareSeat(input: {
|
|
350
|
+
expected: SeatExpectation;
|
|
351
|
+
/** Default true. When false (a fresh sibling target key that cannot yet hold a
|
|
352
|
+
* record) the mint runs under the flock but no expectation is revalidated. */
|
|
353
|
+
revalidate?: boolean;
|
|
354
|
+
/** Eviction remint: delete the known-invalid saved record for this seat BEFORE
|
|
355
|
+
* minting a fresh bearer, still inside the one flock. */
|
|
356
|
+
scrubBeforeMint?: boolean;
|
|
357
|
+
seed: {
|
|
358
|
+
origin: string;
|
|
359
|
+
trustIdentity: string;
|
|
360
|
+
cubeId: string;
|
|
361
|
+
roleId: string;
|
|
362
|
+
operation: SeatOperation;
|
|
363
|
+
credential: string;
|
|
364
|
+
};
|
|
365
|
+
}): Promise<PrepareSeatOutcome> {
|
|
366
|
+
const { expected, revalidate = true, scrubBeforeMint = false, seed } = input;
|
|
367
|
+
const ref = seatRef(seed);
|
|
368
|
+
const binding = { origin: seed.origin, trustIdentity: seed.trustIdentity, cubeId: seed.cubeId };
|
|
369
|
+
return withStore<SeatsFile, PrepareSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
370
|
+
if (revalidate) {
|
|
371
|
+
const prior = txn.data.seats[ref];
|
|
372
|
+
let mismatch: boolean;
|
|
373
|
+
if (expected.kind === 'exact') {
|
|
374
|
+
const holds =
|
|
375
|
+
recordMatches(prior, ref, binding) &&
|
|
376
|
+
prior.state === 'active' &&
|
|
377
|
+
ref === expected.credentialRef &&
|
|
378
|
+
(expected.droneId === undefined || prior.droneId === expected.droneId) &&
|
|
379
|
+
(expected.sessionDigest === undefined || digestOf(prior.credential) === expected.sessionDigest);
|
|
380
|
+
mismatch = !holds;
|
|
381
|
+
} else {
|
|
382
|
+
// ABSENT: an ACTIVE record holding this seat is a mismatch. A PENDING record
|
|
383
|
+
// (a lost-response retry / crash-in-gap) is NOT a live binding and is reused
|
|
384
|
+
// below so the identical bearer is re-sent.
|
|
385
|
+
mismatch = recordMatches(prior, ref, binding) && prior.state === 'active';
|
|
386
|
+
}
|
|
387
|
+
if (mismatch) return { ok: false as const, reason: 'expectation-mismatch' as const };
|
|
388
|
+
}
|
|
389
|
+
if (scrubBeforeMint) {
|
|
390
|
+
delete txn.data.seats[ref];
|
|
391
|
+
}
|
|
392
|
+
const existing = txn.data.seats[ref];
|
|
393
|
+
if (recordMatches(existing, ref, binding)) {
|
|
394
|
+
// Idempotent reuse: re-send the exact bearer the server already digest-bound
|
|
395
|
+
// (a lost-response retry, a crash-in-gap pending, or an active reattach).
|
|
396
|
+
return { ok: true as const, record: existing };
|
|
397
|
+
}
|
|
398
|
+
const record: SeatRecord = {
|
|
399
|
+
origin: seed.origin,
|
|
400
|
+
trustIdentity: seed.trustIdentity,
|
|
401
|
+
cubeId: seed.cubeId,
|
|
402
|
+
roleId: seed.roleId,
|
|
403
|
+
operation: seed.operation,
|
|
404
|
+
credential: seed.credential,
|
|
405
|
+
state: 'pending',
|
|
406
|
+
};
|
|
407
|
+
txn.data.seats[ref] = record;
|
|
408
|
+
await txn.commit();
|
|
409
|
+
return { ok: true as const, record };
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─── FINALIZE (activate + bind in ONE commit) ────────────────────────────────
|
|
414
|
+
|
|
415
|
+
export type ActivateSeatOutcome = 'activated' | 'missing' | 'replaced';
|
|
416
|
+
|
|
417
|
+
/** The worktree binding + display supplied at FINALIZE (known only once the target
|
|
418
|
+
* worktree is decided). Merged atomically with activation by activateAndBindSeat. */
|
|
419
|
+
export interface SeatBinding {
|
|
420
|
+
worktree: string;
|
|
421
|
+
name: string;
|
|
422
|
+
droneLabel: string;
|
|
423
|
+
roleName?: string;
|
|
424
|
+
roleClass?: 'queen' | 'worker';
|
|
425
|
+
isHumanSeat?: boolean;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* ATOMIC compare-and-activate + bind (CR#2 + the scope-A collapse). Under ONE
|
|
430
|
+
* flock: the exact pending/active record whose bearer digest matches
|
|
431
|
+
* `expectedPendingDigest` is stamped ACTIVE with the server metadata AND the
|
|
432
|
+
* worktree binding + display, in a single commit. A same-ref replacement or a
|
|
433
|
+
* missing record fails closed (`replaced`/`missing`) — server metadata for bearer
|
|
434
|
+
* A is never bound onto bearer B, and the digest guard holds even on the
|
|
435
|
+
* no-expectation-digest paths (the SENT bearer's digest is always pinned).
|
|
436
|
+
* Because `state:'active'` and `worktree` land together, ACTIVE-without-binding
|
|
437
|
+
* is unreachable by construction.
|
|
438
|
+
*/
|
|
439
|
+
export async function activateAndBindSeat(input: {
|
|
440
|
+
origin: string;
|
|
441
|
+
trustIdentity: string;
|
|
442
|
+
cubeId: string;
|
|
443
|
+
roleId: string;
|
|
444
|
+
operation: SeatOperation;
|
|
445
|
+
droneId: string;
|
|
446
|
+
sessionId: string;
|
|
447
|
+
expiresAt: string;
|
|
448
|
+
expectedPendingDigest: string;
|
|
449
|
+
worktree: string;
|
|
450
|
+
name: string;
|
|
451
|
+
droneLabel: string;
|
|
452
|
+
roleName?: string;
|
|
453
|
+
roleClass?: 'queen' | 'worker';
|
|
454
|
+
isHumanSeat?: boolean;
|
|
455
|
+
}): Promise<ActivateSeatOutcome> {
|
|
456
|
+
if (!UUID_RE.test(input.droneId) || !UUID_RE.test(input.sessionId)) {
|
|
457
|
+
throw new Error('invalid Borg server session identity');
|
|
458
|
+
}
|
|
459
|
+
if (typeof input.expiresAt !== 'string' || !Number.isFinite(Date.parse(input.expiresAt))) {
|
|
460
|
+
throw new Error('invalid Borg server session expiry');
|
|
461
|
+
}
|
|
462
|
+
const ref = seatRef(input);
|
|
463
|
+
const binding = { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId };
|
|
464
|
+
return withStore<SeatsFile, ActivateSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
465
|
+
const record = txn.data.seats[ref];
|
|
466
|
+
if (!recordMatches(record, ref, binding)) return 'missing';
|
|
467
|
+
if (digestOf(record.credential) !== input.expectedPendingDigest) return 'replaced';
|
|
468
|
+
txn.data.seats[ref] = {
|
|
469
|
+
...record,
|
|
470
|
+
state: 'active',
|
|
471
|
+
droneId: input.droneId,
|
|
472
|
+
sessionId: input.sessionId,
|
|
473
|
+
expiresAt: input.expiresAt,
|
|
474
|
+
worktree: input.worktree,
|
|
475
|
+
name: input.name,
|
|
476
|
+
droneLabel: input.droneLabel,
|
|
477
|
+
...(input.roleName !== undefined ? { roleName: input.roleName } : {}),
|
|
478
|
+
...(input.roleClass !== undefined ? { roleClass: input.roleClass } : {}),
|
|
479
|
+
...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
|
|
480
|
+
};
|
|
481
|
+
await txn.commit();
|
|
482
|
+
return 'activated';
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── CR#2: bind a PENDING record to a preserved worktree (no activation) ─────
|
|
487
|
+
|
|
488
|
+
export type BindPendingSeatOutcome = 'bound' | 'missing' | 'replaced';
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* CR#2: bind an existing PENDING record to a worktree WITHOUT activating it. On a
|
|
492
|
+
* SIBLING attach whose atomic activate+bind failed, the spawned worktree is
|
|
493
|
+
* preserved for an operator-driven rerun (CR#5). This stamps the worktree locator +
|
|
494
|
+
* display (and, if known, the drone id) onto the EXACT digest-matched PENDING record
|
|
495
|
+
* so the rerun FROM that worktree can DISCOVER and RESUME it — re-sending the
|
|
496
|
+
* identical bearer the server already digest-bound, converging on the SAME seat (no
|
|
497
|
+
* ghost). The record STAYS `state:'pending'`: it is non-hydratable as a live seat
|
|
498
|
+
* (getActiveSeatForWorktree still requires state==='active'), so this introduces no
|
|
499
|
+
* ACTIVE-without-activation state. Under ONE store flock (CR#3), fail-closed (CR#4):
|
|
500
|
+
* a missing / already-ACTIVE / same-ref-replaced record is a typed no-op — never
|
|
501
|
+
* binds a worktree onto a different bearer, never mutates an ACTIVE record.
|
|
502
|
+
*/
|
|
503
|
+
export async function bindPendingSeatToWorktree(input: {
|
|
504
|
+
origin: string;
|
|
505
|
+
trustIdentity: string;
|
|
506
|
+
cubeId: string;
|
|
507
|
+
roleId: string;
|
|
508
|
+
operation: SeatOperation;
|
|
509
|
+
expectedPendingDigest: string;
|
|
510
|
+
droneId?: string;
|
|
511
|
+
worktree: string;
|
|
512
|
+
name: string;
|
|
513
|
+
droneLabel: string;
|
|
514
|
+
roleName?: string;
|
|
515
|
+
roleClass?: 'queen' | 'worker';
|
|
516
|
+
isHumanSeat?: boolean;
|
|
517
|
+
}): Promise<BindPendingSeatOutcome> {
|
|
518
|
+
if (input.droneId !== undefined && !UUID_RE.test(input.droneId)) {
|
|
519
|
+
throw new Error('invalid Borg server session identity');
|
|
520
|
+
}
|
|
521
|
+
const ref = seatRef(input);
|
|
522
|
+
const binding = { origin: input.origin, trustIdentity: input.trustIdentity, cubeId: input.cubeId };
|
|
523
|
+
return withStore<SeatsFile, BindPendingSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
524
|
+
const record = txn.data.seats[ref];
|
|
525
|
+
if (!recordMatches(record, ref, binding)) return 'missing';
|
|
526
|
+
// Never touch an ACTIVE record, and never bind a worktree onto a same-ref
|
|
527
|
+
// replacement — the record's live digest must still equal the sent bearer's.
|
|
528
|
+
if (record.state !== 'pending' || digestOf(record.credential) !== input.expectedPendingDigest) {
|
|
529
|
+
return 'replaced';
|
|
530
|
+
}
|
|
531
|
+
txn.data.seats[ref] = {
|
|
532
|
+
...record,
|
|
533
|
+
// state STAYS 'pending' — NOT activated. Only the worktree locator + display
|
|
534
|
+
// (+ drone id) land, so the preserved worktree owns a discoverable, resumable
|
|
535
|
+
// pending record without becoming a live/hydratable binding.
|
|
536
|
+
worktree: input.worktree,
|
|
537
|
+
name: input.name,
|
|
538
|
+
droneLabel: input.droneLabel,
|
|
539
|
+
...(input.droneId !== undefined ? { droneId: input.droneId } : {}),
|
|
540
|
+
...(input.roleName !== undefined ? { roleName: input.roleName } : {}),
|
|
541
|
+
...(input.roleClass !== undefined ? { roleClass: input.roleClass } : {}),
|
|
542
|
+
...(input.isHumanSeat !== undefined ? { isHumanSeat: input.isHumanSeat } : {}),
|
|
543
|
+
};
|
|
544
|
+
await txn.commit();
|
|
545
|
+
return 'bound';
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ─── Hydration / enumeration (scan by worktree) ──────────────────────────────
|
|
550
|
+
|
|
551
|
+
/** The exact ACTIVE seat bound to `worktree`, or null. A pending record (no
|
|
552
|
+
* worktree, or non-active) is NEVER surfaced as a live binding. */
|
|
553
|
+
export async function getActiveSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
|
|
554
|
+
const store = await readStore();
|
|
555
|
+
for (const [ref, record] of Object.entries(store.seats)) {
|
|
556
|
+
if (record.state === 'active' && record.worktree === worktree && seatRef(record) === ref) {
|
|
557
|
+
return record;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* CR#2: the seat bound to `worktree` regardless of state — an ACTIVE seat OR a
|
|
565
|
+
* bound-PENDING record (a sibling whose activation failed, bound to its preserved
|
|
566
|
+
* worktree by `bindPendingSeatToWorktree`). Used ONLY by the resume path to recover
|
|
567
|
+
* a bound-pending record's stored `operation` + `state` so the rerun can re-derive
|
|
568
|
+
* the EXACT seat ref and re-send the identical bearer. A bound-pending record is
|
|
569
|
+
* still NON-hydratable as a live seat — `getActiveSeatForWorktree` (and hence
|
|
570
|
+
* `getActiveCube`) still require state==='active', so this reader does NOT weaken
|
|
571
|
+
* the no-ACTIVE-without-binding / non-hydratable-pending invariants.
|
|
572
|
+
*/
|
|
573
|
+
export async function getSeatForWorktree(worktree: string): Promise<SeatRecord | null> {
|
|
574
|
+
const store = await readStore();
|
|
575
|
+
for (const [ref, record] of Object.entries(store.seats)) {
|
|
576
|
+
if (record.worktree === worktree && seatRef(record) === ref) {
|
|
577
|
+
return record;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* CR#3: find an in-flight IMPLICIT-sibling attempt for `binding` — a PENDING,
|
|
585
|
+
* kind==='sibling' record whose operation.projectRoot is the source repo and which
|
|
586
|
+
* has NO worktree binding yet (unbound). Such a record is the persisted, collision-
|
|
587
|
+
* safe attempt identity left when a crash struck AFTER the server accepted the attach
|
|
588
|
+
* but BEFORE the worktree bind: its per-invocation-unique operationKey would otherwise
|
|
589
|
+
* be undiscoverable, so a rerun would mint a NEW bearer and the server (digest-
|
|
590
|
+
* correlating) would create a GHOST seat. Recovering it lets the rerun re-derive the
|
|
591
|
+
* EXACT ref and re-send the identical bearer (the server reuses its seat). A BOUND
|
|
592
|
+
* pending sibling (already discoverable by its worktree) and an ACTIVE record are NOT
|
|
593
|
+
* returned — so a COMPLETED sibling frees the source-repo key and the next distinct
|
|
594
|
+
* sibling mints a fresh identity. Deterministic first match.
|
|
595
|
+
*/
|
|
596
|
+
export async function findIncompleteSiblingAttempt(binding: {
|
|
597
|
+
origin: string;
|
|
598
|
+
trustIdentity: string;
|
|
599
|
+
cubeId: string;
|
|
600
|
+
projectRoot: string;
|
|
601
|
+
}): Promise<SeatRecord | null> {
|
|
602
|
+
const store = await readStore();
|
|
603
|
+
for (const [ref, record] of Object.entries(store.seats)) {
|
|
604
|
+
if (
|
|
605
|
+
record.state === 'pending' &&
|
|
606
|
+
record.worktree === undefined &&
|
|
607
|
+
record.operation.kind === 'sibling' &&
|
|
608
|
+
record.origin === binding.origin &&
|
|
609
|
+
record.trustIdentity === binding.trustIdentity &&
|
|
610
|
+
record.cubeId === binding.cubeId &&
|
|
611
|
+
record.operation.projectRoot === binding.projectRoot &&
|
|
612
|
+
seatRef(record) === ref
|
|
613
|
+
) {
|
|
614
|
+
return record;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** True iff this worktree has ANY persisted seat record (active OR a bound
|
|
621
|
+
* pending), so a crash-in-gap PENDING seat is discoverable (not mislabeled). */
|
|
622
|
+
export async function hasSeatForWorktree(worktree: string): Promise<boolean> {
|
|
623
|
+
const store = await readStore();
|
|
624
|
+
return Object.values(store.seats).some((r) => r.worktree === worktree);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** All ACTIVE bound seats — {worktree, record}. */
|
|
628
|
+
export async function readAllActiveSeats(): Promise<Array<{ worktree: string; record: SeatRecord }>> {
|
|
629
|
+
const store = await readStore();
|
|
630
|
+
const out: Array<{ worktree: string; record: SeatRecord }> = [];
|
|
631
|
+
for (const [ref, record] of Object.entries(store.seats)) {
|
|
632
|
+
if (record.state === 'active' && typeof record.worktree === 'string' && seatRef(record) === ref) {
|
|
633
|
+
out.push({ worktree: record.worktree, record });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ─── Reset / scrub / metadata (all single-commit) ────────────────────────────
|
|
640
|
+
|
|
641
|
+
export type ResetSeatOutcome =
|
|
642
|
+
| { outcome: 'reset'; ref: string }
|
|
643
|
+
| { outcome: 'no-binding' }
|
|
644
|
+
| { outcome: 'changed' };
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Reset the seat bound to `worktree`: under ONE flock, re-check the exact FULL
|
|
648
|
+
* binding (ref + drone id) and the token-safe observation, then DELETE the whole
|
|
649
|
+
* record — credential AND binding vanish together in one commit (no 'partial',
|
|
650
|
+
* no cross-store skew). Any drift (missing / different ref or drone / same-ref
|
|
651
|
+
* digest replacement) is an honest no-op.
|
|
652
|
+
*/
|
|
653
|
+
export async function resetSeatForWorktree(expected: {
|
|
654
|
+
worktree: string;
|
|
655
|
+
ref: string;
|
|
656
|
+
/** CR#4: undefined for a bound-PENDING record with no drone id yet — the reset
|
|
657
|
+
* matches undefined-vs-undefined and still deletes the exact record. */
|
|
658
|
+
droneId?: string;
|
|
659
|
+
observation: SeatObservation;
|
|
660
|
+
}): Promise<ResetSeatOutcome> {
|
|
661
|
+
return withStore<SeatsFile, ResetSeatOutcome>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
662
|
+
const record = txn.data.seats[expected.ref];
|
|
663
|
+
if (
|
|
664
|
+
record === undefined ||
|
|
665
|
+
record.worktree !== expected.worktree ||
|
|
666
|
+
record.droneId !== expected.droneId ||
|
|
667
|
+
seatRef(record) !== expected.ref
|
|
668
|
+
) {
|
|
669
|
+
// Look for whether ANY record still binds this worktree — if not, no-op.
|
|
670
|
+
const anyBound = Object.values(txn.data.seats).some((r) => r.worktree === expected.worktree);
|
|
671
|
+
return anyBound ? { outcome: 'changed' as const } : { outcome: 'no-binding' as const };
|
|
672
|
+
}
|
|
673
|
+
// Same-ref replacement guard: the live digest must still match the snapshot.
|
|
674
|
+
const expDigest = expected.observation.state === 'absent' ? null : expected.observation.digest;
|
|
675
|
+
if (expDigest !== null && digestOf(record.credential) !== expDigest) {
|
|
676
|
+
return { outcome: 'changed' as const };
|
|
677
|
+
}
|
|
678
|
+
delete txn.data.seats[expected.ref];
|
|
679
|
+
await txn.commit();
|
|
680
|
+
return { outcome: 'reset' as const, ref: expected.ref };
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Abort-scrub of the caller's OWN pending record only (CR#2 finalize abort):
|
|
685
|
+
* delete iff still pending AND digest matches — never an active record, never a
|
|
686
|
+
* same-ref replacement. */
|
|
687
|
+
export async function scrubPendingSeat(
|
|
688
|
+
ref: string,
|
|
689
|
+
binding: { origin: string; trustIdentity: string; cubeId: string },
|
|
690
|
+
expectedPendingDigest: string,
|
|
691
|
+
): Promise<boolean> {
|
|
692
|
+
if (!REF_RE.test(ref)) return false;
|
|
693
|
+
return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
694
|
+
const record = txn.data.seats[ref];
|
|
695
|
+
if (!recordMatches(record, ref, binding) || record.state !== 'pending') return false;
|
|
696
|
+
if (digestOf(record.credential) !== expectedPendingDigest) return false;
|
|
697
|
+
delete txn.data.seats[ref];
|
|
698
|
+
await txn.commit();
|
|
699
|
+
return true;
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Discard any record for one seat ref (eviction remint before a fresh mint). */
|
|
704
|
+
export async function clearSeat(ref: string): Promise<void> {
|
|
705
|
+
if (!REF_RE.test(ref)) throw new Error('invalid Borg server session credential reference');
|
|
706
|
+
await withStore<SeatsFile, void>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
707
|
+
if (txn.data.seats[ref] !== undefined) {
|
|
708
|
+
delete txn.data.seats[ref];
|
|
709
|
+
await txn.commit();
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** Metadata-only refresh of one exact ACTIVE seat. The expected tuple prevents
|
|
715
|
+
* a delayed response from updating a replacement seat that reused the worktree. */
|
|
716
|
+
export async function refreshSeatMetadata(
|
|
717
|
+
worktree: string,
|
|
718
|
+
expected: { credentialRef: string; cubeId: string; droneId: string },
|
|
719
|
+
display: { name: string; droneLabel: string; roleName?: string; roleClass?: 'queen' | 'worker'; isHumanSeat?: boolean },
|
|
720
|
+
): Promise<boolean> {
|
|
721
|
+
if (!REF_RE.test(expected.credentialRef)) return false;
|
|
722
|
+
return withStore<SeatsFile, boolean>(SEATS_FILE, emptyStore, parseStore, async (txn) => {
|
|
723
|
+
const record = txn.data.seats[expected.credentialRef];
|
|
724
|
+
if (record?.state !== 'active' || record.worktree !== worktree ||
|
|
725
|
+
record.cubeId !== expected.cubeId || record.droneId !== expected.droneId ||
|
|
726
|
+
seatRef(record) !== expected.credentialRef) return false;
|
|
727
|
+
txn.data.seats[expected.credentialRef] = {
|
|
728
|
+
...record,
|
|
729
|
+
name: display.name,
|
|
730
|
+
droneLabel: display.droneLabel,
|
|
731
|
+
...(display.roleName !== undefined ? { roleName: display.roleName } : {}),
|
|
732
|
+
...(display.roleClass !== undefined ? { roleClass: display.roleClass } : {}),
|
|
733
|
+
...(display.isHumanSeat !== undefined ? { isHumanSeat: display.isHumanSeat } : {}),
|
|
734
|
+
};
|
|
735
|
+
await txn.commit();
|
|
736
|
+
return true;
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** @internal Test-only: point the store at a fixture path is done via HOME; this
|
|
741
|
+
* clears the module cache (there is none — every op reads fresh under the lock). */
|