borgmcp 1.1.14 → 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 +86 -37
- 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
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
7
|
+
const STREAM_LOCKS_DIR = path.join(homedir(), '.config', 'borgmcp', 'stream-locks');
|
|
8
|
+
const OWNER_FILE = 'owner.json';
|
|
9
|
+
const TAKEOVER_FILE = 'takeover.json';
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
export const STREAM_OWNER_STALE_MS = 70_000;
|
|
13
|
+
/** Grace window for mkdir→owner.json initialization before an empty lock is reclaimable. */
|
|
14
|
+
export const STREAM_OWNER_INIT_STALE_MS = 5_000;
|
|
15
|
+
export const STREAM_OWNER_TAKEOVER_STALE_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
const processNonce = randomUUID();
|
|
18
|
+
const processStartedAt = new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
export interface StreamOwnerRecord {
|
|
21
|
+
schemaVersion: number;
|
|
22
|
+
pid: number;
|
|
23
|
+
processNonce: string;
|
|
24
|
+
cwd: string;
|
|
25
|
+
startedAt: string;
|
|
26
|
+
heartbeatAt: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StreamOwnershipSnapshot {
|
|
30
|
+
state: 'owner' | 'owned-by-other-process' | 'initializing' | 'orphaned-initialization' | 'unowned';
|
|
31
|
+
pid?: number;
|
|
32
|
+
processNonce?: string;
|
|
33
|
+
cwd?: string;
|
|
34
|
+
startedAt?: string;
|
|
35
|
+
heartbeatAt?: string;
|
|
36
|
+
ageMs?: number;
|
|
37
|
+
lockPath?: string;
|
|
38
|
+
/** Opened-directory identity used to bind inspection to later takeover. */
|
|
39
|
+
lockDev?: number;
|
|
40
|
+
lockIno?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface StreamLease {
|
|
44
|
+
lockPath: string;
|
|
45
|
+
record: StreamOwnerRecord;
|
|
46
|
+
refresh(): Promise<boolean>;
|
|
47
|
+
release(): Promise<void>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface StreamOwnerDeps {
|
|
51
|
+
now?: () => Date;
|
|
52
|
+
pid?: number;
|
|
53
|
+
cwd?: string;
|
|
54
|
+
locksDir?: string;
|
|
55
|
+
processNonce?: string;
|
|
56
|
+
processStartedAt?: string;
|
|
57
|
+
isPidAlive?: (pid: number) => boolean;
|
|
58
|
+
beforeTakeoverVerify?: (takeoverPath: string) => Promise<void>;
|
|
59
|
+
beforeLeaseRefreshMutation?: (lockPath: string) => Promise<void>;
|
|
60
|
+
beforeLeaseReleaseMutation?: (lockPath: string) => Promise<void>;
|
|
61
|
+
/** Initialization/refresh writer seam for failure-path regression tests. */
|
|
62
|
+
writeRecord?: (lockPath: string, record: StreamOwnerRecord) => Promise<void>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function streamLockPath(
|
|
66
|
+
cubeId: string,
|
|
67
|
+
droneId: string,
|
|
68
|
+
locksDir = STREAM_LOCKS_DIR
|
|
69
|
+
): string {
|
|
70
|
+
assertUuid('cubeId', cubeId);
|
|
71
|
+
assertUuid('droneId', droneId);
|
|
72
|
+
return path.join(locksDir, cubeId, `${droneId}.lock`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function acquireStreamLease(
|
|
76
|
+
cubeId: string,
|
|
77
|
+
droneId: string,
|
|
78
|
+
staleMs = STREAM_OWNER_STALE_MS,
|
|
79
|
+
deps: StreamOwnerDeps = {}
|
|
80
|
+
): Promise<StreamLease | null> {
|
|
81
|
+
const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
|
|
82
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
83
|
+
|
|
84
|
+
if (!(await recoverTakeoverClaim(lockPath, staleMs, deps))) return null;
|
|
85
|
+
|
|
86
|
+
const lease = await tryCreateLease(lockPath, deps);
|
|
87
|
+
if (lease) return lease;
|
|
88
|
+
|
|
89
|
+
const snapshot = await readOwnershipSnapshot(cubeId, droneId, deps);
|
|
90
|
+
if (snapshot.state === 'unowned') {
|
|
91
|
+
return tryCreateLease(lockPath, deps);
|
|
92
|
+
}
|
|
93
|
+
if (snapshot.state === 'initializing') {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
if (snapshot.state === 'orphaned-initialization') {
|
|
97
|
+
if (!(await moveStaleLockAside(lockPath, snapshot, staleMs, deps))) return null;
|
|
98
|
+
return tryCreateLease(lockPath, deps);
|
|
99
|
+
}
|
|
100
|
+
if (snapshot.state !== 'owned-by-other-process') {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stale = (snapshot.ageMs ?? 0) > staleMs;
|
|
105
|
+
const pidDead =
|
|
106
|
+
typeof snapshot.pid === 'number' &&
|
|
107
|
+
!isPidAlive(snapshot.pid, deps);
|
|
108
|
+
if (!stale && !pidDead) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!(await moveStaleLockAside(lockPath, snapshot, staleMs, deps))) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return tryCreateLease(lockPath, deps);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function readOwnershipSnapshot(
|
|
119
|
+
cubeId: string,
|
|
120
|
+
droneId: string,
|
|
121
|
+
deps: StreamOwnerDeps = {}
|
|
122
|
+
): Promise<StreamOwnershipSnapshot> {
|
|
123
|
+
const lockPath = streamLockPath(cubeId, droneId, deps.locksDir);
|
|
124
|
+
const inspected = await readBoundOwner(lockPath);
|
|
125
|
+
if (!inspected) return { state: 'unowned', lockPath };
|
|
126
|
+
const { raw, stat: lockStat } = inspected;
|
|
127
|
+
if (raw === null) {
|
|
128
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
129
|
+
const ageMs = Math.max(0, now.getTime() - lockStat.mtimeMs);
|
|
130
|
+
return {
|
|
131
|
+
state: ageMs >= STREAM_OWNER_INIT_STALE_MS
|
|
132
|
+
? 'orphaned-initialization'
|
|
133
|
+
: 'initializing',
|
|
134
|
+
ageMs,
|
|
135
|
+
lockPath,
|
|
136
|
+
lockDev: lockStat.dev,
|
|
137
|
+
lockIno: lockStat.ino,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let parsed: any;
|
|
142
|
+
try {
|
|
143
|
+
parsed = JSON.parse(raw);
|
|
144
|
+
} catch {
|
|
145
|
+
return {
|
|
146
|
+
state: 'owned-by-other-process',
|
|
147
|
+
lockPath,
|
|
148
|
+
ageMs: Number.POSITIVE_INFINITY,
|
|
149
|
+
lockDev: lockStat.dev,
|
|
150
|
+
lockIno: lockStat.ino,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (!isRecord(parsed)) {
|
|
155
|
+
return {
|
|
156
|
+
state: 'owned-by-other-process',
|
|
157
|
+
lockPath,
|
|
158
|
+
ageMs: Number.POSITIVE_INFINITY,
|
|
159
|
+
lockDev: lockStat.dev,
|
|
160
|
+
lockIno: lockStat.ino,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
165
|
+
const heartbeatMs = Date.parse(parsed.heartbeatAt);
|
|
166
|
+
const ageMs = Number.isFinite(heartbeatMs) ? now.getTime() - heartbeatMs : Number.POSITIVE_INFINITY;
|
|
167
|
+
const ownPid = deps.pid ?? process.pid;
|
|
168
|
+
const ownNonce = deps.processNonce ?? processNonce;
|
|
169
|
+
const state =
|
|
170
|
+
parsed.pid === ownPid && parsed.processNonce === ownNonce
|
|
171
|
+
? 'owner'
|
|
172
|
+
: 'owned-by-other-process';
|
|
173
|
+
return {
|
|
174
|
+
state,
|
|
175
|
+
pid: parsed.pid,
|
|
176
|
+
processNonce: parsed.processNonce,
|
|
177
|
+
cwd: parsed.cwd,
|
|
178
|
+
startedAt: parsed.startedAt,
|
|
179
|
+
heartbeatAt: parsed.heartbeatAt,
|
|
180
|
+
ageMs,
|
|
181
|
+
lockPath,
|
|
182
|
+
lockDev: lockStat.dev,
|
|
183
|
+
lockIno: lockStat.ino,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function tryCreateLease(
|
|
188
|
+
lockPath: string,
|
|
189
|
+
deps: StreamOwnerDeps
|
|
190
|
+
): Promise<StreamLease | null> {
|
|
191
|
+
if (await pathExists(takeoverPath(lockPath))) return null;
|
|
192
|
+
try {
|
|
193
|
+
await fs.mkdir(lockPath, { mode: 0o700 });
|
|
194
|
+
} catch (err: any) {
|
|
195
|
+
if (err?.code === 'EEXIST') return null;
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const record = makeRecord(deps);
|
|
200
|
+
try {
|
|
201
|
+
await (deps.writeRecord ?? writeRecord)(lockPath, record);
|
|
202
|
+
if (await pathExists(takeoverPath(lockPath))) {
|
|
203
|
+
await cleanupFailedInitialization(lockPath, record).catch(() => {});
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
await cleanupFailedInitialization(lockPath, record).catch(() => {});
|
|
208
|
+
throw err;
|
|
209
|
+
}
|
|
210
|
+
const created = await readBoundOwner(lockPath);
|
|
211
|
+
const createdRecord = created?.raw ? parseOwnershipRecord(created.raw) : null;
|
|
212
|
+
if (
|
|
213
|
+
!created || !createdRecord ||
|
|
214
|
+
!sameOwner(record, createdRecord)
|
|
215
|
+
) {
|
|
216
|
+
await cleanupFailedInitialization(lockPath, record).catch(() => {});
|
|
217
|
+
throw new Error('Borg stream lease initialization lost ownership');
|
|
218
|
+
}
|
|
219
|
+
return makeLease(
|
|
220
|
+
lockPath,
|
|
221
|
+
record,
|
|
222
|
+
{ dev: created.stat.dev, ino: created.stat.ino },
|
|
223
|
+
deps,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Remove a mkdir-without-owner partial state after initialization fails. Rename
|
|
229
|
+
* first, then verify identity: if a racer installed a different valid owner in
|
|
230
|
+
* the canonical path, restore/preserve it rather than recursively deleting it.
|
|
231
|
+
*/
|
|
232
|
+
async function cleanupFailedInitialization(
|
|
233
|
+
lockPath: string,
|
|
234
|
+
attempted: StreamOwnerRecord
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
const cleanupPath = `${lockPath}.failed-${attempted.processNonce}-${Date.now()}`;
|
|
237
|
+
try {
|
|
238
|
+
await fs.rename(lockPath, cleanupPath);
|
|
239
|
+
} catch (err: any) {
|
|
240
|
+
if (err?.code === 'ENOENT') return;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
const current = await readOwnershipRecord(cleanupPath);
|
|
244
|
+
if (current && (current.pid !== attempted.pid || current.processNonce !== attempted.processNonce)) {
|
|
245
|
+
try {
|
|
246
|
+
await fs.rename(cleanupPath, lockPath);
|
|
247
|
+
} catch (err: any) {
|
|
248
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
249
|
+
// A canonical successor won the race. Keep its lock; the displaced
|
|
250
|
+
// record cannot own the canonical lease and is safe to remove.
|
|
251
|
+
await fs.rm(cleanupPath, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
await fs.rm(cleanupPath, { recursive: true, force: true });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function moveStaleLockAside(
|
|
259
|
+
lockPath: string,
|
|
260
|
+
snapshot: StreamOwnershipSnapshot,
|
|
261
|
+
staleMs: number,
|
|
262
|
+
deps: StreamOwnerDeps
|
|
263
|
+
): Promise<boolean> {
|
|
264
|
+
const claimPath = takeoverPath(lockPath);
|
|
265
|
+
try {
|
|
266
|
+
await fs.rename(lockPath, claimPath);
|
|
267
|
+
} catch (err: any) {
|
|
268
|
+
if (err?.code === 'ENOENT') return false;
|
|
269
|
+
if (err?.code === 'EEXIST' || err?.code === 'ENOTEMPTY') return false;
|
|
270
|
+
throw err;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const claimStat = await fs.stat(claimPath).catch(() => null);
|
|
274
|
+
if (!claimStat || !snapshotMatchesIdentity(snapshot, claimStat)) {
|
|
275
|
+
try {
|
|
276
|
+
await fs.rename(claimPath, lockPath);
|
|
277
|
+
} catch (error: any) {
|
|
278
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error;
|
|
279
|
+
// The renamed directory was a successor, not the inspected lease. It is
|
|
280
|
+
// authoritative unless another canonical successor already appeared.
|
|
281
|
+
await preserveDisplacedLock(claimPath, lockPath);
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const claimant = {
|
|
287
|
+
schemaVersion: 1,
|
|
288
|
+
pid: deps.pid ?? process.pid,
|
|
289
|
+
processNonce: deps.processNonce ?? processNonce,
|
|
290
|
+
claimedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
291
|
+
};
|
|
292
|
+
await fs.writeFile(
|
|
293
|
+
path.join(claimPath, TAKEOVER_FILE),
|
|
294
|
+
JSON.stringify(claimant, null, 2) + '\n',
|
|
295
|
+
{ flag: 'wx', mode: 0o600 },
|
|
296
|
+
);
|
|
297
|
+
await deps.beforeTakeoverVerify?.(claimPath);
|
|
298
|
+
const verified = await readOwnershipRecord(claimPath);
|
|
299
|
+
const verifiedStat = await fs.stat(claimPath).catch(() => null);
|
|
300
|
+
if (!isStillReclaimable(snapshot, verified, verifiedStat, staleMs, deps)) {
|
|
301
|
+
await fs.unlink(path.join(claimPath, TAKEOVER_FILE)).catch(() => {});
|
|
302
|
+
try {
|
|
303
|
+
await fs.rename(claimPath, lockPath);
|
|
304
|
+
} catch (err: any) {
|
|
305
|
+
if (err?.code !== 'EEXIST') throw err;
|
|
306
|
+
await fs.rm(claimPath, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
await fs.rm(claimPath, { recursive: true, force: true });
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function takeoverPath(lockPath: string): string {
|
|
316
|
+
return `${lockPath}.takeover`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
320
|
+
try {
|
|
321
|
+
await fs.access(filePath);
|
|
322
|
+
return true;
|
|
323
|
+
} catch (error: any) {
|
|
324
|
+
if (error?.code === 'ENOENT') return false;
|
|
325
|
+
throw error;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function recoverTakeoverClaim(
|
|
330
|
+
lockPath: string,
|
|
331
|
+
staleMs: number,
|
|
332
|
+
deps: StreamOwnerDeps,
|
|
333
|
+
): Promise<boolean> {
|
|
334
|
+
const claimPath = takeoverPath(lockPath);
|
|
335
|
+
let claimStat;
|
|
336
|
+
try {
|
|
337
|
+
claimStat = await fs.stat(claimPath);
|
|
338
|
+
} catch (error: any) {
|
|
339
|
+
if (error?.code === 'ENOENT') return true;
|
|
340
|
+
throw error;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
344
|
+
let claimant: { pid: number; processNonce: string; claimedAt: string } | null = null;
|
|
345
|
+
try {
|
|
346
|
+
const parsed = JSON.parse(await fs.readFile(path.join(claimPath, TAKEOVER_FILE), 'utf8'));
|
|
347
|
+
if (
|
|
348
|
+
parsed?.schemaVersion === 1 &&
|
|
349
|
+
Number.isSafeInteger(parsed.pid) && parsed.pid > 0 &&
|
|
350
|
+
isSafeLeaseText(parsed.processNonce, 128) &&
|
|
351
|
+
isIsoTimestamp(parsed.claimedAt)
|
|
352
|
+
) claimant = parsed;
|
|
353
|
+
} catch {
|
|
354
|
+
// A claimant can die after rename but before publishing its marker. The
|
|
355
|
+
// directory mtime supplies a bounded initialization grace period.
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const claimAge = claimant
|
|
359
|
+
? now.getTime() - Date.parse(claimant.claimedAt)
|
|
360
|
+
: now.getTime() - claimStat.mtimeMs;
|
|
361
|
+
if (
|
|
362
|
+
claimant && Number.isFinite(claimAge) &&
|
|
363
|
+
claimAge <= STREAM_OWNER_TAKEOVER_STALE_MS &&
|
|
364
|
+
isPidAlive(claimant.pid, deps)
|
|
365
|
+
) return false;
|
|
366
|
+
if (!claimant && claimAge <= STREAM_OWNER_TAKEOVER_STALE_MS) return false;
|
|
367
|
+
|
|
368
|
+
const owner = await readOwnershipRecord(claimPath);
|
|
369
|
+
const ownerHeartbeat = owner ? Date.parse(owner.heartbeatAt) : Number.NaN;
|
|
370
|
+
const ownerAge = Number.isFinite(ownerHeartbeat)
|
|
371
|
+
? now.getTime() - ownerHeartbeat
|
|
372
|
+
: Number.POSITIVE_INFINITY;
|
|
373
|
+
const ownerReclaimable = !owner || ownerAge > staleMs || !isPidAlive(owner.pid, deps);
|
|
374
|
+
if (ownerReclaimable) {
|
|
375
|
+
await fs.rm(claimPath, { recursive: true, force: true });
|
|
376
|
+
return true;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
await fs.unlink(path.join(claimPath, TAKEOVER_FILE)).catch(() => {});
|
|
380
|
+
try {
|
|
381
|
+
await fs.rename(claimPath, lockPath);
|
|
382
|
+
} catch (error: any) {
|
|
383
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error;
|
|
384
|
+
// A canonical successor already owns the name. Preserve it and discard
|
|
385
|
+
// only the displaced, noncanonical claim directory.
|
|
386
|
+
await fs.rm(claimPath, { recursive: true, force: true });
|
|
387
|
+
}
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isStillReclaimable(
|
|
392
|
+
snapshot: StreamOwnershipSnapshot,
|
|
393
|
+
current: StreamOwnerRecord | null,
|
|
394
|
+
currentLockStat: { dev: number; ino: number } | null,
|
|
395
|
+
staleMs: number,
|
|
396
|
+
deps: StreamOwnerDeps
|
|
397
|
+
): boolean {
|
|
398
|
+
if (!currentLockStat || !snapshotMatchesIdentity(snapshot, currentLockStat)) return false;
|
|
399
|
+
if (!current) {
|
|
400
|
+
if (snapshot.state === 'orphaned-initialization') return true;
|
|
401
|
+
return snapshot.ageMs === Number.POSITIVE_INFINITY;
|
|
402
|
+
}
|
|
403
|
+
if (
|
|
404
|
+
snapshot.pid !== current.pid ||
|
|
405
|
+
snapshot.processNonce !== current.processNonce ||
|
|
406
|
+
snapshot.heartbeatAt !== current.heartbeatAt
|
|
407
|
+
) {
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
const now = (deps.now ?? (() => new Date()))();
|
|
411
|
+
const heartbeatMs = Date.parse(current.heartbeatAt);
|
|
412
|
+
const ageMs = Number.isFinite(heartbeatMs)
|
|
413
|
+
? now.getTime() - heartbeatMs
|
|
414
|
+
: Number.POSITIVE_INFINITY;
|
|
415
|
+
const stale = ageMs > staleMs;
|
|
416
|
+
const pidDead =
|
|
417
|
+
!isPidAlive(current.pid, deps);
|
|
418
|
+
return stale || pidDead;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function snapshotMatchesIdentity(
|
|
422
|
+
snapshot: StreamOwnershipSnapshot,
|
|
423
|
+
stat: { dev: number; ino: number },
|
|
424
|
+
): boolean {
|
|
425
|
+
return snapshot.lockDev !== undefined && snapshot.lockIno !== undefined &&
|
|
426
|
+
snapshot.lockDev === stat.dev && snapshot.lockIno === stat.ino;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function preserveDisplacedLock(claimPath: string, lockPath: string): Promise<void> {
|
|
430
|
+
try {
|
|
431
|
+
await fs.rename(claimPath, lockPath);
|
|
432
|
+
} catch (error: any) {
|
|
433
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error;
|
|
434
|
+
// Never delete an identity we did not prove stale. Leave it under its
|
|
435
|
+
// unique recovery name for conservative operator/process recovery.
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function isPidAlive(pid: number, deps: StreamOwnerDeps): boolean {
|
|
440
|
+
if (deps.isPidAlive) return deps.isPidAlive(pid);
|
|
441
|
+
// Tests that inject synthetic owner PIDs but no process probe retain the
|
|
442
|
+
// historical "assume live" behavior. Production uses the real process PID
|
|
443
|
+
// and therefore verifies dead owners immediately.
|
|
444
|
+
if (deps.pid !== undefined) return true;
|
|
445
|
+
try {
|
|
446
|
+
process.kill(pid, 0);
|
|
447
|
+
return true;
|
|
448
|
+
} catch (error) {
|
|
449
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM';
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function makeLease(
|
|
454
|
+
lockPath: string,
|
|
455
|
+
record: StreamOwnerRecord,
|
|
456
|
+
identity: { dev: number; ino: number },
|
|
457
|
+
deps: StreamOwnerDeps
|
|
458
|
+
): StreamLease {
|
|
459
|
+
let ownedRecord = record;
|
|
460
|
+
return {
|
|
461
|
+
lockPath,
|
|
462
|
+
record,
|
|
463
|
+
async refresh(): Promise<boolean> {
|
|
464
|
+
await deps.beforeLeaseRefreshMutation?.(lockPath);
|
|
465
|
+
const next = { ...ownedRecord, heartbeatAt: (deps.now ?? (() => new Date()))().toISOString() };
|
|
466
|
+
const refreshed = await mutateClaimedLease(
|
|
467
|
+
lockPath,
|
|
468
|
+
identity,
|
|
469
|
+
ownedRecord,
|
|
470
|
+
deps,
|
|
471
|
+
async (claimPath) => {
|
|
472
|
+
await (deps.writeRecord ?? writeRecord)(claimPath, next);
|
|
473
|
+
return 'restore';
|
|
474
|
+
},
|
|
475
|
+
);
|
|
476
|
+
if (!refreshed) return false;
|
|
477
|
+
ownedRecord = next;
|
|
478
|
+
this.record = next;
|
|
479
|
+
return true;
|
|
480
|
+
},
|
|
481
|
+
async release(): Promise<void> {
|
|
482
|
+
await deps.beforeLeaseReleaseMutation?.(lockPath);
|
|
483
|
+
await mutateClaimedLease(
|
|
484
|
+
lockPath,
|
|
485
|
+
identity,
|
|
486
|
+
ownedRecord,
|
|
487
|
+
deps,
|
|
488
|
+
async () => 'remove',
|
|
489
|
+
);
|
|
490
|
+
},
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function mutateClaimedLease(
|
|
495
|
+
lockPath: string,
|
|
496
|
+
identity: { dev: number; ino: number },
|
|
497
|
+
expected: StreamOwnerRecord,
|
|
498
|
+
deps: StreamOwnerDeps,
|
|
499
|
+
mutation: (claimPath: string) => Promise<'restore' | 'remove'>,
|
|
500
|
+
): Promise<boolean> {
|
|
501
|
+
const claimPath = takeoverPath(lockPath);
|
|
502
|
+
try {
|
|
503
|
+
await fs.rename(lockPath, claimPath);
|
|
504
|
+
} catch (error: any) {
|
|
505
|
+
if (error?.code === 'ENOENT' || error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY') {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
throw error;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const movedStat = await fs.stat(claimPath).catch(() => null);
|
|
512
|
+
if (!movedStat || !sameIdentity(identity, movedStat)) {
|
|
513
|
+
await restoreClaimedLease(claimPath, lockPath);
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const claimant = {
|
|
518
|
+
schemaVersion: 1,
|
|
519
|
+
pid: deps.pid ?? process.pid,
|
|
520
|
+
processNonce: deps.processNonce ?? processNonce,
|
|
521
|
+
claimedAt: (deps.now ?? (() => new Date()))().toISOString(),
|
|
522
|
+
};
|
|
523
|
+
await fs.writeFile(
|
|
524
|
+
path.join(claimPath, TAKEOVER_FILE),
|
|
525
|
+
JSON.stringify(claimant, null, 2) + '\n',
|
|
526
|
+
{ flag: 'wx', mode: 0o600 },
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const inspected = await readBoundOwner(claimPath);
|
|
530
|
+
const current = inspected?.raw ? parseOwnershipRecord(inspected.raw) : null;
|
|
531
|
+
if (
|
|
532
|
+
!inspected || !sameIdentity(identity, inspected.stat) || !current ||
|
|
533
|
+
!sameOwner(expected, current)
|
|
534
|
+
) {
|
|
535
|
+
await fs.unlink(path.join(claimPath, TAKEOVER_FILE)).catch(() => {});
|
|
536
|
+
await restoreClaimedLease(claimPath, lockPath);
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const disposition = await mutation(claimPath);
|
|
541
|
+
if (disposition === 'remove') {
|
|
542
|
+
await fs.rm(claimPath, { recursive: true, force: true });
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
await fs.unlink(path.join(claimPath, TAKEOVER_FILE)).catch(() => {});
|
|
547
|
+
return restoreClaimedLease(claimPath, lockPath);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function restoreClaimedLease(claimPath: string, lockPath: string): Promise<boolean> {
|
|
551
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
552
|
+
try {
|
|
553
|
+
await fs.rename(claimPath, lockPath);
|
|
554
|
+
return true;
|
|
555
|
+
} catch (error: any) {
|
|
556
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') {
|
|
557
|
+
if (error?.code === 'ENOENT') return false;
|
|
558
|
+
throw error;
|
|
559
|
+
}
|
|
560
|
+
// A creator that began just before the claim appeared must observe the
|
|
561
|
+
// fixed claim and clean its own initialization. Give it a bounded chance
|
|
562
|
+
// to do so without ever deleting that unverified canonical directory.
|
|
563
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function sameOwner(left: StreamOwnerRecord, right: StreamOwnerRecord): boolean {
|
|
570
|
+
return left.schemaVersion === right.schemaVersion &&
|
|
571
|
+
left.pid === right.pid &&
|
|
572
|
+
left.processNonce === right.processNonce &&
|
|
573
|
+
left.cwd === right.cwd &&
|
|
574
|
+
left.startedAt === right.startedAt &&
|
|
575
|
+
left.heartbeatAt === right.heartbeatAt;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function readOwnershipRecord(lockPath: string): Promise<StreamOwnerRecord | null> {
|
|
579
|
+
try {
|
|
580
|
+
const raw = await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
|
|
581
|
+
return parseOwnershipRecord(raw);
|
|
582
|
+
} catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function parseOwnershipRecord(raw: string): StreamOwnerRecord | null {
|
|
588
|
+
try {
|
|
589
|
+
const parsed = JSON.parse(raw);
|
|
590
|
+
return isRecord(parsed) ? parsed : null;
|
|
591
|
+
} catch {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
async function readBoundOwner(lockPath: string): Promise<{
|
|
597
|
+
raw: string | null;
|
|
598
|
+
stat: { dev: number; ino: number; mtimeMs: number };
|
|
599
|
+
} | null> {
|
|
600
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
601
|
+
let handle;
|
|
602
|
+
try {
|
|
603
|
+
handle = await fs.open(lockPath, 'r');
|
|
604
|
+
} catch (error: any) {
|
|
605
|
+
if (error?.code === 'ENOENT') return null;
|
|
606
|
+
throw error;
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
const openedStat = await handle.stat();
|
|
610
|
+
let raw: string | null;
|
|
611
|
+
try {
|
|
612
|
+
raw = await fs.readFile(path.join(lockPath, OWNER_FILE), 'utf8');
|
|
613
|
+
} catch (error: any) {
|
|
614
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
615
|
+
raw = null;
|
|
616
|
+
}
|
|
617
|
+
const canonicalStat = await fs.stat(lockPath).catch((error: any) => {
|
|
618
|
+
if (error?.code === 'ENOENT') return null;
|
|
619
|
+
throw error;
|
|
620
|
+
});
|
|
621
|
+
const finalOpenedStat = await handle.stat();
|
|
622
|
+
if (canonicalStat && sameIdentity(openedStat, finalOpenedStat) &&
|
|
623
|
+
sameIdentity(openedStat, canonicalStat)) {
|
|
624
|
+
return { raw, stat: canonicalStat };
|
|
625
|
+
}
|
|
626
|
+
} finally {
|
|
627
|
+
await handle.close();
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function sameIdentity(
|
|
634
|
+
left: { dev: number; ino: number },
|
|
635
|
+
right: { dev: number; ino: number },
|
|
636
|
+
): boolean {
|
|
637
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async function writeRecord(lockPath: string, record: StreamOwnerRecord): Promise<void> {
|
|
641
|
+
const ownerPath = path.join(lockPath, OWNER_FILE);
|
|
642
|
+
const tmpPath = path.join(lockPath, `${OWNER_FILE}.${record.processNonce}.tmp`);
|
|
643
|
+
await fs.writeFile(tmpPath, JSON.stringify(record, null, 2) + '\n', {
|
|
644
|
+
mode: 0o600,
|
|
645
|
+
});
|
|
646
|
+
await fs.rename(tmpPath, ownerPath);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function makeRecord(deps: StreamOwnerDeps): StreamOwnerRecord {
|
|
650
|
+
const now = deps.now ?? (() => new Date());
|
|
651
|
+
return {
|
|
652
|
+
schemaVersion: SCHEMA_VERSION,
|
|
653
|
+
pid: deps.pid ?? process.pid,
|
|
654
|
+
processNonce: deps.processNonce ?? processNonce,
|
|
655
|
+
cwd: deps.cwd ?? process.cwd(),
|
|
656
|
+
startedAt: deps.processStartedAt ?? processStartedAt,
|
|
657
|
+
heartbeatAt: now().toISOString(),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function isRecord(value: any): value is StreamOwnerRecord {
|
|
662
|
+
return (
|
|
663
|
+
value !== null &&
|
|
664
|
+
typeof value === 'object' &&
|
|
665
|
+
value.schemaVersion === SCHEMA_VERSION &&
|
|
666
|
+
Number.isSafeInteger(value.pid) &&
|
|
667
|
+
value.pid > 0 &&
|
|
668
|
+
isSafeLeaseText(value.processNonce, 128) &&
|
|
669
|
+
isSafeLeaseText(value.cwd, 4096) &&
|
|
670
|
+
isIsoTimestamp(value.startedAt) &&
|
|
671
|
+
isIsoTimestamp(value.heartbeatAt)
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function isSafeLeaseText(value: unknown, maxLength: number): value is string {
|
|
676
|
+
return typeof value === 'string' && value.length > 0 && value.length <= maxLength &&
|
|
677
|
+
!/[\u0000-\u001f\u007f]/.test(value);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function isIsoTimestamp(value: unknown): value is string {
|
|
681
|
+
return isSafeLeaseText(value, 64) && Number.isFinite(Date.parse(value));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function assertUuid(label: string, value: string): void {
|
|
685
|
+
if (!UUID_RE.test(value)) throw new Error(`Invalid ${label}: ${value}`);
|
|
686
|
+
}
|