@gajae-code/coding-agent 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -1
- package/dist/types/async/job-manager.d.ts +46 -7
- package/dist/types/cli/config-cli.d.ts +11 -1
- package/dist/types/cli/notify-cli.d.ts +1 -0
- package/dist/types/cli/session-picker.d.ts +2 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/commands/mcp-serve.d.ts +33 -0
- package/dist/types/commands/mcp.d.ts +1 -1
- package/dist/types/commands/notify.d.ts +0 -2
- package/dist/types/commands/sdk.d.ts +1 -1
- package/dist/types/config/atomic-yaml-patch.d.ts +18 -0
- package/dist/types/config/file-lock.d.ts +14 -4
- package/dist/types/config/keybindings.d.ts +42 -1
- package/dist/types/config/model-auth.d.ts +3 -0
- package/dist/types/config/model-bindings-applier.d.ts +13 -0
- package/dist/types/config/model-discovery-manager.d.ts +49 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-registry.d.ts +16 -15
- package/dist/types/config/model-resolver.d.ts +28 -18
- package/dist/types/config/models-config-schema.d.ts +11 -0
- package/dist/types/config/settings-schema.d.ts +56 -1
- package/dist/types/config/settings.d.ts +14 -11
- package/dist/types/coordinator-mcp/policy.d.ts +5 -0
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +129 -0
- package/dist/types/coordinator-mcp/question-state.d.ts +356 -0
- package/dist/types/coordinator-mcp/server.d.ts +4 -0
- package/dist/types/discovery/helpers.d.ts +0 -6
- package/dist/types/edit/index.d.ts +3 -0
- package/dist/types/eval/py/env.d.ts +9 -0
- package/dist/types/extensibility/extensions/loader.d.ts +2 -1
- package/dist/types/extensibility/extensions/runner.d.ts +2 -2
- package/dist/types/extensibility/extensions/types.d.ts +34 -3
- package/dist/types/extensibility/hooks/runner.d.ts +1 -1
- package/dist/types/extensibility/shared-events.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +44 -0
- package/dist/types/gjc-runtime/goal-mode-request.d.ts +11 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +4 -2
- package/dist/types/gjc-runtime/linux-proc.d.ts +29 -0
- package/dist/types/gjc-runtime/session-resolution.d.ts +2 -2
- package/dist/types/gjc-runtime/state-migrations.d.ts +1 -1
- package/dist/types/gjc-runtime/state-writer.d.ts +2 -1
- package/dist/types/gjc-runtime/team-launch.d.ts +59 -0
- package/dist/types/gjc-runtime/team-notify.d.ts +45 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +29 -121
- package/dist/types/gjc-runtime/team-store.d.ts +160 -0
- package/dist/types/gjc-runtime/team-workers.d.ts +78 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-evidence.d.ts +38 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +7 -0
- package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +23 -0
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +11 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +55 -33
- package/dist/types/gjc-runtime/workflow-cli-common.d.ts +8 -0
- package/dist/types/gjc-runtime/workflow-manifest.d.ts +3 -0
- package/dist/types/goals/continuation-timeout-guard.d.ts +22 -0
- package/dist/types/goals/runtime.d.ts +1 -0
- package/dist/types/goals/state.d.ts +8 -0
- package/dist/types/harness-control-plane/lifecycle-reconciliation.d.ts +17 -0
- package/dist/types/harness-control-plane/phase-rollup.d.ts +4 -7
- package/dist/types/harness-control-plane/receipt-ingest.d.ts +5 -0
- package/dist/types/harness-control-plane/receipt-spool.d.ts +4 -0
- package/dist/types/hindsight/state.d.ts +11 -4
- package/dist/types/lsp/lspmux.d.ts +5 -3
- package/dist/types/lsp/path-trust.d.ts +1 -0
- package/dist/types/main.d.ts +3 -2
- package/dist/types/memories/storage.d.ts +1 -0
- package/dist/types/modes/action-registry.d.ts +29 -0
- package/dist/types/modes/components/chrome.d.ts +14 -0
- package/dist/types/modes/components/command-palette.d.ts +27 -0
- package/dist/types/modes/components/custom-provider-wizard.d.ts +1 -0
- package/dist/types/modes/components/index.d.ts +2 -1
- package/dist/types/modes/components/pet-selector.d.ts +1 -1
- package/dist/types/modes/components/plan-preview-overlay.d.ts +43 -0
- package/dist/types/modes/components/queue-mode-selector.d.ts +1 -1
- package/dist/types/modes/components/queue-pane.d.ts +12 -0
- package/dist/types/modes/components/read-tool-group.d.ts +6 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -4
- package/dist/types/modes/components/sessions-dashboard.d.ts +32 -0
- package/dist/types/modes/components/settings-selector.d.ts +1 -1
- package/dist/types/modes/components/show-images-selector.d.ts +1 -1
- package/dist/types/modes/components/status-line/types.d.ts +1 -1
- package/dist/types/modes/components/status-line.d.ts +1 -94
- package/dist/types/modes/components/tasks-pane.d.ts +15 -0
- package/dist/types/modes/components/theme-selector.d.ts +1 -1
- package/dist/types/modes/components/tool-execution.d.ts +15 -0
- package/dist/types/modes/components/tool-status-header.d.ts +111 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +42 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +62 -0
- package/dist/types/modes/controllers/input-controller.d.ts +8 -1
- package/dist/types/modes/controllers/mode-gate.d.ts +8 -0
- package/dist/types/modes/controllers/plan-mode-controller.d.ts +65 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -3
- package/dist/types/modes/controllers/stt-controller.d.ts +26 -0
- package/dist/types/modes/interactive-mode.d.ts +21 -19
- package/dist/types/modes/jobs-observer.d.ts +2 -0
- package/dist/types/modes/shared/agent-wire/deep-interview-gate.d.ts +4 -11
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +21 -2
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +31 -0
- package/dist/types/modes/tasks-aggregator.d.ts +39 -0
- package/dist/types/modes/transcript-item-registry.d.ts +60 -0
- package/dist/types/modes/types.d.ts +22 -15
- package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
- package/dist/types/runtime-mcp/manager.d.ts +1 -1
- package/dist/types/sdk/broker/broker.d.ts +78 -12
- package/dist/types/sdk/broker/ensure.d.ts +9 -0
- package/dist/types/sdk/broker/lifecycle-codec.d.ts +4 -0
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +15 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +6 -2
- package/dist/types/sdk/broker/process-incarnation.d.ts +2 -0
- package/dist/types/sdk/broker/session-index.d.ts +0 -1
- package/dist/types/sdk/broker/state-version.d.ts +2 -0
- package/dist/types/sdk/bus/chat-effect-journal.d.ts +1 -0
- package/dist/types/sdk/bus/config-commands.d.ts +1 -1
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +5 -0
- package/dist/types/sdk/bus/html-format.d.ts +3 -2
- package/dist/types/sdk/bus/index.d.ts +27 -0
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/notification-service.d.ts +20 -0
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +13 -2
- package/dist/types/sdk/bus/recent-activity.d.ts +21 -12
- package/dist/types/sdk/bus/rich-document.d.ts +7 -0
- package/dist/types/sdk/bus/rich-render.d.ts +18 -41
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +9 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +69 -3
- package/dist/types/sdk/bus/threaded-inbound.d.ts +1 -1
- package/dist/types/sdk/bus/threaded-render.d.ts +13 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +24 -2
- package/dist/types/sdk/host/control/operations.d.ts +1 -1
- package/dist/types/sdk/host/host.d.ts +2 -0
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/session-directory.d.ts +56 -0
- package/dist/types/session/agent-session.d.ts +82 -47
- package/dist/types/session/agent-storage.d.ts +7 -0
- package/dist/types/session/fallback-chain-controller.d.ts +10 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +102 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +38 -0
- package/dist/types/session/messages.d.ts +16 -0
- package/dist/types/session/session-manager-internal.d.ts +14 -0
- package/dist/types/session/session-manager.d.ts +82 -12
- package/dist/types/session/session-storage.d.ts +43 -3
- package/dist/types/session/volatile-context-pruning.d.ts +15 -0
- package/dist/types/skill-state/{deep-interview-mutation-guard.d.ts → workflow-mutation-guard.d.ts} +24 -8
- package/dist/types/skill-state/workflow-state-contract.d.ts +1 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +4 -1
- package/dist/types/system-prompt.d.ts +2 -11
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/thinking.d.ts +7 -0
- package/dist/types/tool-discovery/tool-index.d.ts +1 -50
- package/dist/types/tools/ask.d.ts +64 -2
- package/dist/types/tools/bash.d.ts +3 -0
- package/dist/types/tools/browser/dead-tab-recovery.d.ts +24 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +21 -0
- package/dist/types/tools/cron.d.ts +2 -0
- package/dist/types/tools/fetch.d.ts +1 -0
- package/dist/types/tools/index.d.ts +30 -15
- package/dist/types/tools/output-meta.d.ts +1 -0
- package/dist/types/tools/read.d.ts +3 -0
- package/dist/types/tools/search-tool-bm25.d.ts +3 -4
- package/dist/types/tools/subagent-render.d.ts +1 -0
- package/dist/types/tools/subagent.d.ts +6 -1
- package/dist/types/utils/event-bus.d.ts +1 -1
- package/dist/types/web/search/index.d.ts +3 -1
- package/package.json +21 -9
- package/scripts/build-sdk-package-smoke.ts +39 -1
- package/scripts/generate-hotkeys-docs.ts +74 -0
- package/scripts/generate-sdk-operation-inventory.ts +64 -20
- package/scripts/verify-gjc-sdk-canonicalization.ts +182 -2
- package/src/async/job-manager.ts +455 -69
- package/src/cli/config-cli.ts +75 -2
- package/src/cli/notify-cli.ts +92 -26
- package/src/cli/session-picker.ts +20 -3
- package/src/cli/setup-cli.ts +1 -1
- package/src/cli.ts +1 -1
- package/src/commands/config.ts +1 -1
- package/src/commands/harness.ts +34 -106
- package/src/commands/launch.ts +6 -4
- package/src/commands/mcp-serve.ts +85 -8
- package/src/commands/mcp.ts +2 -2
- package/src/commands/notify.ts +8 -4
- package/src/commands/sdk.ts +61 -40
- package/src/commands/ultragoal.ts +4 -1
- package/src/config/atomic-yaml-patch.ts +80 -13
- package/src/config/file-lock.ts +105 -43
- package/src/config/keybindings.ts +64 -20
- package/src/config/model-auth.ts +9 -0
- package/src/config/model-bindings-applier.ts +139 -0
- package/src/config/model-discovery-manager.ts +224 -0
- package/src/config/model-equivalence.ts +66 -0
- package/src/config/model-registry.ts +169 -302
- package/src/config/model-resolver.ts +361 -369
- package/src/config/models-config-schema.ts +5 -4
- package/src/config/settings-schema.ts +159 -2
- package/src/config/settings.ts +458 -482
- package/src/coordinator-mcp/policy.ts +76 -11
- package/src/coordinator-mcp/question-gate-codec.ts +274 -0
- package/src/coordinator-mcp/question-state.ts +778 -0
- package/src/coordinator-mcp/server.ts +1702 -238
- package/src/debug/index.ts +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +13 -1
- package/src/defaults/gjc/skills/team/SKILL.md +16 -0
- package/src/discovery/helpers.ts +1 -50
- package/src/edit/index.ts +64 -0
- package/src/eval/py/env.ts +26 -0
- package/src/eval/py/kernel.ts +5 -3
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +12 -3
- package/src/extensibility/extensions/types.ts +39 -1
- package/src/extensibility/gjc-plugins/installer.ts +16 -16
- package/src/extensibility/hooks/runner.ts +2 -2
- package/src/extensibility/runtime-skill-discovery.ts +33 -13
- package/src/extensibility/shared-events.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +63 -3
- package/src/gjc-runtime/deep-interview-runtime.ts +76 -27
- package/src/gjc-runtime/deep-interview-state.ts +317 -5
- package/src/gjc-runtime/goal-mode-request.ts +55 -8
- package/src/gjc-runtime/launch-tmux.ts +2 -5
- package/src/gjc-runtime/launch-worktree.ts +5 -5
- package/src/gjc-runtime/linux-proc.ts +71 -0
- package/src/gjc-runtime/ralplan-runtime.ts +74 -90
- package/src/gjc-runtime/session-resolution.ts +18 -3
- package/src/gjc-runtime/session-state-sidecar.ts +6 -107
- package/src/gjc-runtime/state-migrations.ts +1 -1
- package/src/gjc-runtime/state-runtime.ts +320 -249
- package/src/gjc-runtime/state-writer.ts +36 -40
- package/src/gjc-runtime/team-launch.ts +319 -0
- package/src/gjc-runtime/team-notify.ts +326 -0
- package/src/gjc-runtime/team-runtime.ts +1099 -1482
- package/src/gjc-runtime/team-store.ts +828 -0
- package/src/gjc-runtime/team-workers.ts +962 -0
- package/src/gjc-runtime/tmux-owner-isolation-cli.ts +2 -11
- package/src/gjc-runtime/tmux-owner-isolation.ts +14 -9
- package/src/gjc-runtime/tmux-sessions.ts +4 -21
- package/src/gjc-runtime/ultragoal-change-set.ts +172 -0
- package/src/gjc-runtime/ultragoal-evidence.ts +874 -0
- package/src/gjc-runtime/ultragoal-guard.ts +83 -12
- package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +102 -1
- package/src/gjc-runtime/ultragoal-runtime.ts +156 -1319
- package/src/gjc-runtime/workflow-cli-common.ts +30 -0
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +18 -0
- package/src/goals/continuation-timeout-guard.ts +79 -0
- package/src/goals/runtime.ts +7 -5
- package/src/goals/state.ts +18 -0
- package/src/harness-control-plane/lifecycle-reconciliation.ts +64 -0
- package/src/harness-control-plane/phase-rollup.ts +4 -7
- package/src/harness-control-plane/receipt-ingest.ts +5 -0
- package/src/harness-control-plane/receipt-spool.ts +51 -7
- package/src/harness-control-plane/session-lease.ts +12 -130
- package/src/hindsight/backend.ts +26 -27
- package/src/hindsight/state.ts +101 -32
- package/src/hooks/skill-state.ts +17 -11
- package/src/internal-urls/docs-index.generated.ts +21 -18
- package/src/lsp/client.ts +1 -1
- package/src/lsp/config.ts +145 -26
- package/src/lsp/index.ts +1 -1
- package/src/lsp/lspmux.ts +39 -10
- package/src/lsp/path-trust.ts +81 -0
- package/src/main.ts +28 -8
- package/src/memories/index.ts +1 -0
- package/src/memories/storage.ts +6 -2
- package/src/modes/action-registry.ts +165 -0
- package/src/modes/components/chrome.ts +40 -0
- package/src/modes/components/command-palette.ts +168 -0
- package/src/modes/components/custom-editor.ts +1 -1
- package/src/modes/components/custom-provider-wizard.ts +51 -14
- package/src/modes/components/index.ts +2 -1
- package/src/modes/components/pet-selector.ts +11 -17
- package/src/modes/components/plan-preview-overlay.ts +295 -0
- package/src/modes/components/queue-mode-selector.ts +10 -27
- package/src/modes/components/queue-pane.ts +79 -0
- package/src/modes/components/read-tool-group.ts +13 -0
- package/src/modes/components/session-observer-overlay.ts +207 -772
- package/src/modes/components/sessions-dashboard.ts +181 -0
- package/src/modes/components/settings-selector.ts +1 -1
- package/src/modes/components/show-images-selector.ts +10 -24
- package/src/modes/components/status-line/types.ts +1 -1
- package/src/modes/components/status-line.ts +1 -969
- package/src/modes/components/tasks-pane.ts +73 -0
- package/src/modes/components/theme-selector.ts +11 -31
- package/src/modes/components/tool-execution.ts +22 -0
- package/src/modes/components/tool-status-header.ts +1056 -0
- package/src/modes/components/transcript-viewer-overlay.ts +384 -0
- package/src/modes/controllers/btw-controller.ts +1 -0
- package/src/modes/controllers/command-controller.ts +9 -15
- package/src/modes/controllers/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +72 -25
- package/src/modes/controllers/goal-mode-controller.ts +474 -0
- package/src/modes/controllers/input-controller.ts +764 -229
- package/src/modes/controllers/mode-gate.ts +24 -0
- package/src/modes/controllers/plan-mode-controller.ts +517 -0
- package/src/modes/controllers/selector-controller.ts +225 -10
- package/src/modes/controllers/stt-controller.ts +125 -0
- package/src/modes/interactive-mode.ts +302 -1151
- package/src/modes/jobs-observer.ts +3 -0
- package/src/modes/runtime-init.ts +8 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -106
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +78 -19
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +177 -0
- package/src/modes/tasks-aggregator.ts +199 -0
- package/src/modes/transcript-item-registry.ts +229 -0
- package/src/modes/types.ts +47 -20
- package/src/modes/utils/ui-helpers.ts +17 -9
- package/src/prompts/agent-fragments/ralplan-persistence.md +6 -0
- package/src/prompts/agent-fragments/restricted-bash.md +1 -0
- package/src/prompts/agents/architect.md +2 -8
- package/src/prompts/agents/critic.md +2 -8
- package/src/prompts/agents/executor.md +3 -0
- package/src/prompts/agents/planner.md +4 -12
- package/src/prompts/system/custom-system-prompt.md +1 -23
- package/src/prompts/system/plan-mode-approved.md +8 -0
- package/src/prompts/system/system-prompt.md +17 -96
- package/src/prompts/tools/bash.md +4 -4
- package/src/prompts/tools/browser.md +15 -52
- package/src/prompts/tools/find.md +2 -2
- package/src/prompts/tools/hashline.md +13 -55
- package/src/prompts/tools/read.md +3 -6
- package/src/prompts/tools/search-tool-bm25.md +0 -2
- package/src/prompts/tools/search.md +2 -4
- package/src/prompts/tools/skill-discovery.md +1 -2
- package/src/prompts/tools/task.md +1 -1
- package/src/runtime-mcp/manager.ts +1 -1
- package/src/sdk/broker/broker.ts +206 -78
- package/src/sdk/broker/discovery.ts +21 -2
- package/src/sdk/broker/ensure.ts +85 -25
- package/src/sdk/broker/lifecycle-codec.ts +11 -0
- package/src/sdk/broker/lifecycle-ledger.ts +371 -74
- package/src/sdk/broker/lifecycle.ts +1399 -142
- package/src/sdk/broker/process-incarnation.ts +5 -13
- package/src/sdk/broker/session-index.ts +145 -56
- package/src/sdk/broker/state-version.ts +21 -0
- package/src/sdk/broker/transport.ts +1 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -0
- package/src/sdk/bus/chat-daemon-control.ts +8 -28
- package/src/sdk/bus/chat-effect-journal.ts +14 -4
- package/src/sdk/bus/config-commands.ts +5 -4
- package/src/sdk/bus/config.ts +98 -0
- package/src/sdk/bus/conversation-store.ts +5 -29
- package/src/sdk/bus/daemon-paths.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +25 -8
- package/src/sdk/bus/html-format.ts +20 -3
- package/src/sdk/bus/index.ts +834 -113
- package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
- package/src/sdk/bus/native-runtime-compatibility.ts +1 -1
- package/src/sdk/bus/natives-augment.d.ts +26 -1
- package/src/sdk/bus/notification-service.ts +168 -20
- package/src/sdk/bus/rate-limit-pool.ts +58 -14
- package/src/sdk/bus/recent-activity.ts +188 -74
- package/src/sdk/bus/rich-document.ts +258 -0
- package/src/sdk/bus/rich-render.ts +62 -70
- package/src/sdk/bus/telegram-daemon-cli.ts +15 -59
- package/src/sdk/bus/telegram-daemon-contract.ts +9 -4
- package/src/sdk/bus/telegram-daemon.ts +1491 -247
- package/src/sdk/bus/threaded-inbound.ts +8 -3
- package/src/sdk/bus/threaded-render.ts +57 -1
- package/src/sdk/bus/topic-registry.ts +120 -13
- package/src/sdk/host/control/dispatch.ts +14 -2
- package/src/sdk/host/control/operations.ts +6 -1
- package/src/sdk/host/host.ts +31 -1
- package/src/sdk/host/query/handlers.ts +20 -22
- package/src/sdk/host/query/revision-store.ts +80 -57
- package/src/sdk/index.ts +1 -0
- package/src/sdk/protocol/operation-inventory.generated.json +943 -1907
- package/src/sdk/session-directory.ts +141 -0
- package/src/sdk/session.ts +458 -479
- package/src/session/agent-session.ts +1901 -637
- package/src/session/agent-storage.ts +18 -0
- package/src/session/cache-economics.ts +25 -8
- package/src/session/fallback-chain-controller.ts +53 -3
- package/src/session/internal/managed-session-scope.ts +2226 -0
- package/src/session/internal/managed-session-storage.ts +453 -0
- package/src/session/messages.ts +91 -2
- package/src/session/session-manager-internal.ts +33 -0
- package/src/session/session-manager.ts +1112 -333
- package/src/session/session-storage.ts +444 -39
- package/src/session/volatile-context-pruning.ts +78 -0
- package/src/setup/credential-auto-import.ts +2 -1
- package/src/setup/hermes/templates/operator-instructions.v1.md +1 -1
- package/src/setup/provider-onboarding.ts +36 -61
- package/src/setup/provider-presets.json +5 -0
- package/src/skill-state/active-state.ts +3 -13
- package/src/skill-state/{deep-interview-mutation-guard.ts → workflow-mutation-guard.ts} +47 -29
- package/src/skill-state/workflow-state-contract.ts +6 -41
- package/src/slash-commands/builtin-registry.ts +46 -11
- package/src/system-prompt.ts +37 -44
- package/src/task/agents.ts +19 -1
- package/src/task/executor.ts +90 -6
- package/src/task/index.ts +62 -37
- package/src/thinking.ts +21 -6
- package/src/tool-discovery/tool-index.ts +2 -134
- package/src/tools/ask.ts +157 -21
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/bash.ts +38 -0
- package/src/tools/browser/dead-tab-recovery.ts +78 -0
- package/src/tools/browser/tab-supervisor.ts +282 -36
- package/src/tools/computer.ts +10 -3
- package/src/tools/cron.ts +30 -16
- package/src/tools/eval.ts +1 -2
- package/src/tools/fetch.ts +11 -4
- package/src/tools/index.ts +76 -24
- package/src/tools/output-meta.ts +13 -5
- package/src/tools/read.ts +37 -1
- package/src/tools/resource-gc.ts +2 -2
- package/src/tools/search-tool-bm25.ts +11 -74
- package/src/tools/subagent-render.ts +19 -8
- package/src/tools/subagent.ts +84 -32
- package/src/utils/event-bus.ts +9 -4
- package/src/web/search/index.ts +3 -3
- package/dist/types/discovery/plugin-dir-roots.d.ts +0 -15
- package/dist/types/runtime-mcp/discoverable-tool-metadata.d.ts +0 -7
- package/src/discovery/plugin-dir-roots.ts +0 -28
- package/src/runtime-mcp/discoverable-tool-metadata.ts +0 -25
|
@@ -0,0 +1,2226 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import * as native from "@gajae-code/natives";
|
|
6
|
+
import { canonicalExistingDirectoryIdentity, verifyOwnerOnlyPathSecurity } from "@gajae-code/natives";
|
|
7
|
+
import { pathIsWithin } from "@gajae-code/utils";
|
|
8
|
+
import type { ResumeSessionIdentity } from "../session-manager";
|
|
9
|
+
import {
|
|
10
|
+
FileSessionStorage,
|
|
11
|
+
type NativeDirectoryTreeSnapshot,
|
|
12
|
+
type SessionStorageFileIdentity,
|
|
13
|
+
} from "../session-storage";
|
|
14
|
+
import {
|
|
15
|
+
acquireManagedLock,
|
|
16
|
+
captureManagedFileNoFollow,
|
|
17
|
+
copyManagedFileNoReplace,
|
|
18
|
+
ensureManagedDirectory,
|
|
19
|
+
fsyncManagedArtifactTree,
|
|
20
|
+
type ManagedStorageLock,
|
|
21
|
+
publishManagedFileNoReplace,
|
|
22
|
+
publishManagedFileNoReplaceSync,
|
|
23
|
+
publishManagedTombstone,
|
|
24
|
+
validateManagedArtifactTree,
|
|
25
|
+
} from "./managed-session-storage";
|
|
26
|
+
|
|
27
|
+
export const MANAGED_SESSION_LAYOUT_VERSION = 2 as const;
|
|
28
|
+
export const MANAGED_SESSION_IDENTITY_VERSION = 1 as const;
|
|
29
|
+
export const MANAGED_SESSION_BINDING_FILE = ".gjc-managed-session-scope.v2.json";
|
|
30
|
+
|
|
31
|
+
export interface ManagedScope {
|
|
32
|
+
apiVersion: 1;
|
|
33
|
+
layoutVersion: 2;
|
|
34
|
+
identityVersion: 1;
|
|
35
|
+
agentDir: string;
|
|
36
|
+
sessionsRoot: string;
|
|
37
|
+
canonicalCwd: string;
|
|
38
|
+
legacyLexicalCwd: string;
|
|
39
|
+
directoryName: string;
|
|
40
|
+
directoryPath: string;
|
|
41
|
+
platform: "posix" | "win32";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ManagedMigrationPolicy = "copy-retain" | "disabled";
|
|
45
|
+
|
|
46
|
+
export type ManagedScopeErrorCode =
|
|
47
|
+
| "cwd_missing"
|
|
48
|
+
| "cwd_not_directory"
|
|
49
|
+
| "identity_unavailable"
|
|
50
|
+
| "network_unsupported"
|
|
51
|
+
| "sessions_root_unavailable"
|
|
52
|
+
| "binding_conflict"
|
|
53
|
+
| "binding_invalid";
|
|
54
|
+
|
|
55
|
+
export type ManagedScopeResolution =
|
|
56
|
+
| { kind: "resolved"; scope: ManagedScope }
|
|
57
|
+
| { kind: "error"; code: ManagedScopeErrorCode; message: string };
|
|
58
|
+
|
|
59
|
+
export interface ManagedCandidate {
|
|
60
|
+
sessionId: string;
|
|
61
|
+
path: string;
|
|
62
|
+
cwd: string;
|
|
63
|
+
provenance: "v2" | "legacy";
|
|
64
|
+
identity: ResumeSessionIdentity;
|
|
65
|
+
migrationState: "native_v2" | "legacy_unmigrated" | "migrated_v2";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ManagedCandidateListing =
|
|
69
|
+
| {
|
|
70
|
+
kind: "complete";
|
|
71
|
+
scope: ManagedScope;
|
|
72
|
+
owned: readonly ManagedCandidate[];
|
|
73
|
+
foreignCount: number;
|
|
74
|
+
invalid: readonly { code: string }[];
|
|
75
|
+
}
|
|
76
|
+
| { kind: "error"; code: "scan_failed" | "unsafe_root" | "invalid_candidate"; message: string };
|
|
77
|
+
|
|
78
|
+
export type ManagedOpenFailure =
|
|
79
|
+
| "migration_busy"
|
|
80
|
+
| "binding_conflict"
|
|
81
|
+
| "binding_invalid"
|
|
82
|
+
| "destination_conflict"
|
|
83
|
+
| "source_changed"
|
|
84
|
+
| "unsafe_artifacts"
|
|
85
|
+
| "durability_failed"
|
|
86
|
+
| "migration_retired"
|
|
87
|
+
| "legacy_migration_disabled"
|
|
88
|
+
| "managed_storage_unsupported";
|
|
89
|
+
|
|
90
|
+
export type ManagedOpenCandidateResult =
|
|
91
|
+
| { kind: "opened"; path: string; candidate: ManagedCandidate; migrated: boolean }
|
|
92
|
+
| { kind: "error"; code: ManagedOpenFailure; message: string };
|
|
93
|
+
|
|
94
|
+
export type ManagedDeleteCandidateResult =
|
|
95
|
+
| { kind: "deleted"; tombstonePath: string }
|
|
96
|
+
| { kind: "already_deleted"; tombstonePath: string }
|
|
97
|
+
| { kind: "error"; code: ManagedOpenFailure; message: string };
|
|
98
|
+
|
|
99
|
+
type NativeIdentity =
|
|
100
|
+
| { ok: true; platform: "posix" | "win32"; canonicalPath: string }
|
|
101
|
+
| { ok: false; code: NativeIdentityFailureCode };
|
|
102
|
+
|
|
103
|
+
type NativeIdentityFailureCode =
|
|
104
|
+
| "not_found"
|
|
105
|
+
| "not_directory"
|
|
106
|
+
| "not_utf8"
|
|
107
|
+
| "network_unsupported"
|
|
108
|
+
| "identity_unavailable"
|
|
109
|
+
| "io_error";
|
|
110
|
+
|
|
111
|
+
type NativeSecurity = { ok: true } | { ok: false; code: string };
|
|
112
|
+
|
|
113
|
+
interface Binding {
|
|
114
|
+
schemaVersion: 1;
|
|
115
|
+
layoutVersion: 2;
|
|
116
|
+
identityVersion: 1;
|
|
117
|
+
platform: "posix" | "win32";
|
|
118
|
+
canonicalPath: string;
|
|
119
|
+
identityDigest: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const BASE32 = "abcdefghijklmnopqrstuvwxyz234567";
|
|
123
|
+
const HEADER_MAX_BYTES = 64 * 1024;
|
|
124
|
+
|
|
125
|
+
function scopeDigest(platform: "posix" | "win32", canonicalPath: string): string {
|
|
126
|
+
const bytes = createHash("sha256")
|
|
127
|
+
.update("gjc-managed-session-scope\0identity-v1\0", "utf8")
|
|
128
|
+
.update(platform, "utf8")
|
|
129
|
+
.update("\0", "utf8")
|
|
130
|
+
.update(canonicalPath, "utf8")
|
|
131
|
+
.digest();
|
|
132
|
+
let result = "";
|
|
133
|
+
let accumulator = 0;
|
|
134
|
+
let bits = 0;
|
|
135
|
+
for (const byte of bytes) {
|
|
136
|
+
accumulator = (accumulator << 8) | byte;
|
|
137
|
+
bits += 8;
|
|
138
|
+
while (bits >= 5) {
|
|
139
|
+
result += BASE32[(accumulator >>> (bits - 5)) & 31];
|
|
140
|
+
bits -= 5;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (bits > 0) result += BASE32[(accumulator << (5 - bits)) & 31];
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
export const computeManagedScopeDigest = scopeDigest;
|
|
147
|
+
|
|
148
|
+
function identityFor(cwd: string): NativeIdentity {
|
|
149
|
+
return canonicalExistingDirectoryIdentity(cwd) as NativeIdentity;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Resolve benign symlinks in the deepest existing ancestor of a trusted storage
|
|
154
|
+
* root (e.g. macOS `/var -> /private/var`, or a symlinked `$HOME`) while keeping
|
|
155
|
+
* any not-yet-created tail verbatim. The native owner-only primitive and the
|
|
156
|
+
* session-storage reparse guard traverse with `O_NOFOLLOW` and reject every
|
|
157
|
+
* symlink component, so the trusted root must be canonical before it reaches
|
|
158
|
+
* them; canonicalizing only the existing prefix never follows an
|
|
159
|
+
* attacker-plantable component below the root.
|
|
160
|
+
*/
|
|
161
|
+
export function canonicalizeTrustedPath(target: string): string {
|
|
162
|
+
let base = path.resolve(target);
|
|
163
|
+
const suffix: string[] = [];
|
|
164
|
+
for (;;) {
|
|
165
|
+
const identity = canonicalExistingDirectoryIdentity(base) as NativeIdentity;
|
|
166
|
+
if (identity.ok) {
|
|
167
|
+
return suffix.length === 0 ? identity.canonicalPath : path.join(identity.canonicalPath, ...suffix);
|
|
168
|
+
}
|
|
169
|
+
if (identity.code !== "not_found" && identity.code !== "not_directory") return path.resolve(target);
|
|
170
|
+
const parent = path.dirname(base);
|
|
171
|
+
if (parent === base) return path.resolve(target);
|
|
172
|
+
suffix.unshift(path.basename(base));
|
|
173
|
+
base = parent;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nativeFailure(code: NativeIdentityFailureCode): ManagedScopeResolution {
|
|
178
|
+
if (code === "not_found")
|
|
179
|
+
return { kind: "error", code: "cwd_missing", message: "The workspace directory does not exist." };
|
|
180
|
+
if (code === "not_directory")
|
|
181
|
+
return { kind: "error", code: "cwd_not_directory", message: "The workspace path is not a directory." };
|
|
182
|
+
if (code === "network_unsupported") {
|
|
183
|
+
return {
|
|
184
|
+
kind: "error",
|
|
185
|
+
code: "network_unsupported",
|
|
186
|
+
message: "Network workspace directories are not supported.",
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
return { kind: "error", code: "identity_unavailable", message: "Workspace directory identity is unavailable." };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function bindingFor(scope: ManagedScope): Binding {
|
|
193
|
+
return {
|
|
194
|
+
schemaVersion: 1,
|
|
195
|
+
layoutVersion: 2,
|
|
196
|
+
identityVersion: 1,
|
|
197
|
+
platform: scope.platform,
|
|
198
|
+
canonicalPath: scope.canonicalCwd,
|
|
199
|
+
identityDigest: scope.directoryName.slice(3),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isBinding(value: unknown): value is Binding {
|
|
204
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
205
|
+
const binding = value as Record<string, unknown>;
|
|
206
|
+
return (
|
|
207
|
+
binding.schemaVersion === 1 &&
|
|
208
|
+
binding.layoutVersion === 2 &&
|
|
209
|
+
binding.identityVersion === 1 &&
|
|
210
|
+
(binding.platform === "posix" || binding.platform === "win32") &&
|
|
211
|
+
typeof binding.canonicalPath === "string" &&
|
|
212
|
+
typeof binding.identityDigest === "string" &&
|
|
213
|
+
/^[a-z2-7]{52}$/.test(binding.identityDigest)
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function validateExistingBinding(scope: ManagedScope): ManagedScopeResolution | undefined {
|
|
218
|
+
const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
|
|
219
|
+
let raw: string;
|
|
220
|
+
let parsed: unknown;
|
|
221
|
+
try {
|
|
222
|
+
raw = captureManagedFileNoFollow(bindingPath).bytes.toString("utf8");
|
|
223
|
+
parsed = JSON.parse(raw);
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
226
|
+
return { kind: "error", code: "binding_invalid", message: "The managed scope binding is invalid JSON." };
|
|
227
|
+
}
|
|
228
|
+
if (!isBinding(parsed))
|
|
229
|
+
return { kind: "error", code: "binding_invalid", message: "The managed scope binding is invalid." };
|
|
230
|
+
const expected = bindingFor(scope);
|
|
231
|
+
if (
|
|
232
|
+
parsed.platform !== expected.platform ||
|
|
233
|
+
parsed.canonicalPath !== expected.canonicalPath ||
|
|
234
|
+
parsed.identityDigest !== expected.identityDigest
|
|
235
|
+
) {
|
|
236
|
+
return {
|
|
237
|
+
kind: "error",
|
|
238
|
+
code: "binding_conflict",
|
|
239
|
+
message: "The managed scope binding belongs to another workspace.",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (raw !== `${JSON.stringify(expected)}\n`) {
|
|
243
|
+
return {
|
|
244
|
+
kind: "error",
|
|
245
|
+
code: "binding_invalid",
|
|
246
|
+
message: "The managed scope binding is not canonically encoded.",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function resolveManagedScope(input: {
|
|
253
|
+
cwd: string;
|
|
254
|
+
agentDir: string;
|
|
255
|
+
sessionsRoot: string;
|
|
256
|
+
}): ManagedScopeResolution {
|
|
257
|
+
const identity = identityFor(input.cwd);
|
|
258
|
+
if (!identity.ok) return nativeFailure(identity.code);
|
|
259
|
+
const sessionsRoot = canonicalizeTrustedPath(input.sessionsRoot);
|
|
260
|
+
const agentDir = canonicalizeTrustedPath(input.agentDir);
|
|
261
|
+
const digest = scopeDigest(identity.platform, identity.canonicalPath);
|
|
262
|
+
const scope: ManagedScope = {
|
|
263
|
+
apiVersion: 1,
|
|
264
|
+
layoutVersion: MANAGED_SESSION_LAYOUT_VERSION,
|
|
265
|
+
identityVersion: MANAGED_SESSION_IDENTITY_VERSION,
|
|
266
|
+
agentDir,
|
|
267
|
+
sessionsRoot,
|
|
268
|
+
canonicalCwd: identity.canonicalPath,
|
|
269
|
+
legacyLexicalCwd: path.resolve(input.cwd),
|
|
270
|
+
directoryName: `v2-${digest}`,
|
|
271
|
+
directoryPath: path.join(sessionsRoot, `v2-${digest}`),
|
|
272
|
+
platform: identity.platform,
|
|
273
|
+
};
|
|
274
|
+
try {
|
|
275
|
+
const root = fs.lstatSync(sessionsRoot);
|
|
276
|
+
if (!root.isDirectory() || root.isSymbolicLink()) {
|
|
277
|
+
return {
|
|
278
|
+
kind: "error",
|
|
279
|
+
code: "sessions_root_unavailable",
|
|
280
|
+
message: "The sessions root is not a safe directory.",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
285
|
+
return {
|
|
286
|
+
kind: "error",
|
|
287
|
+
code: "sessions_root_unavailable",
|
|
288
|
+
message: "The sessions root could not be inspected.",
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const directory = fs.lstatSync(scope.directoryPath);
|
|
294
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) {
|
|
295
|
+
return { kind: "error", code: "binding_invalid", message: "The managed scope path is not a safe directory." };
|
|
296
|
+
}
|
|
297
|
+
const security = verifyOwnerOnlyPathSecurity(scope.directoryPath, "directory") as NativeSecurity;
|
|
298
|
+
if (!security.ok)
|
|
299
|
+
return {
|
|
300
|
+
kind: "error",
|
|
301
|
+
code: "binding_invalid",
|
|
302
|
+
message: "The managed scope security could not be verified.",
|
|
303
|
+
};
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
306
|
+
return { kind: "error", code: "binding_invalid", message: "The managed scope path could not be inspected." };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return validateExistingBinding(scope) ?? { kind: "resolved", scope };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function legacyDirectoryNames(
|
|
313
|
+
platform: ManagedScope["platform"],
|
|
314
|
+
canonicalCwd: string,
|
|
315
|
+
lexicalCwd: string,
|
|
316
|
+
): readonly string[] {
|
|
317
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
318
|
+
const encodeAbsolute = (value: string): string => `--${value.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
319
|
+
const encodeRelative = (value: string): string => value.replace(/[/\\:]/g, "-");
|
|
320
|
+
const relativeTo = (root: string, target: string): string | undefined => {
|
|
321
|
+
const relative = pathApi.relative(root, target);
|
|
322
|
+
return relative === ".." || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)
|
|
323
|
+
? undefined
|
|
324
|
+
: relative;
|
|
325
|
+
};
|
|
326
|
+
const names = new Set<string>([encodeAbsolute(canonicalCwd), encodeAbsolute(lexicalCwd)]);
|
|
327
|
+
const canonicalRoot = (root: string): string => {
|
|
328
|
+
const identity = canonicalExistingDirectoryIdentity(root);
|
|
329
|
+
return identity.ok ? identity.canonicalPath : pathApi.resolve(root);
|
|
330
|
+
};
|
|
331
|
+
const home = os.homedir();
|
|
332
|
+
// Volume-GUID canonical identities cannot be relativized against normal drive paths.
|
|
333
|
+
// Legacy directories were named from lexical drive/POSIX aliases in that case.
|
|
334
|
+
const legacyRelativeCwd = (root: string): string | undefined =>
|
|
335
|
+
relativeTo(canonicalRoot(root), canonicalCwd) ?? relativeTo(pathApi.resolve(root), lexicalCwd);
|
|
336
|
+
const homeRelative = legacyRelativeCwd(home);
|
|
337
|
+
if (homeRelative !== undefined) {
|
|
338
|
+
const encodedHome = encodeRelative(home);
|
|
339
|
+
const encodedRelative = encodeRelative(homeRelative);
|
|
340
|
+
names.add(`-${encodedRelative}`);
|
|
341
|
+
names.add(homeRelative === "" ? "----" : `---${encodedRelative}--`);
|
|
342
|
+
if (homeRelative === "") names.add(`--${encodedHome}--`);
|
|
343
|
+
else names.add(`--${encodedHome}-${encodedRelative}--`);
|
|
344
|
+
}
|
|
345
|
+
const tempRelative = legacyRelativeCwd(os.tmpdir());
|
|
346
|
+
if (tempRelative !== undefined) {
|
|
347
|
+
const encodedTempRelative = encodeRelative(tempRelative);
|
|
348
|
+
names.add(`-tmp${tempRelative ? `-${encodedTempRelative}` : ""}`);
|
|
349
|
+
names.add(`---tmp${tempRelative ? `-${encodedTempRelative}` : ""}--`);
|
|
350
|
+
}
|
|
351
|
+
return [...names];
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function fsyncManagedParent(pathname: string): void {
|
|
355
|
+
if (process.platform === "win32") return;
|
|
356
|
+
let parent = path.dirname(pathname);
|
|
357
|
+
for (;;) {
|
|
358
|
+
let descriptor: number;
|
|
359
|
+
try {
|
|
360
|
+
descriptor = fs.openSync(parent, fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT" && path.dirname(parent) !== parent) {
|
|
363
|
+
parent = path.dirname(parent);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
throw new Error("durability_failed", { cause: error });
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
fs.fsyncSync(descriptor);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
throw new Error("durability_failed", { cause: error });
|
|
372
|
+
} finally {
|
|
373
|
+
fs.closeSync(descriptor);
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function inspectCandidate(filePath: string, provenance: "v2" | "legacy"): ManagedCandidate | { code: string } {
|
|
380
|
+
try {
|
|
381
|
+
const snapshot = captureManagedFileNoFollow(filePath);
|
|
382
|
+
const lineEnd = snapshot.bytes.subarray(0, HEADER_MAX_BYTES).indexOf(0x0a);
|
|
383
|
+
if (lineEnd < 0) return { code: "invalid_header" };
|
|
384
|
+
const value: unknown = JSON.parse(snapshot.bytes.subarray(0, lineEnd).toString("utf8"));
|
|
385
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return { code: "invalid_header" };
|
|
386
|
+
const header = value as Record<string, unknown>;
|
|
387
|
+
if (header.type !== "session" || typeof header.id !== "string" || typeof header.cwd !== "string")
|
|
388
|
+
return { code: "invalid_header" };
|
|
389
|
+
if (!identityFor(header.cwd).ok) return { code: "invalid_cwd" };
|
|
390
|
+
const named = fs.lstatSync(filePath, { bigint: true });
|
|
391
|
+
if (
|
|
392
|
+
!named.isFile() ||
|
|
393
|
+
named.isSymbolicLink() ||
|
|
394
|
+
named.dev !== snapshot.identity.dev ||
|
|
395
|
+
named.ino !== snapshot.identity.ino ||
|
|
396
|
+
Number(named.size) !== snapshot.identity.size ||
|
|
397
|
+
named.mtimeNs !== snapshot.identity.mtimeNs
|
|
398
|
+
)
|
|
399
|
+
return { code: "source_changed" };
|
|
400
|
+
return {
|
|
401
|
+
sessionId: header.id,
|
|
402
|
+
path: filePath,
|
|
403
|
+
cwd: header.cwd,
|
|
404
|
+
provenance,
|
|
405
|
+
migrationState: provenance === "v2" ? "native_v2" : "legacy_unmigrated",
|
|
406
|
+
identity: {
|
|
407
|
+
canonicalPath: path.resolve(filePath),
|
|
408
|
+
sessionId: header.id,
|
|
409
|
+
...snapshot.identity,
|
|
410
|
+
mtimeMs: Number(named.mtimeMs),
|
|
411
|
+
sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
} catch (error) {
|
|
415
|
+
return { code: (error as Error).message === "source_changed" ? "source_changed" : "unreadable_candidate" };
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const MAX_DISCOVERED_LEGACY_DIRECTORIES = 256;
|
|
420
|
+
|
|
421
|
+
function discoveredLegacyDirectoryNames(scope: ManagedScope): readonly string[] {
|
|
422
|
+
const known = new Set(legacyDirectoryNames(scope.platform, scope.canonicalCwd, scope.legacyLexicalCwd));
|
|
423
|
+
const discovered = fs
|
|
424
|
+
.readdirSync(scope.sessionsRoot, { withFileTypes: true })
|
|
425
|
+
.filter(
|
|
426
|
+
entry =>
|
|
427
|
+
entry.isDirectory() &&
|
|
428
|
+
entry.name.startsWith("-") &&
|
|
429
|
+
!entry.name.startsWith("v2-") &&
|
|
430
|
+
entry.name !== MANAGED_INTERNAL_DIRECTORY,
|
|
431
|
+
)
|
|
432
|
+
.map(entry => entry.name)
|
|
433
|
+
.sort()
|
|
434
|
+
.filter(name => !known.has(name))
|
|
435
|
+
.slice(0, MAX_DISCOVERED_LEGACY_DIRECTORIES);
|
|
436
|
+
return [...known, ...discovered];
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function listDirectoryCandidates(
|
|
440
|
+
directory: string,
|
|
441
|
+
provenance: "v2" | "legacy",
|
|
442
|
+
): readonly (ManagedCandidate | { code: string })[] {
|
|
443
|
+
let directoryStat: fs.Stats;
|
|
444
|
+
try {
|
|
445
|
+
directoryStat = fs.lstatSync(directory);
|
|
446
|
+
} catch (error) {
|
|
447
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error("unsafe directory");
|
|
451
|
+
return fs
|
|
452
|
+
.readdirSync(directory, { withFileTypes: true })
|
|
453
|
+
.filter(entry => entry.name.endsWith(".jsonl"))
|
|
454
|
+
.map(entry => inspectCandidate(path.join(directory, entry.name), provenance));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function ensureManagedScope(scope: ManagedScope): Promise<ManagedScopeResolution> {
|
|
458
|
+
try {
|
|
459
|
+
ensureManagedDirectory(scope.sessionsRoot);
|
|
460
|
+
ensureManagedDirectory(scope.directoryPath);
|
|
461
|
+
const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
|
|
462
|
+
const binding = `${JSON.stringify(bindingFor(scope))}\n`;
|
|
463
|
+
try {
|
|
464
|
+
await publishManagedFileNoReplace(bindingPath, new TextEncoder().encode(binding));
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
467
|
+
}
|
|
468
|
+
return validateExistingBinding(scope) ?? { kind: "resolved", scope };
|
|
469
|
+
} catch (error) {
|
|
470
|
+
return {
|
|
471
|
+
kind: "error",
|
|
472
|
+
code: "binding_invalid",
|
|
473
|
+
message: error instanceof Error ? error.message : "The managed scope could not be initialized.",
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Synchronously create and validate the v2 binding before a default session writer exists. */
|
|
479
|
+
export function prepareManagedSessionScopeForWriteSync(scope: ManagedScope): ManagedScopeResolution {
|
|
480
|
+
try {
|
|
481
|
+
ensureManagedDirectory(scope.sessionsRoot);
|
|
482
|
+
ensureManagedDirectory(scope.directoryPath);
|
|
483
|
+
const bindingPath = path.join(scope.directoryPath, MANAGED_SESSION_BINDING_FILE);
|
|
484
|
+
try {
|
|
485
|
+
publishManagedFileNoReplaceSync(
|
|
486
|
+
bindingPath,
|
|
487
|
+
new TextEncoder().encode(`${JSON.stringify(bindingFor(scope))}\n`),
|
|
488
|
+
);
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
491
|
+
}
|
|
492
|
+
const validated = validateExistingBinding(scope);
|
|
493
|
+
if (validated) return validated;
|
|
494
|
+
const internal = managedInternalDirectory(scope);
|
|
495
|
+
ensureManagedDirectory(internal);
|
|
496
|
+
ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY));
|
|
497
|
+
ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY));
|
|
498
|
+
ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY));
|
|
499
|
+
return { kind: "resolved", scope };
|
|
500
|
+
} catch (error) {
|
|
501
|
+
return {
|
|
502
|
+
kind: "error",
|
|
503
|
+
code: "binding_invalid",
|
|
504
|
+
message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function listManagedCandidates(scope: ManagedScope): ManagedCandidateListing {
|
|
510
|
+
try {
|
|
511
|
+
let root: fs.Stats;
|
|
512
|
+
try {
|
|
513
|
+
root = fs.lstatSync(scope.sessionsRoot);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT")
|
|
516
|
+
return { kind: "complete", scope, owned: [], foreignCount: 0, invalid: [] };
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
if (!root.isDirectory() || root.isSymbolicLink())
|
|
520
|
+
return { kind: "error", code: "unsafe_root", message: "The sessions root is unsafe." };
|
|
521
|
+
const owned: ManagedCandidate[] = [];
|
|
522
|
+
const invalid: { code: string }[] = [];
|
|
523
|
+
let foreignCount = 0;
|
|
524
|
+
const directories: Array<{ path: string; provenance: "v2" | "legacy" }> = [
|
|
525
|
+
{ path: scope.directoryPath, provenance: "v2" },
|
|
526
|
+
...discoveredLegacyDirectoryNames(scope).map(directoryName => ({
|
|
527
|
+
path: path.join(scope.sessionsRoot, directoryName),
|
|
528
|
+
provenance: "legacy" as const,
|
|
529
|
+
})),
|
|
530
|
+
];
|
|
531
|
+
const seen = new Set<string>();
|
|
532
|
+
for (const directory of directories) {
|
|
533
|
+
for (const candidate of listDirectoryCandidates(directory.path, directory.provenance)) {
|
|
534
|
+
if ("code" in candidate) {
|
|
535
|
+
invalid.push({ code: candidate.code });
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const candidateIdentity = identityFor(candidate.cwd);
|
|
539
|
+
if (!candidateIdentity.ok) {
|
|
540
|
+
invalid.push({ code: "invalid_cwd" });
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (
|
|
544
|
+
candidateIdentity.platform !== scope.platform ||
|
|
545
|
+
candidateIdentity.canonicalPath !== scope.canonicalCwd
|
|
546
|
+
) {
|
|
547
|
+
foreignCount++;
|
|
548
|
+
continue;
|
|
549
|
+
}
|
|
550
|
+
if (!seen.has(candidate.identity.canonicalPath)) {
|
|
551
|
+
seen.add(candidate.identity.canonicalPath);
|
|
552
|
+
owned.push(candidate);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const visible = owned.filter(candidate => !isRetired(scope, candidate));
|
|
557
|
+
const active = visible.filter(
|
|
558
|
+
candidate =>
|
|
559
|
+
candidate.provenance === "v2" ||
|
|
560
|
+
!visible.some(
|
|
561
|
+
destination =>
|
|
562
|
+
destination.provenance === "v2" &&
|
|
563
|
+
receiptMatches(receiptPathFor(scope, candidate), candidate, destination, scope),
|
|
564
|
+
),
|
|
565
|
+
);
|
|
566
|
+
return {
|
|
567
|
+
kind: "complete",
|
|
568
|
+
scope,
|
|
569
|
+
owned: active.map(candidate => {
|
|
570
|
+
if (candidate.provenance !== "v2") return candidate;
|
|
571
|
+
const migrated = visible.some(
|
|
572
|
+
source =>
|
|
573
|
+
source.provenance === "legacy" &&
|
|
574
|
+
receiptMatches(receiptPathFor(scope, source), source, candidate, scope),
|
|
575
|
+
);
|
|
576
|
+
return migrated ? { ...candidate, migrationState: "migrated_v2" as const } : candidate;
|
|
577
|
+
}),
|
|
578
|
+
foreignCount,
|
|
579
|
+
invalid,
|
|
580
|
+
};
|
|
581
|
+
} catch (error) {
|
|
582
|
+
return {
|
|
583
|
+
kind: "error",
|
|
584
|
+
code: "scan_failed",
|
|
585
|
+
message: error instanceof Error ? error.message : "Session scan failed.",
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const MANAGED_INTERNAL_DIRECTORY = ".gjc-managed-session-internal";
|
|
591
|
+
const MANAGED_RECEIPTS_DIRECTORY = "receipts";
|
|
592
|
+
const MANAGED_LOCKS_DIRECTORY = "locks";
|
|
593
|
+
const MANAGED_TOMBSTONES_DIRECTORY = "tombstones";
|
|
594
|
+
|
|
595
|
+
function managedInternalDirectory(scope: ManagedScope): string {
|
|
596
|
+
return path.join(scope.directoryPath, MANAGED_INTERNAL_DIRECTORY);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function stableOperationName(candidate: ManagedCandidate): string {
|
|
600
|
+
return createHash("sha256")
|
|
601
|
+
.update(candidate.identity.canonicalPath)
|
|
602
|
+
.update("\0")
|
|
603
|
+
.update(candidate.identity.dev.toString())
|
|
604
|
+
.update("\0")
|
|
605
|
+
.update(candidate.identity.ino.toString())
|
|
606
|
+
.update("\0")
|
|
607
|
+
.update(candidate.identity.size.toString())
|
|
608
|
+
.update("\0")
|
|
609
|
+
.update(candidate.identity.mtimeNs.toString())
|
|
610
|
+
.update("\0")
|
|
611
|
+
.update(candidate.identity.sha256)
|
|
612
|
+
.digest("hex");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function expectedFailure(error: unknown): ManagedOpenFailure {
|
|
616
|
+
const message = error instanceof Error ? error.message : "";
|
|
617
|
+
return message === "migration_busy" ||
|
|
618
|
+
message === "binding_conflict" ||
|
|
619
|
+
message === "binding_invalid" ||
|
|
620
|
+
message === "destination_conflict" ||
|
|
621
|
+
message === "source_changed" ||
|
|
622
|
+
message === "unsafe_artifacts" ||
|
|
623
|
+
message === "durability_failed" ||
|
|
624
|
+
message === "migration_retired"
|
|
625
|
+
? message
|
|
626
|
+
: "managed_storage_unsupported";
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function sameCandidate(left: ManagedCandidate, right: ManagedCandidate): boolean {
|
|
630
|
+
return (
|
|
631
|
+
left.path === right.path &&
|
|
632
|
+
left.identity.dev === right.identity.dev &&
|
|
633
|
+
left.identity.ino === right.identity.ino &&
|
|
634
|
+
left.identity.size === right.identity.size &&
|
|
635
|
+
left.identity.mtimeNs === right.identity.mtimeNs &&
|
|
636
|
+
left.identity.sha256 === right.identity.sha256
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function receiptPathFor(
|
|
641
|
+
scope: ManagedScope,
|
|
642
|
+
source: ManagedCandidate,
|
|
643
|
+
state: "prepared" | "published" | "committed" = "committed",
|
|
644
|
+
): string {
|
|
645
|
+
const suffix = state === "committed" ? "" : `.${state}`;
|
|
646
|
+
return path.join(
|
|
647
|
+
managedInternalDirectory(scope),
|
|
648
|
+
MANAGED_RECEIPTS_DIRECTORY,
|
|
649
|
+
`${stableOperationName(source)}${suffix}.json`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function receiptMatches(
|
|
654
|
+
receiptPath: string,
|
|
655
|
+
source: ManagedCandidate,
|
|
656
|
+
destination: ManagedCandidate,
|
|
657
|
+
scope: ManagedScope,
|
|
658
|
+
): boolean {
|
|
659
|
+
try {
|
|
660
|
+
const value: unknown = JSON.parse(captureManagedFileNoFollow(receiptPath).bytes.toString("utf8"));
|
|
661
|
+
if (!value || typeof value !== "object") return false;
|
|
662
|
+
const record = value as {
|
|
663
|
+
schemaVersion?: unknown;
|
|
664
|
+
state?: unknown;
|
|
665
|
+
policy?: unknown;
|
|
666
|
+
scope?: unknown;
|
|
667
|
+
source?: {
|
|
668
|
+
path?: unknown;
|
|
669
|
+
sha256?: unknown;
|
|
670
|
+
sessionId?: unknown;
|
|
671
|
+
header?: { id?: unknown; cwd?: unknown };
|
|
672
|
+
identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
|
|
673
|
+
};
|
|
674
|
+
destination?: {
|
|
675
|
+
path?: unknown;
|
|
676
|
+
sha256?: unknown;
|
|
677
|
+
sessionId?: unknown;
|
|
678
|
+
header?: { id?: unknown; cwd?: unknown };
|
|
679
|
+
identity?: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown };
|
|
680
|
+
};
|
|
681
|
+
artifactManifest?: unknown;
|
|
682
|
+
};
|
|
683
|
+
const exact = (
|
|
684
|
+
recorded: { dev?: unknown; ino?: unknown; size?: unknown; mtimeNs?: unknown } | undefined,
|
|
685
|
+
candidate: ManagedCandidate,
|
|
686
|
+
): boolean =>
|
|
687
|
+
recorded?.dev === String(candidate.identity.dev) &&
|
|
688
|
+
recorded.ino === String(candidate.identity.ino) &&
|
|
689
|
+
recorded.size === candidate.identity.size &&
|
|
690
|
+
recorded.mtimeNs === String(candidate.identity.mtimeNs);
|
|
691
|
+
const lineage = (recorded: { dev?: unknown; ino?: unknown } | undefined, candidate: ManagedCandidate): boolean =>
|
|
692
|
+
recorded?.dev === String(candidate.identity.dev) && recorded.ino === String(candidate.identity.ino);
|
|
693
|
+
const sourceSnapshot = captureManagedFileNoFollow(source.path);
|
|
694
|
+
const destinationSnapshot = captureManagedFileNoFollow(destination.path);
|
|
695
|
+
const appendLineage =
|
|
696
|
+
sourceSnapshot.identity.dev === source.identity.dev &&
|
|
697
|
+
sourceSnapshot.identity.ino === source.identity.ino &&
|
|
698
|
+
sourceSnapshot.identity.size === source.identity.size &&
|
|
699
|
+
sourceSnapshot.identity.mtimeNs === source.identity.mtimeNs &&
|
|
700
|
+
destinationSnapshot.identity.dev === destination.identity.dev &&
|
|
701
|
+
destinationSnapshot.identity.ino === destination.identity.ino &&
|
|
702
|
+
destinationSnapshot.bytes.length >= sourceSnapshot.bytes.length &&
|
|
703
|
+
destinationSnapshot.bytes.subarray(0, sourceSnapshot.bytes.length).equals(sourceSnapshot.bytes);
|
|
704
|
+
const manifest = record.artifactManifest;
|
|
705
|
+
const validManifest =
|
|
706
|
+
Array.isArray(manifest) &&
|
|
707
|
+
manifest.every(entry => {
|
|
708
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
709
|
+
const item = entry as { kind?: unknown; path?: unknown; sha256?: unknown; size?: unknown };
|
|
710
|
+
const safePath =
|
|
711
|
+
typeof item.path === "string" && !path.isAbsolute(item.path) && !item.path.split(/[\\/]/).includes("..");
|
|
712
|
+
if (!safePath) return false;
|
|
713
|
+
if (item.kind === "directory") return true;
|
|
714
|
+
return (
|
|
715
|
+
item.kind === "file" &&
|
|
716
|
+
/^[a-f0-9]{64}$/.test(String(item.sha256)) &&
|
|
717
|
+
typeof item.size === "number" &&
|
|
718
|
+
Number.isSafeInteger(item.size) &&
|
|
719
|
+
item.size >= 0
|
|
720
|
+
);
|
|
721
|
+
});
|
|
722
|
+
return (
|
|
723
|
+
record.schemaVersion === 2 &&
|
|
724
|
+
record.state === "committed" &&
|
|
725
|
+
record.policy === "copy-retain" &&
|
|
726
|
+
record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
|
|
727
|
+
validManifest &&
|
|
728
|
+
manifestContains(destination.path, manifest as readonly ArtifactManifestEntry[]) &&
|
|
729
|
+
record.source?.path === source.path &&
|
|
730
|
+
record.source?.sessionId === source.sessionId &&
|
|
731
|
+
record.source?.header?.id === source.sessionId &&
|
|
732
|
+
record.source?.header?.cwd === source.cwd &&
|
|
733
|
+
record.source?.sha256 === source.identity.sha256 &&
|
|
734
|
+
exact(record.source?.identity, source) &&
|
|
735
|
+
record.destination?.path === destination.path &&
|
|
736
|
+
record.destination?.sessionId === destination.sessionId &&
|
|
737
|
+
record.destination?.header?.id === destination.sessionId &&
|
|
738
|
+
record.destination?.header?.cwd === destination.cwd &&
|
|
739
|
+
lineage(record.destination?.identity, destination) &&
|
|
740
|
+
appendLineage
|
|
741
|
+
);
|
|
742
|
+
} catch {
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function preparedReceiptMatches(
|
|
748
|
+
receiptPath: string,
|
|
749
|
+
scope: ManagedScope,
|
|
750
|
+
source: ManagedCandidate,
|
|
751
|
+
destination: { path: string; sessionId: string; cwd: string },
|
|
752
|
+
artifactPlan: DetachedArtifactRoot | undefined,
|
|
753
|
+
): boolean {
|
|
754
|
+
try {
|
|
755
|
+
const record = JSON.parse(captureManagedFileNoFollow(receiptPath).bytes.toString("utf8")) as Record<
|
|
756
|
+
string,
|
|
757
|
+
unknown
|
|
758
|
+
>;
|
|
759
|
+
const recordedSource = record.source as Record<string, unknown> | undefined;
|
|
760
|
+
const recordedDestination = record.destination as Record<string, unknown> | undefined;
|
|
761
|
+
const quarantine = record.sourceArtifactQuarantine as Record<string, unknown> | undefined;
|
|
762
|
+
const identity = quarantine?.identity as Record<string, unknown> | undefined;
|
|
763
|
+
return (
|
|
764
|
+
record.schemaVersion === 2 &&
|
|
765
|
+
record.state === "prepared" &&
|
|
766
|
+
record.policy === "copy-retain" &&
|
|
767
|
+
record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
|
|
768
|
+
Array.isArray(record.artifactManifest) &&
|
|
769
|
+
record.artifactManifest.length === 0 &&
|
|
770
|
+
recordedSource?.path === source.path &&
|
|
771
|
+
recordedSource.sessionId === source.sessionId &&
|
|
772
|
+
recordedSource.sha256 === source.identity.sha256 &&
|
|
773
|
+
(recordedSource.identity as Record<string, unknown> | undefined)?.dev === String(source.identity.dev) &&
|
|
774
|
+
(recordedSource.identity as Record<string, unknown> | undefined)?.ino === String(source.identity.ino) &&
|
|
775
|
+
(recordedSource.identity as Record<string, unknown> | undefined)?.size === source.identity.size &&
|
|
776
|
+
(recordedSource.identity as Record<string, unknown> | undefined)?.mtimeNs ===
|
|
777
|
+
String(source.identity.mtimeNs) &&
|
|
778
|
+
recordedDestination?.path === destination.path &&
|
|
779
|
+
recordedDestination.sessionId === destination.sessionId &&
|
|
780
|
+
recordedDestination.header instanceof Object &&
|
|
781
|
+
(recordedDestination.header as Record<string, unknown>).id === destination.sessionId &&
|
|
782
|
+
(recordedDestination.header as Record<string, unknown>).cwd === destination.cwd &&
|
|
783
|
+
(artifactPlan
|
|
784
|
+
? quarantine?.path === artifactPlan.originalPath &&
|
|
785
|
+
quarantine.detachedPath === artifactPlan.detachedPath &&
|
|
786
|
+
identity?.dev === String(artifactPlan.identity.dev) &&
|
|
787
|
+
identity.ino === String(artifactPlan.identity.ino) &&
|
|
788
|
+
identity.size === String(artifactPlan.identity.size) &&
|
|
789
|
+
identity.mtimeNs === String(artifactPlan.identity.mtimeNs) &&
|
|
790
|
+
JSON.stringify(artifactTreeSnapshot(quarantine.tree)) === JSON.stringify(artifactPlan.tree)
|
|
791
|
+
: quarantine === undefined)
|
|
792
|
+
);
|
|
793
|
+
} catch {
|
|
794
|
+
return false;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
type RetiredTarget = ManagedCandidate;
|
|
799
|
+
|
|
800
|
+
function retiredTargets(scope: ManagedScope, pathname: string): readonly RetiredTarget[] | undefined {
|
|
801
|
+
try {
|
|
802
|
+
const value: unknown = JSON.parse(captureManagedFileNoFollow(pathname).bytes.toString("utf8"));
|
|
803
|
+
if (!value || typeof value !== "object") return undefined;
|
|
804
|
+
const record = value as { schemaVersion?: unknown; state?: unknown; scope?: unknown; targets?: unknown };
|
|
805
|
+
if (
|
|
806
|
+
record.schemaVersion !== 2 ||
|
|
807
|
+
record.state !== "retired" ||
|
|
808
|
+
record.scope !== scopeDigest(scope.platform, scope.canonicalCwd) ||
|
|
809
|
+
!Array.isArray(record.targets)
|
|
810
|
+
)
|
|
811
|
+
return undefined;
|
|
812
|
+
const targets: RetiredTarget[] = [];
|
|
813
|
+
for (const target of record.targets) {
|
|
814
|
+
if (!target || typeof target !== "object" || Array.isArray(target)) return undefined;
|
|
815
|
+
const item = target as Record<string, unknown>;
|
|
816
|
+
const identity = item.identity;
|
|
817
|
+
if (!identity || typeof identity !== "object" || Array.isArray(identity)) return undefined;
|
|
818
|
+
const fields = identity as Record<string, unknown>;
|
|
819
|
+
if (
|
|
820
|
+
typeof item.path !== "string" ||
|
|
821
|
+
typeof item.sessionId !== "string" ||
|
|
822
|
+
typeof item.cwd !== "string" ||
|
|
823
|
+
!pathIsWithin(scope.sessionsRoot, item.path) ||
|
|
824
|
+
(item.provenance !== undefined && item.provenance !== "v2" && item.provenance !== "legacy") ||
|
|
825
|
+
typeof fields.canonicalPath !== "string" ||
|
|
826
|
+
typeof fields.dev !== "string" ||
|
|
827
|
+
typeof fields.ino !== "string" ||
|
|
828
|
+
typeof fields.size !== "number" ||
|
|
829
|
+
typeof fields.mtimeMs !== "number" ||
|
|
830
|
+
typeof fields.mtimeNs !== "string" ||
|
|
831
|
+
typeof fields.sha256 !== "string"
|
|
832
|
+
)
|
|
833
|
+
return undefined;
|
|
834
|
+
const provenance =
|
|
835
|
+
item.provenance === "v2" || item.provenance === "legacy"
|
|
836
|
+
? item.provenance
|
|
837
|
+
: path.dirname(item.path) === scope.directoryPath
|
|
838
|
+
? "v2"
|
|
839
|
+
: "legacy";
|
|
840
|
+
targets.push({
|
|
841
|
+
path: item.path,
|
|
842
|
+
sessionId: item.sessionId,
|
|
843
|
+
cwd: item.cwd,
|
|
844
|
+
provenance,
|
|
845
|
+
migrationState: provenance === "v2" ? "native_v2" : "legacy_unmigrated",
|
|
846
|
+
identity: {
|
|
847
|
+
canonicalPath: fields.canonicalPath,
|
|
848
|
+
dev: BigInt(fields.dev),
|
|
849
|
+
ino: BigInt(fields.ino),
|
|
850
|
+
size: fields.size,
|
|
851
|
+
mtimeMs: fields.mtimeMs,
|
|
852
|
+
mtimeNs: BigInt(fields.mtimeNs),
|
|
853
|
+
sha256: fields.sha256,
|
|
854
|
+
sessionId: item.sessionId,
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
return targets;
|
|
859
|
+
} catch {
|
|
860
|
+
return undefined;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Append-only cleanup state machine:
|
|
866
|
+
* `pending(N)` durably authorizes only its planned quarantine names before detach;
|
|
867
|
+
* a returned partial result appends `pending(N + 1)` with the observed detached
|
|
868
|
+
* identity/path. Restart accepts only a contiguous, target-bound sequence.
|
|
869
|
+
*/
|
|
870
|
+
type CleanupReceipt = {
|
|
871
|
+
attempt: number;
|
|
872
|
+
target: RetiredTarget;
|
|
873
|
+
expectedArtifactsIdentity?: SessionStorageFileIdentity;
|
|
874
|
+
expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
|
|
875
|
+
detachedArtifactsPath?: string;
|
|
876
|
+
detachedTranscriptPath?: string;
|
|
877
|
+
plannedArtifactsPath: string;
|
|
878
|
+
plannedTranscriptPath: string;
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
function cleanupReceiptPath(
|
|
882
|
+
tombstone: string,
|
|
883
|
+
target: RetiredTarget,
|
|
884
|
+
state: "pending" | "artifacts_removed" | "completed",
|
|
885
|
+
attempt: number,
|
|
886
|
+
): string {
|
|
887
|
+
return path.join(
|
|
888
|
+
path.dirname(tombstone),
|
|
889
|
+
`${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-${state}-${attempt}.json`,
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function cleanupReceipt(scope: ManagedScope, tombstone: string, receipt: CleanupReceipt): Record<string, unknown> {
|
|
894
|
+
return {
|
|
895
|
+
schemaVersion: 2,
|
|
896
|
+
state: "cleanup_pending",
|
|
897
|
+
scope: scopeDigest(scope.platform, scope.canonicalCwd),
|
|
898
|
+
tombstone,
|
|
899
|
+
attempt: receipt.attempt,
|
|
900
|
+
target: {
|
|
901
|
+
path: receipt.target.path,
|
|
902
|
+
sessionId: receipt.target.sessionId,
|
|
903
|
+
cwd: receipt.target.cwd,
|
|
904
|
+
identity: receipt.target.identity,
|
|
905
|
+
},
|
|
906
|
+
...(receipt.expectedArtifactsIdentity ? { expectedArtifactsIdentity: receipt.expectedArtifactsIdentity } : {}),
|
|
907
|
+
...(receipt.expectedArtifactsTree ? { expectedArtifactsTree: receipt.expectedArtifactsTree } : {}),
|
|
908
|
+
...(receipt.detachedArtifactsPath ? { detachedArtifactsPath: receipt.detachedArtifactsPath } : {}),
|
|
909
|
+
...(receipt.detachedTranscriptPath ? { detachedTranscriptPath: receipt.detachedTranscriptPath } : {}),
|
|
910
|
+
plannedArtifactsPath: receipt.plannedArtifactsPath,
|
|
911
|
+
plannedTranscriptPath: receipt.plannedTranscriptPath,
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
function cleanupArtifactsRemoved(
|
|
916
|
+
scope: ManagedScope,
|
|
917
|
+
tombstone: string,
|
|
918
|
+
target: RetiredTarget,
|
|
919
|
+
attempt: number,
|
|
920
|
+
): boolean {
|
|
921
|
+
try {
|
|
922
|
+
const value: unknown = JSON.parse(
|
|
923
|
+
captureManagedFileNoFollow(cleanupReceiptPath(tombstone, target, "artifacts_removed", attempt)).bytes.toString(
|
|
924
|
+
"utf8",
|
|
925
|
+
),
|
|
926
|
+
);
|
|
927
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
928
|
+
const record = value as Record<string, unknown>;
|
|
929
|
+
const recorded = record.target as Record<string, unknown> | undefined;
|
|
930
|
+
return (
|
|
931
|
+
record.schemaVersion === 2 &&
|
|
932
|
+
record.state === "artifacts_removed" &&
|
|
933
|
+
record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
|
|
934
|
+
record.tombstone === tombstone &&
|
|
935
|
+
record.attempt === attempt &&
|
|
936
|
+
recorded?.path === target.path &&
|
|
937
|
+
recorded.sessionId === target.sessionId &&
|
|
938
|
+
recorded.cwd === target.cwd
|
|
939
|
+
);
|
|
940
|
+
} catch {
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
async function publishCleanupArtifactsRemoved(
|
|
946
|
+
scope: ManagedScope,
|
|
947
|
+
tombstone: string,
|
|
948
|
+
receipt: CleanupReceipt,
|
|
949
|
+
lock: ManagedStorageLock,
|
|
950
|
+
): Promise<void> {
|
|
951
|
+
await publishManagedTombstone(
|
|
952
|
+
cleanupReceiptPath(tombstone, receipt.target, "artifacts_removed", receipt.attempt),
|
|
953
|
+
{ ...cleanupReceipt(scope, tombstone, receipt), state: "artifacts_removed" },
|
|
954
|
+
lock.assertOwned,
|
|
955
|
+
).catch(error => {
|
|
956
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
957
|
+
});
|
|
958
|
+
if (!cleanupArtifactsRemoved(scope, tombstone, receipt.target, receipt.attempt))
|
|
959
|
+
throw new Error("durability_failed");
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function isQuarantinePath(target: RetiredTarget, pathname: unknown): pathname is string {
|
|
963
|
+
return (
|
|
964
|
+
typeof pathname === "string" &&
|
|
965
|
+
path.dirname(pathname) === path.dirname(target.path) &&
|
|
966
|
+
path.basename(pathname).startsWith(".gjc-delete-")
|
|
967
|
+
);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function deterministicRemovalRoot(plannedRoot: string): string {
|
|
971
|
+
return `${plannedRoot}.removing`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
function isAuthorizedArtifactRoot(target: RetiredTarget, plannedRoot: string, pathname: unknown): pathname is string {
|
|
975
|
+
return (
|
|
976
|
+
isQuarantinePath(target, pathname) &&
|
|
977
|
+
(pathname === plannedRoot || pathname === deterministicRemovalRoot(plannedRoot))
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function artifactRootsAbsent(
|
|
982
|
+
_scope: ManagedScope,
|
|
983
|
+
tombstone: string,
|
|
984
|
+
target: RetiredTarget,
|
|
985
|
+
pending: CleanupReceipt,
|
|
986
|
+
): boolean {
|
|
987
|
+
const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
|
|
988
|
+
const roots = new Set<string>([
|
|
989
|
+
pending.plannedArtifactsPath,
|
|
990
|
+
deterministicRemovalRoot(pending.plannedArtifactsPath),
|
|
991
|
+
...(pending.detachedArtifactsPath ? [pending.detachedArtifactsPath] : []),
|
|
992
|
+
]);
|
|
993
|
+
for (const name of fs.readdirSync(path.dirname(tombstone))) {
|
|
994
|
+
if (!name.startsWith(prefix) || !name.endsWith(".json")) continue;
|
|
995
|
+
const record = JSON.parse(
|
|
996
|
+
captureManagedFileNoFollow(path.join(path.dirname(tombstone), name)).bytes.toString("utf8"),
|
|
997
|
+
) as {
|
|
998
|
+
plannedArtifactsPath?: unknown;
|
|
999
|
+
detachedArtifactsPath?: unknown;
|
|
1000
|
+
};
|
|
1001
|
+
if (isQuarantinePath(target, record.plannedArtifactsPath)) {
|
|
1002
|
+
roots.add(record.plannedArtifactsPath);
|
|
1003
|
+
roots.add(deterministicRemovalRoot(record.plannedArtifactsPath));
|
|
1004
|
+
}
|
|
1005
|
+
if (isQuarantinePath(target, record.detachedArtifactsPath)) roots.add(record.detachedArtifactsPath);
|
|
1006
|
+
}
|
|
1007
|
+
return [...roots].every(pathname => !fs.existsSync(pathname));
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function sameArtifactRootIdentity(left: SessionStorageFileIdentity, right: SessionStorageFileIdentity): boolean {
|
|
1011
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* A pending receipt is written before the native no-replace detach. If a crash
|
|
1016
|
+
* happens after that detach, the next receipt may only inherit the exact
|
|
1017
|
+
* planned quarantine pathname and identity from its predecessor.
|
|
1018
|
+
*/
|
|
1019
|
+
function probePlannedCleanupDetach(target: RetiredTarget, pending: CleanupReceipt): CleanupReceipt {
|
|
1020
|
+
let detachedArtifactsPath = pending.detachedArtifactsPath;
|
|
1021
|
+
let detachedTranscriptPath = pending.detachedTranscriptPath;
|
|
1022
|
+
for (const pathname of [pending.plannedArtifactsPath, deterministicRemovalRoot(pending.plannedArtifactsPath)]) {
|
|
1023
|
+
if (!fs.existsSync(pathname)) continue;
|
|
1024
|
+
if (!pending.expectedArtifactsIdentity) throw new Error("durability_failed");
|
|
1025
|
+
const observed = artifactIdentityAt(pathname);
|
|
1026
|
+
if (!observed || !sameArtifactRootIdentity(observed, pending.expectedArtifactsIdentity))
|
|
1027
|
+
throw new Error("durability_failed");
|
|
1028
|
+
if (detachedArtifactsPath && detachedArtifactsPath !== pathname) throw new Error("durability_failed");
|
|
1029
|
+
detachedArtifactsPath = pathname;
|
|
1030
|
+
}
|
|
1031
|
+
if (fs.existsSync(pending.plannedTranscriptPath)) {
|
|
1032
|
+
const observed = captureManagedFileNoFollow(pending.plannedTranscriptPath);
|
|
1033
|
+
const digest = createHash("sha256").update(observed.bytes).digest("hex");
|
|
1034
|
+
if (
|
|
1035
|
+
observed.identity.dev !== target.identity.dev ||
|
|
1036
|
+
observed.identity.ino !== target.identity.ino ||
|
|
1037
|
+
observed.identity.size !== target.identity.size ||
|
|
1038
|
+
observed.identity.mtimeNs !== target.identity.mtimeNs ||
|
|
1039
|
+
digest !== target.identity.sha256
|
|
1040
|
+
)
|
|
1041
|
+
throw new Error("durability_failed");
|
|
1042
|
+
detachedTranscriptPath = pending.plannedTranscriptPath;
|
|
1043
|
+
}
|
|
1044
|
+
return { ...pending, detachedArtifactsPath, detachedTranscriptPath };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function artifactIdentityAt(pathname: string): SessionStorageFileIdentity | undefined {
|
|
1048
|
+
try {
|
|
1049
|
+
const stat = fs.lstatSync(pathname, { bigint: true });
|
|
1050
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return undefined;
|
|
1051
|
+
return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs, sha256: "" };
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1054
|
+
throw error;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function artifactTreeSnapshot(value: unknown): NativeDirectoryTreeSnapshot | undefined {
|
|
1059
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
1060
|
+
const snapshot = value as Record<string, unknown>;
|
|
1061
|
+
if (typeof snapshot.rootDev !== "string" || typeof snapshot.rootIno !== "string" || !Array.isArray(snapshot.entries))
|
|
1062
|
+
return undefined;
|
|
1063
|
+
if (
|
|
1064
|
+
snapshot.entries.length === 0 ||
|
|
1065
|
+
!snapshot.entries.every(entry => {
|
|
1066
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
|
|
1067
|
+
const item = entry as Record<string, unknown>;
|
|
1068
|
+
return (
|
|
1069
|
+
typeof item.relativePath === "string" &&
|
|
1070
|
+
!path.isAbsolute(item.relativePath) &&
|
|
1071
|
+
!item.relativePath.split(/[\\/]/).includes("..") &&
|
|
1072
|
+
(item.kind === "file" || item.kind === "directory") &&
|
|
1073
|
+
typeof item.dev === "string" &&
|
|
1074
|
+
typeof item.ino === "string" &&
|
|
1075
|
+
typeof item.size === "string" &&
|
|
1076
|
+
typeof item.mtimeNs === "string" &&
|
|
1077
|
+
(item.sha256 === undefined || typeof item.sha256 === "string")
|
|
1078
|
+
);
|
|
1079
|
+
})
|
|
1080
|
+
)
|
|
1081
|
+
return undefined;
|
|
1082
|
+
return snapshot as unknown as NativeDirectoryTreeSnapshot;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function pendingCleanupReceipt(
|
|
1086
|
+
scope: ManagedScope,
|
|
1087
|
+
tombstone: string,
|
|
1088
|
+
target: RetiredTarget,
|
|
1089
|
+
): CleanupReceipt | undefined {
|
|
1090
|
+
try {
|
|
1091
|
+
const prefix = `${path.basename(tombstone, ".json")}.${stableOperationName(target)}.cleanup-pending-`;
|
|
1092
|
+
const records = fs
|
|
1093
|
+
.readdirSync(path.dirname(tombstone))
|
|
1094
|
+
.filter(name => name.startsWith(prefix) && name.endsWith(".json"))
|
|
1095
|
+
.map(
|
|
1096
|
+
name =>
|
|
1097
|
+
JSON.parse(
|
|
1098
|
+
captureManagedFileNoFollow(path.join(path.dirname(tombstone), name)).bytes.toString("utf8"),
|
|
1099
|
+
) as unknown,
|
|
1100
|
+
)
|
|
1101
|
+
.filter(
|
|
1102
|
+
(value): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value),
|
|
1103
|
+
)
|
|
1104
|
+
.sort((left, right) => Number(left.attempt) - Number(right.attempt));
|
|
1105
|
+
let latest: CleanupReceipt | undefined;
|
|
1106
|
+
const plannedPaths = new Set<string>();
|
|
1107
|
+
for (const record of records) {
|
|
1108
|
+
const attempt = record.attempt;
|
|
1109
|
+
const recorded = record.target as Record<string, unknown> | undefined;
|
|
1110
|
+
const identity = recorded?.identity as Record<string, unknown> | undefined;
|
|
1111
|
+
if (
|
|
1112
|
+
record.schemaVersion !== 2 ||
|
|
1113
|
+
record.state !== "cleanup_pending" ||
|
|
1114
|
+
record.scope !== scopeDigest(scope.platform, scope.canonicalCwd) ||
|
|
1115
|
+
record.tombstone !== tombstone ||
|
|
1116
|
+
typeof attempt !== "number" ||
|
|
1117
|
+
!Number.isSafeInteger(attempt) ||
|
|
1118
|
+
attempt !== (latest?.attempt ?? 0) + 1 ||
|
|
1119
|
+
recorded?.path !== target.path ||
|
|
1120
|
+
recorded.sessionId !== target.sessionId ||
|
|
1121
|
+
recorded.cwd !== target.cwd ||
|
|
1122
|
+
identity?.dev !== String(target.identity.dev) ||
|
|
1123
|
+
identity.ino !== String(target.identity.ino) ||
|
|
1124
|
+
identity.size !== target.identity.size ||
|
|
1125
|
+
identity.mtimeNs !== String(target.identity.mtimeNs) ||
|
|
1126
|
+
identity.sha256 !== target.identity.sha256 ||
|
|
1127
|
+
!isQuarantinePath(target, record.plannedArtifactsPath) ||
|
|
1128
|
+
!isQuarantinePath(target, record.plannedTranscriptPath) ||
|
|
1129
|
+
record.plannedArtifactsPath === record.plannedTranscriptPath ||
|
|
1130
|
+
plannedPaths.has(record.plannedArtifactsPath as string) ||
|
|
1131
|
+
plannedPaths.has(record.plannedTranscriptPath as string) ||
|
|
1132
|
+
(record.detachedArtifactsPath !== undefined && !isQuarantinePath(target, record.detachedArtifactsPath)) ||
|
|
1133
|
+
(record.detachedTranscriptPath !== undefined && !isQuarantinePath(target, record.detachedTranscriptPath))
|
|
1134
|
+
)
|
|
1135
|
+
throw new Error("durability_failed");
|
|
1136
|
+
const artifact = record.expectedArtifactsIdentity as Record<string, unknown> | undefined;
|
|
1137
|
+
const expectedArtifactsIdentity = artifact
|
|
1138
|
+
? typeof artifact.dev === "string" &&
|
|
1139
|
+
typeof artifact.ino === "string" &&
|
|
1140
|
+
typeof artifact.size === "number" &&
|
|
1141
|
+
typeof artifact.mtimeNs === "string" &&
|
|
1142
|
+
typeof artifact.sha256 === "string"
|
|
1143
|
+
? {
|
|
1144
|
+
dev: BigInt(artifact.dev),
|
|
1145
|
+
ino: BigInt(artifact.ino),
|
|
1146
|
+
size: artifact.size,
|
|
1147
|
+
mtimeNs: BigInt(artifact.mtimeNs),
|
|
1148
|
+
sha256: artifact.sha256,
|
|
1149
|
+
}
|
|
1150
|
+
: undefined
|
|
1151
|
+
: undefined;
|
|
1152
|
+
if (artifact && !expectedArtifactsIdentity) throw new Error("durability_failed");
|
|
1153
|
+
const expectedArtifactsTree =
|
|
1154
|
+
record.expectedArtifactsTree === undefined ? undefined : artifactTreeSnapshot(record.expectedArtifactsTree);
|
|
1155
|
+
if (record.expectedArtifactsTree !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
|
|
1156
|
+
if (record.detachedArtifactsPath !== undefined && !expectedArtifactsTree) throw new Error("durability_failed");
|
|
1157
|
+
if (
|
|
1158
|
+
latest &&
|
|
1159
|
+
((record.detachedArtifactsPath !== undefined &&
|
|
1160
|
+
![...plannedPaths].some(planned =>
|
|
1161
|
+
isAuthorizedArtifactRoot(target, planned, record.detachedArtifactsPath),
|
|
1162
|
+
)) ||
|
|
1163
|
+
(record.detachedTranscriptPath !== undefined && !plannedPaths.has(record.detachedTranscriptPath)))
|
|
1164
|
+
)
|
|
1165
|
+
throw new Error("durability_failed");
|
|
1166
|
+
plannedPaths.add(record.plannedArtifactsPath as string);
|
|
1167
|
+
plannedPaths.add(record.plannedTranscriptPath as string);
|
|
1168
|
+
latest = {
|
|
1169
|
+
attempt,
|
|
1170
|
+
target,
|
|
1171
|
+
expectedArtifactsIdentity,
|
|
1172
|
+
expectedArtifactsTree,
|
|
1173
|
+
detachedArtifactsPath: record.detachedArtifactsPath as string | undefined,
|
|
1174
|
+
detachedTranscriptPath: record.detachedTranscriptPath as string | undefined,
|
|
1175
|
+
plannedArtifactsPath: record.plannedArtifactsPath as string,
|
|
1176
|
+
plannedTranscriptPath: record.plannedTranscriptPath as string,
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
return latest;
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if ((error as Error).message === "durability_failed") throw error;
|
|
1182
|
+
return undefined;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function artifactIdentityForCleanup(target: RetiredTarget): SessionStorageFileIdentity | undefined {
|
|
1187
|
+
try {
|
|
1188
|
+
const stat = fs.lstatSync(target.path.slice(0, -6), { bigint: true });
|
|
1189
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("unsafe_artifacts");
|
|
1190
|
+
return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs, sha256: "" };
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1193
|
+
throw error;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
type NativeDirectorySnapshotApi = {
|
|
1198
|
+
snapshotDirectoryTree(
|
|
1199
|
+
pathname: string,
|
|
1200
|
+
): { ok: true; snapshot: NativeDirectoryTreeSnapshot } | { ok: false; code: string; snapshot?: undefined };
|
|
1201
|
+
};
|
|
1202
|
+
function snapshotArtifactTree(pathname: string): NativeDirectoryTreeSnapshot {
|
|
1203
|
+
validateManagedArtifactTree(pathname);
|
|
1204
|
+
const result = (native as unknown as NativeDirectorySnapshotApi).snapshotDirectoryTree(pathname);
|
|
1205
|
+
if (!result.ok || !result.snapshot) throw new Error(result.ok ? "unsafe_artifacts" : result.code);
|
|
1206
|
+
return result.snapshot;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
function nextCleanupReceipt(target: RetiredTarget, pending: CleanupReceipt | undefined): CleanupReceipt {
|
|
1210
|
+
const attempt = (pending?.attempt ?? 0) + 1;
|
|
1211
|
+
const directory = path.dirname(target.path);
|
|
1212
|
+
const operation = stableOperationName(target);
|
|
1213
|
+
const expectedArtifactsIdentity = pending?.expectedArtifactsIdentity ?? artifactIdentityForCleanup(target);
|
|
1214
|
+
const expectedArtifactsTree =
|
|
1215
|
+
pending?.expectedArtifactsTree ??
|
|
1216
|
+
(expectedArtifactsIdentity ? snapshotArtifactTree(target.path.slice(0, -6)) : undefined);
|
|
1217
|
+
if (pending?.expectedArtifactsIdentity && !expectedArtifactsTree) throw new Error("durability_failed");
|
|
1218
|
+
return {
|
|
1219
|
+
attempt,
|
|
1220
|
+
target,
|
|
1221
|
+
expectedArtifactsIdentity,
|
|
1222
|
+
expectedArtifactsTree,
|
|
1223
|
+
detachedArtifactsPath: pending?.detachedArtifactsPath,
|
|
1224
|
+
detachedTranscriptPath: pending?.detachedTranscriptPath,
|
|
1225
|
+
plannedArtifactsPath: path.join(directory, `.gjc-delete-${operation}-artifacts-${attempt}`),
|
|
1226
|
+
plannedTranscriptPath: path.join(directory, `.gjc-delete-${operation}-transcript-${attempt}`),
|
|
1227
|
+
};
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function requiresFreshCleanupPlan(pending: CleanupReceipt): boolean {
|
|
1231
|
+
return (
|
|
1232
|
+
(pending.detachedArtifactsPath !== undefined && pending.detachedArtifactsPath === pending.plannedArtifactsPath) ||
|
|
1233
|
+
(pending.detachedTranscriptPath !== undefined && pending.detachedTranscriptPath === pending.plannedTranscriptPath)
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function publishCleanupPending(
|
|
1238
|
+
scope: ManagedScope,
|
|
1239
|
+
tombstone: string,
|
|
1240
|
+
receipt: CleanupReceipt,
|
|
1241
|
+
lock: ManagedStorageLock,
|
|
1242
|
+
): Promise<void> {
|
|
1243
|
+
try {
|
|
1244
|
+
await publishManagedTombstone(
|
|
1245
|
+
cleanupReceiptPath(tombstone, receipt.target, "pending", receipt.attempt),
|
|
1246
|
+
cleanupReceipt(scope, tombstone, receipt),
|
|
1247
|
+
lock.assertOwned,
|
|
1248
|
+
);
|
|
1249
|
+
} catch (error) {
|
|
1250
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
1251
|
+
}
|
|
1252
|
+
const persisted = pendingCleanupReceipt(scope, tombstone, receipt.target);
|
|
1253
|
+
if (!persisted || persisted.attempt !== receipt.attempt) throw new Error("durability_failed");
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function cleanupCompleted(scope: ManagedScope, tombstone: string, target: RetiredTarget): boolean {
|
|
1257
|
+
try {
|
|
1258
|
+
const value: unknown = JSON.parse(
|
|
1259
|
+
captureManagedFileNoFollow(cleanupReceiptPath(tombstone, target, "completed", 1)).bytes.toString("utf8"),
|
|
1260
|
+
);
|
|
1261
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1262
|
+
const record = value as Record<string, unknown>;
|
|
1263
|
+
const recorded = record.target as Record<string, unknown> | undefined;
|
|
1264
|
+
const identity = recorded?.identity as Record<string, unknown> | undefined;
|
|
1265
|
+
return (
|
|
1266
|
+
record.schemaVersion === 1 &&
|
|
1267
|
+
record.state === "cleanup_completed" &&
|
|
1268
|
+
record.scope === scopeDigest(scope.platform, scope.canonicalCwd) &&
|
|
1269
|
+
record.tombstone === tombstone &&
|
|
1270
|
+
record.attempt === 1 &&
|
|
1271
|
+
recorded?.path === target.path &&
|
|
1272
|
+
recorded.sessionId === target.sessionId &&
|
|
1273
|
+
recorded.cwd === target.cwd &&
|
|
1274
|
+
identity?.dev === String(target.identity.dev) &&
|
|
1275
|
+
identity.ino === String(target.identity.ino) &&
|
|
1276
|
+
identity.size === target.identity.size &&
|
|
1277
|
+
identity.mtimeNs === String(target.identity.mtimeNs) &&
|
|
1278
|
+
identity.sha256 === target.identity.sha256
|
|
1279
|
+
);
|
|
1280
|
+
} catch {
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function publishCleanupCompleted(
|
|
1286
|
+
scope: ManagedScope,
|
|
1287
|
+
tombstone: string,
|
|
1288
|
+
target: RetiredTarget,
|
|
1289
|
+
lock: ManagedStorageLock,
|
|
1290
|
+
): Promise<void> {
|
|
1291
|
+
try {
|
|
1292
|
+
await publishManagedTombstone(
|
|
1293
|
+
cleanupReceiptPath(tombstone, target, "completed", 1),
|
|
1294
|
+
{
|
|
1295
|
+
schemaVersion: 1,
|
|
1296
|
+
state: "cleanup_completed",
|
|
1297
|
+
scope: scopeDigest(scope.platform, scope.canonicalCwd),
|
|
1298
|
+
tombstone,
|
|
1299
|
+
attempt: 1,
|
|
1300
|
+
target: { path: target.path, sessionId: target.sessionId, cwd: target.cwd, identity: target.identity },
|
|
1301
|
+
},
|
|
1302
|
+
lock.assertOwned,
|
|
1303
|
+
);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function tombstonePathContaining(scope: ManagedScope, candidate: ManagedCandidate): string | undefined {
|
|
1310
|
+
const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY);
|
|
1311
|
+
try {
|
|
1312
|
+
for (const name of fs.readdirSync(directory)) {
|
|
1313
|
+
const pathname = path.join(directory, name);
|
|
1314
|
+
if (retiredTargets(scope, pathname)?.some(target => sameCandidate(target, candidate))) return pathname;
|
|
1315
|
+
}
|
|
1316
|
+
} catch {
|
|
1317
|
+
return undefined;
|
|
1318
|
+
}
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function isRetired(scope: ManagedScope, candidate: ManagedCandidate): boolean {
|
|
1323
|
+
const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY);
|
|
1324
|
+
try {
|
|
1325
|
+
for (const name of fs.readdirSync(directory)) {
|
|
1326
|
+
const pathname = path.join(directory, name);
|
|
1327
|
+
const value: unknown = JSON.parse(captureManagedFileNoFollow(pathname).bytes.toString("utf8"));
|
|
1328
|
+
if (!value || typeof value !== "object") continue;
|
|
1329
|
+
const record = value as { schemaVersion?: unknown; state?: unknown; scope?: unknown; targets?: unknown };
|
|
1330
|
+
if (
|
|
1331
|
+
record.schemaVersion !== 2 ||
|
|
1332
|
+
record.state !== "retired" ||
|
|
1333
|
+
record.scope !== scopeDigest(scope.platform, scope.canonicalCwd) ||
|
|
1334
|
+
!Array.isArray(record.targets)
|
|
1335
|
+
)
|
|
1336
|
+
continue;
|
|
1337
|
+
if (
|
|
1338
|
+
record.targets.some(target => {
|
|
1339
|
+
if (!target || typeof target !== "object") return false;
|
|
1340
|
+
const value = target as {
|
|
1341
|
+
path?: unknown;
|
|
1342
|
+
sessionId?: unknown;
|
|
1343
|
+
cwd?: unknown;
|
|
1344
|
+
identity?: {
|
|
1345
|
+
canonicalPath?: unknown;
|
|
1346
|
+
dev?: unknown;
|
|
1347
|
+
ino?: unknown;
|
|
1348
|
+
size?: unknown;
|
|
1349
|
+
mtimeNs?: unknown;
|
|
1350
|
+
sha256?: unknown;
|
|
1351
|
+
};
|
|
1352
|
+
};
|
|
1353
|
+
const identity = value.identity;
|
|
1354
|
+
if (!identity) return false;
|
|
1355
|
+
return (
|
|
1356
|
+
value.path === candidate.path &&
|
|
1357
|
+
value.sessionId === candidate.sessionId &&
|
|
1358
|
+
value.cwd === candidate.cwd &&
|
|
1359
|
+
identity.canonicalPath === candidate.identity.canonicalPath &&
|
|
1360
|
+
identity.dev === String(candidate.identity.dev) &&
|
|
1361
|
+
identity.ino === String(candidate.identity.ino) &&
|
|
1362
|
+
identity.size === candidate.identity.size &&
|
|
1363
|
+
identity.mtimeNs === String(candidate.identity.mtimeNs) &&
|
|
1364
|
+
identity.sha256 === candidate.identity.sha256
|
|
1365
|
+
);
|
|
1366
|
+
})
|
|
1367
|
+
)
|
|
1368
|
+
return true;
|
|
1369
|
+
}
|
|
1370
|
+
} catch {
|
|
1371
|
+
/* a missing/malformed tombstone grants no retirement authority */
|
|
1372
|
+
}
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
type ArtifactManifestEntry =
|
|
1377
|
+
| { kind: "directory"; path: string }
|
|
1378
|
+
| { kind: "file"; path: string; sha256: string; size: number };
|
|
1379
|
+
|
|
1380
|
+
function artifactManifestFromSnapshot(snapshot: NativeDirectoryTreeSnapshot): readonly ArtifactManifestEntry[] {
|
|
1381
|
+
return snapshot.entries
|
|
1382
|
+
.map(entry => {
|
|
1383
|
+
if (entry.kind === "directory") return { kind: "directory" as const, path: entry.relativePath };
|
|
1384
|
+
const size = Number(entry.size);
|
|
1385
|
+
if (
|
|
1386
|
+
entry.kind !== "file" ||
|
|
1387
|
+
!Number.isSafeInteger(size) ||
|
|
1388
|
+
size < 0 ||
|
|
1389
|
+
typeof entry.sha256 !== "string" ||
|
|
1390
|
+
!/^[a-f0-9]{64}$/.test(entry.sha256)
|
|
1391
|
+
)
|
|
1392
|
+
throw new Error("unsafe_artifacts");
|
|
1393
|
+
return { kind: "file" as const, path: entry.relativePath, size, sha256: entry.sha256 };
|
|
1394
|
+
})
|
|
1395
|
+
.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
function artifactManifest(transcriptPath: string, rootOverride?: string): readonly ArtifactManifestEntry[] {
|
|
1399
|
+
const root = rootOverride ?? transcriptPath.slice(0, -6);
|
|
1400
|
+
try {
|
|
1401
|
+
validateManagedArtifactTree(root);
|
|
1402
|
+
const entries: ArtifactManifestEntry[] = [{ kind: "directory", path: "" }];
|
|
1403
|
+
const walk = (directory: string): void => {
|
|
1404
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
1405
|
+
const pathname = path.join(directory, entry.name);
|
|
1406
|
+
const relative = path.relative(root, pathname).split(path.sep).join("/");
|
|
1407
|
+
if (entry.isDirectory()) {
|
|
1408
|
+
entries.push({ kind: "directory", path: relative });
|
|
1409
|
+
walk(pathname);
|
|
1410
|
+
} else {
|
|
1411
|
+
const snapshot = captureManagedFileNoFollow(pathname);
|
|
1412
|
+
entries.push({
|
|
1413
|
+
kind: "file",
|
|
1414
|
+
path: relative,
|
|
1415
|
+
size: snapshot.bytes.byteLength,
|
|
1416
|
+
sha256: createHash("sha256").update(snapshot.bytes).digest("hex"),
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
walk(root);
|
|
1422
|
+
return entries.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
|
|
1423
|
+
} catch (error) {
|
|
1424
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
1425
|
+
throw error;
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function manifestMatches(
|
|
1430
|
+
transcriptPath: string,
|
|
1431
|
+
manifest: readonly ArtifactManifestEntry[],
|
|
1432
|
+
rootOverride?: string,
|
|
1433
|
+
): boolean {
|
|
1434
|
+
try {
|
|
1435
|
+
const actual = artifactManifest(transcriptPath, rootOverride);
|
|
1436
|
+
return (
|
|
1437
|
+
actual.length === manifest.length &&
|
|
1438
|
+
actual.every((entry, index) => {
|
|
1439
|
+
const expected = manifest[index];
|
|
1440
|
+
return entry.kind === "directory"
|
|
1441
|
+
? expected?.kind === "directory" && entry.path === expected.path
|
|
1442
|
+
: expected?.kind === "file" &&
|
|
1443
|
+
entry.path === expected.path &&
|
|
1444
|
+
entry.sha256 === expected.sha256 &&
|
|
1445
|
+
entry.size === expected.size;
|
|
1446
|
+
})
|
|
1447
|
+
);
|
|
1448
|
+
} catch {
|
|
1449
|
+
return false;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
function manifestContains(transcriptPath: string, manifest: readonly ArtifactManifestEntry[]): boolean {
|
|
1454
|
+
try {
|
|
1455
|
+
const actual = artifactManifest(transcriptPath);
|
|
1456
|
+
return manifest.every(expected =>
|
|
1457
|
+
actual.some(entry =>
|
|
1458
|
+
entry.kind === "directory"
|
|
1459
|
+
? expected.kind === "directory" && entry.path === expected.path
|
|
1460
|
+
: expected.kind === "file" &&
|
|
1461
|
+
entry.path === expected.path &&
|
|
1462
|
+
entry.sha256 === expected.sha256 &&
|
|
1463
|
+
entry.size === expected.size,
|
|
1464
|
+
),
|
|
1465
|
+
);
|
|
1466
|
+
} catch {
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
type DetachedArtifactRoot = {
|
|
1472
|
+
originalPath: string;
|
|
1473
|
+
detachedPath: string;
|
|
1474
|
+
identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint };
|
|
1475
|
+
tree: NativeDirectoryTreeSnapshot;
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
function planArtifactRootForMigration(sourceTranscript: string, operation: string): DetachedArtifactRoot | undefined {
|
|
1479
|
+
const originalPath = sourceTranscript.slice(0, -6);
|
|
1480
|
+
let stat: fs.BigIntStats;
|
|
1481
|
+
try {
|
|
1482
|
+
stat = fs.lstatSync(originalPath, { bigint: true });
|
|
1483
|
+
} catch (error) {
|
|
1484
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1485
|
+
throw error;
|
|
1486
|
+
}
|
|
1487
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("unsafe_artifacts");
|
|
1488
|
+
return {
|
|
1489
|
+
originalPath,
|
|
1490
|
+
detachedPath: path.join(path.dirname(originalPath), `.gjc-migrate-${operation}-artifacts`),
|
|
1491
|
+
identity: { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeNs: stat.mtimeNs },
|
|
1492
|
+
tree: snapshotArtifactTree(originalPath),
|
|
1493
|
+
};
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
function sameDirectoryObject(leftPath: string, rightPath: string): boolean {
|
|
1497
|
+
try {
|
|
1498
|
+
const left = fs.lstatSync(leftPath, { bigint: true });
|
|
1499
|
+
const right = fs.lstatSync(rightPath, { bigint: true });
|
|
1500
|
+
return (
|
|
1501
|
+
left.isDirectory() &&
|
|
1502
|
+
right.isDirectory() &&
|
|
1503
|
+
!left.isSymbolicLink() &&
|
|
1504
|
+
!right.isSymbolicLink() &&
|
|
1505
|
+
left.dev === right.dev &&
|
|
1506
|
+
left.ino === right.ino
|
|
1507
|
+
);
|
|
1508
|
+
} catch {
|
|
1509
|
+
return false;
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function matchesDetachedArtifactRoot(pathname: string, plan: DetachedArtifactRoot): boolean {
|
|
1514
|
+
try {
|
|
1515
|
+
const stat = fs.lstatSync(pathname, { bigint: true });
|
|
1516
|
+
return (
|
|
1517
|
+
stat.isDirectory() &&
|
|
1518
|
+
!stat.isSymbolicLink() &&
|
|
1519
|
+
stat.dev === plan.identity.dev &&
|
|
1520
|
+
stat.ino === plan.identity.ino &&
|
|
1521
|
+
stat.size === plan.identity.size &&
|
|
1522
|
+
stat.mtimeNs === plan.identity.mtimeNs &&
|
|
1523
|
+
sameDirectoryObject(path.dirname(pathname), path.dirname(plan.detachedPath))
|
|
1524
|
+
);
|
|
1525
|
+
} catch {
|
|
1526
|
+
return false;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function detachArtifactRootForMigration(plan: DetachedArtifactRoot): DetachedArtifactRoot {
|
|
1531
|
+
const result = native.exactUnlink(plan.originalPath, {
|
|
1532
|
+
...plan.identity,
|
|
1533
|
+
directory: true,
|
|
1534
|
+
detachOnly: true,
|
|
1535
|
+
quarantineName: path.basename(plan.detachedPath),
|
|
1536
|
+
});
|
|
1537
|
+
if (!result.ok || !result.detachedPath || !matchesDetachedArtifactRoot(result.detachedPath, plan))
|
|
1538
|
+
throw new Error("durability_failed");
|
|
1539
|
+
return { ...plan, detachedPath: result.detachedPath };
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function restorePreparedArtifactRoot(scope: ManagedScope, source: ManagedCandidate): void {
|
|
1543
|
+
const receipt = receiptPathFor(scope, source, "prepared");
|
|
1544
|
+
let record: {
|
|
1545
|
+
sourceArtifactQuarantine?: {
|
|
1546
|
+
path?: unknown;
|
|
1547
|
+
detachedPath?: unknown;
|
|
1548
|
+
identity?: Record<string, unknown>;
|
|
1549
|
+
tree?: unknown;
|
|
1550
|
+
};
|
|
1551
|
+
};
|
|
1552
|
+
try {
|
|
1553
|
+
record = JSON.parse(captureManagedFileNoFollow(receipt).bytes.toString("utf8")) as typeof record;
|
|
1554
|
+
} catch {
|
|
1555
|
+
if (!fs.existsSync(receipt)) return;
|
|
1556
|
+
throw new Error("durability_failed");
|
|
1557
|
+
}
|
|
1558
|
+
const quarantine = record.sourceArtifactQuarantine;
|
|
1559
|
+
if (!quarantine) return;
|
|
1560
|
+
const identity = quarantine.identity;
|
|
1561
|
+
if (
|
|
1562
|
+
quarantine.path !== source.path.slice(0, -6) ||
|
|
1563
|
+
typeof quarantine.detachedPath !== "string" ||
|
|
1564
|
+
path.dirname(quarantine.detachedPath) !== path.dirname(source.path) ||
|
|
1565
|
+
!path.basename(quarantine.detachedPath).startsWith(".gjc-migrate-") ||
|
|
1566
|
+
!artifactTreeSnapshot(quarantine.tree) ||
|
|
1567
|
+
!identity ||
|
|
1568
|
+
typeof identity.dev !== "string" ||
|
|
1569
|
+
typeof identity.ino !== "string" ||
|
|
1570
|
+
typeof identity.size !== "string" ||
|
|
1571
|
+
typeof identity.mtimeNs !== "string"
|
|
1572
|
+
)
|
|
1573
|
+
throw new Error("durability_failed");
|
|
1574
|
+
const expectedTree = artifactTreeSnapshot(quarantine.tree)!;
|
|
1575
|
+
const assertPreparedTree = (pathname: string): void => {
|
|
1576
|
+
validateManagedArtifactTree(pathname);
|
|
1577
|
+
const observed = native.snapshotDirectoryTree(pathname);
|
|
1578
|
+
if (!observed.ok || !observed.snapshot || JSON.stringify(observed.snapshot) !== JSON.stringify(expectedTree))
|
|
1579
|
+
throw new Error("durability_failed");
|
|
1580
|
+
};
|
|
1581
|
+
if (fs.existsSync(quarantine.path)) {
|
|
1582
|
+
const existing = fs.lstatSync(quarantine.path, { bigint: true });
|
|
1583
|
+
if (
|
|
1584
|
+
existing.isSymbolicLink() ||
|
|
1585
|
+
!existing.isDirectory() ||
|
|
1586
|
+
existing.dev !== BigInt(identity.dev) ||
|
|
1587
|
+
existing.ino !== BigInt(identity.ino) ||
|
|
1588
|
+
existing.size !== BigInt(identity.size) ||
|
|
1589
|
+
existing.mtimeNs !== BigInt(identity.mtimeNs)
|
|
1590
|
+
)
|
|
1591
|
+
throw new Error("durability_failed");
|
|
1592
|
+
assertPreparedTree(quarantine.path);
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
assertPreparedTree(quarantine.detachedPath);
|
|
1596
|
+
const result = native.exactRestore(quarantine.detachedPath, quarantine.path, {
|
|
1597
|
+
dev: BigInt(identity.dev),
|
|
1598
|
+
ino: BigInt(identity.ino),
|
|
1599
|
+
size: BigInt(identity.size),
|
|
1600
|
+
mtimeNs: BigInt(identity.mtimeNs),
|
|
1601
|
+
directory: true,
|
|
1602
|
+
});
|
|
1603
|
+
if (!result.ok) throw new Error("durability_failed");
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
function restoreDetachedArtifactRoot(detached: DetachedArtifactRoot): void {
|
|
1607
|
+
const result = native.exactRestore(detached.detachedPath, detached.originalPath, {
|
|
1608
|
+
...detached.identity,
|
|
1609
|
+
directory: true,
|
|
1610
|
+
});
|
|
1611
|
+
if (!result.ok) throw new Error("durability_failed");
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
async function copyArtifacts(
|
|
1615
|
+
sourceTranscript: string,
|
|
1616
|
+
destinationTranscript: string,
|
|
1617
|
+
manifest: readonly ArtifactManifestEntry[],
|
|
1618
|
+
lock: ManagedStorageLock,
|
|
1619
|
+
sourceRootOverride?: string,
|
|
1620
|
+
): Promise<void> {
|
|
1621
|
+
if (manifest.length === 0) return;
|
|
1622
|
+
const sourceRoot = sourceRootOverride ?? sourceTranscript.slice(0, -6);
|
|
1623
|
+
if (!manifestMatches(sourceTranscript, manifest, sourceRoot)) throw new Error("source_changed");
|
|
1624
|
+
const destinationRoot = destinationTranscript.slice(0, -6);
|
|
1625
|
+
for (const entry of manifest) {
|
|
1626
|
+
lock.assertOwned();
|
|
1627
|
+
const source = path.join(sourceRoot, entry.path);
|
|
1628
|
+
const destination = path.join(destinationRoot, entry.path);
|
|
1629
|
+
if (entry.kind === "directory") {
|
|
1630
|
+
if (entry.path === "") ensureManagedDirectory(destinationRoot);
|
|
1631
|
+
else ensureManagedDirectory(destination);
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
const snapshot = captureManagedFileNoFollow(source);
|
|
1635
|
+
if (
|
|
1636
|
+
snapshot.bytes.byteLength !== entry.size ||
|
|
1637
|
+
createHash("sha256").update(snapshot.bytes).digest("hex") !== entry.sha256
|
|
1638
|
+
)
|
|
1639
|
+
throw new Error("source_changed");
|
|
1640
|
+
try {
|
|
1641
|
+
await copyManagedFileNoReplace(source, destination, snapshot);
|
|
1642
|
+
} catch (error) {
|
|
1643
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
1644
|
+
}
|
|
1645
|
+
lock.assertOwned();
|
|
1646
|
+
const copied = captureManagedFileNoFollow(destination);
|
|
1647
|
+
if (
|
|
1648
|
+
copied.bytes.byteLength !== entry.size ||
|
|
1649
|
+
createHash("sha256").update(copied.bytes).digest("hex") !== entry.sha256
|
|
1650
|
+
)
|
|
1651
|
+
throw new Error("durability_failed");
|
|
1652
|
+
}
|
|
1653
|
+
if (!manifestMatches(sourceTranscript, manifest, sourceRoot) || !manifestMatches(destinationTranscript, manifest))
|
|
1654
|
+
throw new Error("durability_failed");
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
function migrationReceipt(
|
|
1658
|
+
scope: ManagedScope,
|
|
1659
|
+
lock: ManagedStorageLock,
|
|
1660
|
+
state: "prepared" | "published" | "committed",
|
|
1661
|
+
source: ManagedCandidate,
|
|
1662
|
+
destination: ManagedCandidate | { path: string; sessionId: string; cwd: string },
|
|
1663
|
+
manifest: readonly ArtifactManifestEntry[],
|
|
1664
|
+
sourceArtifactQuarantine?: {
|
|
1665
|
+
path: string;
|
|
1666
|
+
detachedPath: string;
|
|
1667
|
+
identity: DetachedArtifactRoot["identity"];
|
|
1668
|
+
tree: NativeDirectoryTreeSnapshot;
|
|
1669
|
+
},
|
|
1670
|
+
): Uint8Array {
|
|
1671
|
+
lock.assertOwned();
|
|
1672
|
+
const destinationRecord =
|
|
1673
|
+
"identity" in destination
|
|
1674
|
+
? {
|
|
1675
|
+
path: destination.path,
|
|
1676
|
+
sessionId: destination.sessionId,
|
|
1677
|
+
header: { id: destination.sessionId, cwd: destination.cwd },
|
|
1678
|
+
identity: destination.identity,
|
|
1679
|
+
sha256: destination.identity.sha256,
|
|
1680
|
+
}
|
|
1681
|
+
: {
|
|
1682
|
+
path: destination.path,
|
|
1683
|
+
sessionId: destination.sessionId,
|
|
1684
|
+
header: { id: destination.sessionId, cwd: destination.cwd },
|
|
1685
|
+
};
|
|
1686
|
+
return new TextEncoder().encode(
|
|
1687
|
+
`${JSON.stringify({ schemaVersion: 2, state, policy: "copy-retain", attemptId: lock.attemptId, scope: scopeDigest(scope.platform, scope.canonicalCwd), source: { path: source.path, sessionId: source.sessionId, header: { id: source.sessionId, cwd: source.cwd }, identity: source.identity, sha256: source.identity.sha256 }, destination: destinationRecord, artifactManifest: manifest, ...(sourceArtifactQuarantine ? { sourceArtifactQuarantine } : {}) }, (_key, value: unknown) => (typeof value === "bigint" ? value.toString() : value))}\n`,
|
|
1688
|
+
);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
async function removeStagedReceipts(scope: ManagedScope, candidate: ManagedCandidate): Promise<void> {
|
|
1692
|
+
for (const state of ["prepared", "published"] as const) {
|
|
1693
|
+
const pathname = receiptPathFor(scope, candidate, state);
|
|
1694
|
+
try {
|
|
1695
|
+
const stat = fs.lstatSync(pathname);
|
|
1696
|
+
if (stat.isFile() || stat.isSymbolicLink()) await fs.promises.unlink(pathname);
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
function receiptPair(scope: ManagedScope, candidate: ManagedCandidate): ManagedCandidate | undefined {
|
|
1704
|
+
const directory = path.join(managedInternalDirectory(scope), MANAGED_RECEIPTS_DIRECTORY);
|
|
1705
|
+
try {
|
|
1706
|
+
for (const name of fs.readdirSync(directory)) {
|
|
1707
|
+
const pathname = path.join(directory, name);
|
|
1708
|
+
const value: unknown = JSON.parse(captureManagedFileNoFollow(pathname).bytes.toString("utf8"));
|
|
1709
|
+
if (!value || typeof value !== "object") continue;
|
|
1710
|
+
const record = value as { source?: { path?: unknown }; destination?: { path?: unknown } };
|
|
1711
|
+
const otherPath =
|
|
1712
|
+
record.source?.path === candidate.path
|
|
1713
|
+
? record.destination?.path
|
|
1714
|
+
: record.destination?.path === candidate.path
|
|
1715
|
+
? record.source?.path
|
|
1716
|
+
: undefined;
|
|
1717
|
+
if (typeof otherPath !== "string") continue;
|
|
1718
|
+
const other = inspectCandidate(otherPath, candidate.provenance === "v2" ? "legacy" : "v2");
|
|
1719
|
+
if (
|
|
1720
|
+
"code" in other ||
|
|
1721
|
+
!receiptMatches(
|
|
1722
|
+
pathname,
|
|
1723
|
+
candidate.provenance === "legacy" ? candidate : other,
|
|
1724
|
+
candidate.provenance === "v2" ? candidate : other,
|
|
1725
|
+
scope,
|
|
1726
|
+
)
|
|
1727
|
+
)
|
|
1728
|
+
continue;
|
|
1729
|
+
return other;
|
|
1730
|
+
}
|
|
1731
|
+
} catch {
|
|
1732
|
+
/* no committed pair grants no shadow authority */
|
|
1733
|
+
}
|
|
1734
|
+
return undefined;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
function validateCandidateForScope(scope: ManagedScope, candidate: ManagedCandidate): ManagedCandidate | undefined {
|
|
1738
|
+
const inspected = inspectCandidate(candidate.path, candidate.provenance);
|
|
1739
|
+
if ("code" in inspected || !sameCandidate(inspected, candidate)) return undefined;
|
|
1740
|
+
const identity = identityFor(inspected.cwd);
|
|
1741
|
+
if (!identity.ok || identity.platform !== scope.platform || identity.canonicalPath !== scope.canonicalCwd)
|
|
1742
|
+
return undefined;
|
|
1743
|
+
return inspected;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/** Resume tombstoned cleanup under its original operation lease without restoring retired candidates. */
|
|
1747
|
+
export async function reconcileManagedTombstones(scope: ManagedScope): Promise<void> {
|
|
1748
|
+
const directory = path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY);
|
|
1749
|
+
for (const name of fs.readdirSync(directory)) {
|
|
1750
|
+
const tombstone = path.join(directory, name);
|
|
1751
|
+
const targets = retiredTargets(scope, tombstone);
|
|
1752
|
+
if (!targets) continue;
|
|
1753
|
+
let lock: ManagedStorageLock | undefined;
|
|
1754
|
+
try {
|
|
1755
|
+
lock = await acquireManagedLock(
|
|
1756
|
+
path.join(managedInternalDirectory(scope), MANAGED_LOCKS_DIRECTORY),
|
|
1757
|
+
path.basename(tombstone, ".json"),
|
|
1758
|
+
);
|
|
1759
|
+
const lockedTargets = retiredTargets(scope, tombstone);
|
|
1760
|
+
if (!lockedTargets) continue;
|
|
1761
|
+
for (const target of lockedTargets) {
|
|
1762
|
+
lock.assertOwned();
|
|
1763
|
+
if (cleanupCompleted(scope, tombstone, target)) continue;
|
|
1764
|
+
const pending = pendingCleanupReceipt(scope, tombstone, target);
|
|
1765
|
+
const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
|
|
1766
|
+
try {
|
|
1767
|
+
fs.lstatSync(target.path);
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
1770
|
+
if (!observedPending) continue;
|
|
1771
|
+
if (
|
|
1772
|
+
cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
|
|
1773
|
+
artifactRootsAbsent(scope, tombstone, target, observedPending) &&
|
|
1774
|
+
!fs.existsSync(observedPending.plannedTranscriptPath)
|
|
1775
|
+
) {
|
|
1776
|
+
fsyncManagedParent(target.path);
|
|
1777
|
+
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
1778
|
+
continue;
|
|
1779
|
+
}
|
|
1780
|
+
} else throw error;
|
|
1781
|
+
}
|
|
1782
|
+
const verified = observedPending ? target : validateCandidateForScope(scope, target);
|
|
1783
|
+
if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
|
|
1784
|
+
const discoveredDetach =
|
|
1785
|
+
!!observedPending &&
|
|
1786
|
+
(observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
|
|
1787
|
+
observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
|
|
1788
|
+
const active =
|
|
1789
|
+
discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
|
|
1790
|
+
? nextCleanupReceipt(target, observedPending)
|
|
1791
|
+
: (observedPending ?? nextCleanupReceipt(target, undefined));
|
|
1792
|
+
if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
|
|
1793
|
+
await publishCleanupPending(scope, tombstone, active, lock);
|
|
1794
|
+
let deletion = await new FileSessionStorage().deleteSessionVerified({
|
|
1795
|
+
sessionsRoot: scope.sessionsRoot,
|
|
1796
|
+
transcriptPath: target.path,
|
|
1797
|
+
sessionId: target.sessionId,
|
|
1798
|
+
cwd: target.cwd,
|
|
1799
|
+
transcriptIdentity: target.identity,
|
|
1800
|
+
expectedArtifactsIdentity: active.expectedArtifactsIdentity,
|
|
1801
|
+
expectedArtifactsTree: active.expectedArtifactsTree,
|
|
1802
|
+
detachedArtifactsPath:
|
|
1803
|
+
active.detachedArtifactsPath ??
|
|
1804
|
+
observedPending?.detachedArtifactsPath ??
|
|
1805
|
+
(fs.existsSync(active.plannedArtifactsPath) ? active.plannedArtifactsPath : undefined),
|
|
1806
|
+
detachedTranscriptPath:
|
|
1807
|
+
active.detachedTranscriptPath ??
|
|
1808
|
+
observedPending?.detachedTranscriptPath ??
|
|
1809
|
+
(pending && fs.existsSync(pending.plannedTranscriptPath)
|
|
1810
|
+
? pending.plannedTranscriptPath
|
|
1811
|
+
: undefined) ??
|
|
1812
|
+
(fs.existsSync(active.plannedTranscriptPath) ? active.plannedTranscriptPath : undefined),
|
|
1813
|
+
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
1814
|
+
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
1815
|
+
...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
|
|
1816
|
+
? { artifactsRemoved: true as const }
|
|
1817
|
+
: {}),
|
|
1818
|
+
});
|
|
1819
|
+
if (deletion.kind === "artifacts_removed") {
|
|
1820
|
+
await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
|
|
1821
|
+
deletion = await new FileSessionStorage().deleteSessionVerified({
|
|
1822
|
+
sessionsRoot: scope.sessionsRoot,
|
|
1823
|
+
transcriptPath: target.path,
|
|
1824
|
+
sessionId: target.sessionId,
|
|
1825
|
+
cwd: target.cwd,
|
|
1826
|
+
transcriptIdentity: target.identity,
|
|
1827
|
+
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
1828
|
+
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
1829
|
+
artifactsRemoved: true,
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
if (deletion.kind === "cleanup_pending") {
|
|
1833
|
+
const retry = nextCleanupReceipt(target, active);
|
|
1834
|
+
await publishCleanupPending(
|
|
1835
|
+
scope,
|
|
1836
|
+
tombstone,
|
|
1837
|
+
{
|
|
1838
|
+
...retry,
|
|
1839
|
+
expectedArtifactsIdentity:
|
|
1840
|
+
deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
|
|
1841
|
+
expectedArtifactsTree:
|
|
1842
|
+
deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
|
|
1843
|
+
detachedArtifactsPath:
|
|
1844
|
+
deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
|
|
1845
|
+
detachedTranscriptPath:
|
|
1846
|
+
deletion.phase === "transcript"
|
|
1847
|
+
? deletion.detachedTranscriptPath
|
|
1848
|
+
: active.detachedTranscriptPath,
|
|
1849
|
+
},
|
|
1850
|
+
lock,
|
|
1851
|
+
);
|
|
1852
|
+
throw new Error("durability_failed");
|
|
1853
|
+
}
|
|
1854
|
+
fsyncManagedParent(target.path);
|
|
1855
|
+
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
1856
|
+
}
|
|
1857
|
+
} finally {
|
|
1858
|
+
if (lock) await lock.release().catch(() => undefined);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/** Create the v2 binding and private write protocol directories before managed writes. */
|
|
1864
|
+
export async function prepareManagedSessionScopeForWrite(scope: ManagedScope): Promise<ManagedScopeResolution> {
|
|
1865
|
+
const prepared = await ensureManagedScope(scope);
|
|
1866
|
+
if (prepared.kind === "error") return prepared;
|
|
1867
|
+
try {
|
|
1868
|
+
const internal = managedInternalDirectory(scope);
|
|
1869
|
+
ensureManagedDirectory(internal);
|
|
1870
|
+
ensureManagedDirectory(path.join(internal, MANAGED_LOCKS_DIRECTORY));
|
|
1871
|
+
ensureManagedDirectory(path.join(internal, MANAGED_RECEIPTS_DIRECTORY));
|
|
1872
|
+
ensureManagedDirectory(path.join(internal, MANAGED_TOMBSTONES_DIRECTORY));
|
|
1873
|
+
await reconcileManagedTombstones(scope);
|
|
1874
|
+
return { kind: "resolved", scope };
|
|
1875
|
+
} catch (error) {
|
|
1876
|
+
if (error instanceof Error && error.message === "durability_failed") return { kind: "resolved", scope };
|
|
1877
|
+
return {
|
|
1878
|
+
kind: "error",
|
|
1879
|
+
code: "binding_invalid",
|
|
1880
|
+
message: error instanceof Error ? error.message : "Managed write protocol setup failed.",
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Open a validated candidate for mutation. Legacy transcripts are copied exactly once
|
|
1887
|
+
* into v2 and retained at their original location; no transcript data is merged.
|
|
1888
|
+
*/
|
|
1889
|
+
export async function openManagedCandidateForWrite(
|
|
1890
|
+
scope: ManagedScope,
|
|
1891
|
+
candidate: ManagedCandidate,
|
|
1892
|
+
migrationPolicy: ManagedMigrationPolicy = "copy-retain",
|
|
1893
|
+
): Promise<ManagedOpenCandidateResult> {
|
|
1894
|
+
if (migrationPolicy === "disabled" && candidate.provenance === "legacy")
|
|
1895
|
+
return {
|
|
1896
|
+
kind: "error",
|
|
1897
|
+
code: "legacy_migration_disabled",
|
|
1898
|
+
message: "Legacy session migration is disabled for this workspace.",
|
|
1899
|
+
};
|
|
1900
|
+
const prepared = await prepareManagedSessionScopeForWrite(scope);
|
|
1901
|
+
if (prepared.kind === "error")
|
|
1902
|
+
return {
|
|
1903
|
+
kind: "error",
|
|
1904
|
+
code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
|
|
1905
|
+
message: prepared.message,
|
|
1906
|
+
};
|
|
1907
|
+
const current = validateCandidateForScope(scope, candidate);
|
|
1908
|
+
if (!current)
|
|
1909
|
+
return {
|
|
1910
|
+
kind: "error",
|
|
1911
|
+
code: "source_changed",
|
|
1912
|
+
message: "The managed candidate changed before it could be opened.",
|
|
1913
|
+
};
|
|
1914
|
+
if (isRetired(scope, current))
|
|
1915
|
+
return { kind: "error", code: "migration_retired", message: "The managed session has been retired." };
|
|
1916
|
+
if (current.provenance === "v2") return { kind: "opened", path: current.path, candidate: current, migrated: false };
|
|
1917
|
+
|
|
1918
|
+
const operation = stableOperationName(current);
|
|
1919
|
+
const internal = managedInternalDirectory(scope);
|
|
1920
|
+
let lock: ManagedStorageLock | undefined;
|
|
1921
|
+
let detachedArtifacts: DetachedArtifactRoot | undefined;
|
|
1922
|
+
try {
|
|
1923
|
+
lock = await acquireManagedLock(path.join(internal, MANAGED_LOCKS_DIRECTORY), operation);
|
|
1924
|
+
const afterLock = validateCandidateForScope(scope, current);
|
|
1925
|
+
if (!afterLock)
|
|
1926
|
+
return { kind: "error", code: "source_changed", message: "The legacy candidate changed during migration." };
|
|
1927
|
+
const listing = listManagedCandidates(scope);
|
|
1928
|
+
if (listing.kind === "error") return { kind: "error", code: "binding_invalid", message: listing.message };
|
|
1929
|
+
const destination = path.join(scope.directoryPath, path.basename(afterLock.path));
|
|
1930
|
+
const sameId = listing.owned.filter(item => item.provenance === "v2" && item.sessionId === afterLock.sessionId);
|
|
1931
|
+
const existing = sameId.find(item => path.resolve(item.path) === path.resolve(destination));
|
|
1932
|
+
if (sameId.some(item => item !== existing))
|
|
1933
|
+
return {
|
|
1934
|
+
kind: "error",
|
|
1935
|
+
code: "destination_conflict",
|
|
1936
|
+
message: "A distinct v2 transcript already owns this session id.",
|
|
1937
|
+
};
|
|
1938
|
+
restorePreparedArtifactRoot(scope, afterLock);
|
|
1939
|
+
const sourceSnapshot = captureManagedFileNoFollow(afterLock.path);
|
|
1940
|
+
let manifest: readonly ArtifactManifestEntry[] = [];
|
|
1941
|
+
const artifactPlan = planArtifactRootForMigration(afterLock.path, operation);
|
|
1942
|
+
const intendedDestination = { path: destination, sessionId: afterLock.sessionId, cwd: afterLock.cwd };
|
|
1943
|
+
if (existing && existing.identity.sha256 !== afterLock.identity.sha256) {
|
|
1944
|
+
return {
|
|
1945
|
+
kind: "error",
|
|
1946
|
+
code: "destination_conflict",
|
|
1947
|
+
message: "A different v2 transcript already occupies the migration destination.",
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
if (!existing && fs.existsSync(destination)) {
|
|
1951
|
+
return {
|
|
1952
|
+
kind: "error",
|
|
1953
|
+
code: "destination_conflict",
|
|
1954
|
+
message: "The migration destination already exists without validated ownership.",
|
|
1955
|
+
};
|
|
1956
|
+
}
|
|
1957
|
+
const preparedReceipt = receiptPathFor(scope, afterLock, "prepared");
|
|
1958
|
+
try {
|
|
1959
|
+
lock.assertOwned();
|
|
1960
|
+
await publishManagedFileNoReplace(
|
|
1961
|
+
preparedReceipt,
|
|
1962
|
+
migrationReceipt(
|
|
1963
|
+
scope,
|
|
1964
|
+
lock,
|
|
1965
|
+
"prepared",
|
|
1966
|
+
afterLock,
|
|
1967
|
+
intendedDestination,
|
|
1968
|
+
manifest,
|
|
1969
|
+
artifactPlan
|
|
1970
|
+
? {
|
|
1971
|
+
path: artifactPlan.originalPath,
|
|
1972
|
+
detachedPath: artifactPlan.detachedPath,
|
|
1973
|
+
identity: artifactPlan.identity,
|
|
1974
|
+
tree: artifactPlan.tree,
|
|
1975
|
+
}
|
|
1976
|
+
: undefined,
|
|
1977
|
+
),
|
|
1978
|
+
lock.assertOwned,
|
|
1979
|
+
);
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
1982
|
+
}
|
|
1983
|
+
lock.assertOwned();
|
|
1984
|
+
if (!preparedReceiptMatches(preparedReceipt, scope, afterLock, intendedDestination, artifactPlan))
|
|
1985
|
+
throw new Error("durability_failed");
|
|
1986
|
+
if (artifactPlan && fs.existsSync(artifactPlan.detachedPath)) throw new Error("destination_conflict");
|
|
1987
|
+
detachedArtifacts = artifactPlan ? detachArtifactRootForMigration(artifactPlan) : undefined;
|
|
1988
|
+
manifest = detachedArtifacts ? artifactManifestFromSnapshot(detachedArtifacts.tree) : [];
|
|
1989
|
+
await copyArtifacts(afterLock.path, destination, manifest, lock, detachedArtifacts?.detachedPath);
|
|
1990
|
+
if (!existing) {
|
|
1991
|
+
try {
|
|
1992
|
+
lock.assertOwned();
|
|
1993
|
+
await copyManagedFileNoReplace(afterLock.path, destination, sourceSnapshot);
|
|
1994
|
+
} catch (error) {
|
|
1995
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
// Artifact files, directories, and the transcript must be durable before a receipt can grant shadow authority.
|
|
1999
|
+
if (manifest.length > 0) fsyncManagedArtifactTree(destination.slice(0, -6));
|
|
2000
|
+
lock.assertOwned();
|
|
2001
|
+
const migrated = inspectCandidate(destination, "v2");
|
|
2002
|
+
if (
|
|
2003
|
+
"code" in migrated ||
|
|
2004
|
+
migrated.sessionId !== afterLock.sessionId ||
|
|
2005
|
+
migrated.cwd !== afterLock.cwd ||
|
|
2006
|
+
migrated.identity.sha256 !== afterLock.identity.sha256 ||
|
|
2007
|
+
!manifestMatches(destination, manifest)
|
|
2008
|
+
)
|
|
2009
|
+
throw new Error("durability_failed");
|
|
2010
|
+
if (detachedArtifacts) {
|
|
2011
|
+
restoreDetachedArtifactRoot(detachedArtifacts);
|
|
2012
|
+
detachedArtifacts = undefined;
|
|
2013
|
+
}
|
|
2014
|
+
const latest = validateCandidateForScope(scope, afterLock);
|
|
2015
|
+
if (!latest || !sameCandidate(latest, afterLock) || !manifestMatches(afterLock.path, manifest))
|
|
2016
|
+
return {
|
|
2017
|
+
kind: "error",
|
|
2018
|
+
code: "source_changed",
|
|
2019
|
+
message: "The legacy candidate or its artifacts changed before migration commit.",
|
|
2020
|
+
};
|
|
2021
|
+
for (const state of ["published", "committed"] as const) {
|
|
2022
|
+
const receipt = receiptPathFor(scope, afterLock, state);
|
|
2023
|
+
try {
|
|
2024
|
+
lock.assertOwned();
|
|
2025
|
+
await publishManagedFileNoReplace(
|
|
2026
|
+
receipt,
|
|
2027
|
+
migrationReceipt(scope, lock, state, afterLock, migrated, manifest),
|
|
2028
|
+
lock.assertOwned,
|
|
2029
|
+
);
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
const receipt = receiptPathFor(scope, afterLock);
|
|
2035
|
+
lock.assertOwned();
|
|
2036
|
+
if (!receiptMatches(receipt, afterLock, migrated, scope))
|
|
2037
|
+
return {
|
|
2038
|
+
kind: "error",
|
|
2039
|
+
code: "durability_failed",
|
|
2040
|
+
message: "The migration receipt does not bind the copied v2 transcript and artifacts.",
|
|
2041
|
+
};
|
|
2042
|
+
await removeStagedReceipts(scope, afterLock);
|
|
2043
|
+
return {
|
|
2044
|
+
kind: "opened",
|
|
2045
|
+
path: migrated.path,
|
|
2046
|
+
candidate: { ...migrated, migrationState: "migrated_v2" },
|
|
2047
|
+
migrated: true,
|
|
2048
|
+
};
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
try {
|
|
2051
|
+
if (detachedArtifacts) restoreDetachedArtifactRoot(detachedArtifacts);
|
|
2052
|
+
} catch {
|
|
2053
|
+
return {
|
|
2054
|
+
kind: "error",
|
|
2055
|
+
code: "durability_failed",
|
|
2056
|
+
message: "The detached legacy artifacts could not be restored without replacing a collision.",
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
const code = expectedFailure(error);
|
|
2060
|
+
return { kind: "error", code, message: error instanceof Error ? error.message : "Managed migration failed." };
|
|
2061
|
+
} finally {
|
|
2062
|
+
if (lock) await lock.release().catch(() => undefined);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
/** Tombstone a verified managed candidate before exact-identity deletion. */
|
|
2067
|
+
export async function deleteManagedSessionCandidate(
|
|
2068
|
+
scope: ManagedScope,
|
|
2069
|
+
candidate: ManagedCandidate,
|
|
2070
|
+
): Promise<ManagedDeleteCandidateResult> {
|
|
2071
|
+
const prepared = await prepareManagedSessionScopeForWrite(scope);
|
|
2072
|
+
if (prepared.kind === "error")
|
|
2073
|
+
return {
|
|
2074
|
+
kind: "error",
|
|
2075
|
+
code: prepared.code === "binding_conflict" ? "binding_conflict" : "binding_invalid",
|
|
2076
|
+
message: prepared.message,
|
|
2077
|
+
};
|
|
2078
|
+
const current = validateCandidateForScope(scope, candidate);
|
|
2079
|
+
const paired = current ? receiptPair(scope, current) : undefined;
|
|
2080
|
+
const logical = paired?.provenance === "legacy" ? paired : (current ?? candidate);
|
|
2081
|
+
const existingTombstone = tombstonePathContaining(scope, candidate);
|
|
2082
|
+
const tombstone =
|
|
2083
|
+
existingTombstone ??
|
|
2084
|
+
path.join(managedInternalDirectory(scope), MANAGED_TOMBSTONES_DIRECTORY, `${stableOperationName(logical)}.json`);
|
|
2085
|
+
if (!current && !existingTombstone)
|
|
2086
|
+
return { kind: "error", code: "source_changed", message: "The managed candidate changed before deletion." };
|
|
2087
|
+
const operation = path.basename(tombstone, ".json");
|
|
2088
|
+
let lock: ManagedStorageLock | undefined;
|
|
2089
|
+
try {
|
|
2090
|
+
lock = await acquireManagedLock(path.join(managedInternalDirectory(scope), MANAGED_LOCKS_DIRECTORY), operation);
|
|
2091
|
+
let targets = retiredTargets(scope, tombstone);
|
|
2092
|
+
if (!targets) {
|
|
2093
|
+
if (!current) throw new Error("source_changed");
|
|
2094
|
+
targets = [current, ...(paired ? [paired] : [])];
|
|
2095
|
+
lock.assertOwned();
|
|
2096
|
+
try {
|
|
2097
|
+
await publishManagedTombstone(
|
|
2098
|
+
tombstone,
|
|
2099
|
+
{
|
|
2100
|
+
schemaVersion: 2,
|
|
2101
|
+
state: "retired",
|
|
2102
|
+
scope: scopeDigest(scope.platform, scope.canonicalCwd),
|
|
2103
|
+
targets: targets.map(target => ({
|
|
2104
|
+
path: target.path,
|
|
2105
|
+
sessionId: target.sessionId,
|
|
2106
|
+
cwd: target.cwd,
|
|
2107
|
+
provenance: target.provenance,
|
|
2108
|
+
identity: target.identity,
|
|
2109
|
+
})),
|
|
2110
|
+
},
|
|
2111
|
+
lock.assertOwned,
|
|
2112
|
+
);
|
|
2113
|
+
} catch (error) {
|
|
2114
|
+
if ((error as Error).message !== "destination_conflict") throw error;
|
|
2115
|
+
}
|
|
2116
|
+
targets = retiredTargets(scope, tombstone);
|
|
2117
|
+
if (!targets) throw new Error("durability_failed");
|
|
2118
|
+
}
|
|
2119
|
+
let deletedAny = false;
|
|
2120
|
+
for (const target of targets) {
|
|
2121
|
+
lock.assertOwned();
|
|
2122
|
+
const pending = pendingCleanupReceipt(scope, tombstone, target);
|
|
2123
|
+
const observedPending = pending ? probePlannedCleanupDetach(target, pending) : undefined;
|
|
2124
|
+
try {
|
|
2125
|
+
fs.lstatSync(target.path);
|
|
2126
|
+
} catch (error) {
|
|
2127
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
2128
|
+
if (!observedPending) continue;
|
|
2129
|
+
if (
|
|
2130
|
+
cleanupArtifactsRemoved(scope, tombstone, target, observedPending.attempt) &&
|
|
2131
|
+
artifactRootsAbsent(scope, tombstone, target, observedPending) &&
|
|
2132
|
+
!fs.existsSync(observedPending.plannedTranscriptPath)
|
|
2133
|
+
) {
|
|
2134
|
+
fsyncManagedParent(target.path);
|
|
2135
|
+
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
} else throw error;
|
|
2139
|
+
}
|
|
2140
|
+
if (cleanupCompleted(scope, tombstone, target)) continue;
|
|
2141
|
+
deletedAny = true;
|
|
2142
|
+
const verified = observedPending ? target : validateCandidateForScope(scope, target);
|
|
2143
|
+
if (!verified || !sameCandidate(verified, target)) throw new Error("source_changed");
|
|
2144
|
+
const discoveredDetach =
|
|
2145
|
+
!!observedPending &&
|
|
2146
|
+
(observedPending.detachedArtifactsPath !== pending?.detachedArtifactsPath ||
|
|
2147
|
+
observedPending.detachedTranscriptPath !== pending?.detachedTranscriptPath);
|
|
2148
|
+
const active =
|
|
2149
|
+
discoveredDetach || (observedPending && requiresFreshCleanupPlan(observedPending))
|
|
2150
|
+
? nextCleanupReceipt(target, observedPending)
|
|
2151
|
+
: (observedPending ?? nextCleanupReceipt(target, undefined));
|
|
2152
|
+
if (!observedPending || discoveredDetach || requiresFreshCleanupPlan(observedPending))
|
|
2153
|
+
await publishCleanupPending(scope, tombstone, active, lock);
|
|
2154
|
+
let deletion = await new FileSessionStorage().deleteSessionVerified({
|
|
2155
|
+
sessionsRoot: scope.sessionsRoot,
|
|
2156
|
+
transcriptPath: target.path,
|
|
2157
|
+
sessionId: target.sessionId,
|
|
2158
|
+
cwd: target.cwd,
|
|
2159
|
+
transcriptIdentity: target.identity,
|
|
2160
|
+
expectedArtifactsIdentity: active.expectedArtifactsIdentity,
|
|
2161
|
+
expectedArtifactsTree: active.expectedArtifactsTree,
|
|
2162
|
+
detachedArtifactsPath: active.detachedArtifactsPath ?? observedPending?.detachedArtifactsPath,
|
|
2163
|
+
detachedTranscriptPath:
|
|
2164
|
+
active.detachedTranscriptPath ??
|
|
2165
|
+
observedPending?.detachedTranscriptPath ??
|
|
2166
|
+
(pending && fs.existsSync(pending.plannedTranscriptPath) ? pending.plannedTranscriptPath : undefined),
|
|
2167
|
+
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
2168
|
+
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
2169
|
+
...(cleanupArtifactsRemoved(scope, tombstone, target, pending?.attempt ?? active.attempt)
|
|
2170
|
+
? { artifactsRemoved: true as const }
|
|
2171
|
+
: {}),
|
|
2172
|
+
});
|
|
2173
|
+
if (deletion.kind === "artifacts_removed") {
|
|
2174
|
+
await publishCleanupArtifactsRemoved(scope, tombstone, active, lock);
|
|
2175
|
+
deletion = await new FileSessionStorage().deleteSessionVerified({
|
|
2176
|
+
sessionsRoot: scope.sessionsRoot,
|
|
2177
|
+
transcriptPath: target.path,
|
|
2178
|
+
sessionId: target.sessionId,
|
|
2179
|
+
cwd: target.cwd,
|
|
2180
|
+
transcriptIdentity: target.identity,
|
|
2181
|
+
plannedArtifactsPath: active.plannedArtifactsPath,
|
|
2182
|
+
plannedTranscriptPath: active.plannedTranscriptPath,
|
|
2183
|
+
artifactsRemoved: true,
|
|
2184
|
+
});
|
|
2185
|
+
}
|
|
2186
|
+
if (deletion.kind === "cleanup_pending") {
|
|
2187
|
+
if (
|
|
2188
|
+
(deletion.phase === "artifacts" &&
|
|
2189
|
+
!isAuthorizedArtifactRoot(
|
|
2190
|
+
target,
|
|
2191
|
+
active.detachedArtifactsPath ?? active.plannedArtifactsPath,
|
|
2192
|
+
deletion.detachedArtifactsPath,
|
|
2193
|
+
)) ||
|
|
2194
|
+
(deletion.phase === "transcript" && deletion.detachedTranscriptPath !== active.plannedTranscriptPath)
|
|
2195
|
+
)
|
|
2196
|
+
throw new Error("durability_failed");
|
|
2197
|
+
const retry = nextCleanupReceipt(target, active);
|
|
2198
|
+
await publishCleanupPending(
|
|
2199
|
+
scope,
|
|
2200
|
+
tombstone,
|
|
2201
|
+
{
|
|
2202
|
+
...retry,
|
|
2203
|
+
expectedArtifactsIdentity:
|
|
2204
|
+
deletion.phase === "artifacts" ? deletion.artifactsIdentity : active.expectedArtifactsIdentity,
|
|
2205
|
+
expectedArtifactsTree:
|
|
2206
|
+
deletion.phase === "artifacts" ? deletion.artifactsTree : active.expectedArtifactsTree,
|
|
2207
|
+
detachedArtifactsPath:
|
|
2208
|
+
deletion.phase === "artifacts" ? deletion.detachedArtifactsPath : active.detachedArtifactsPath,
|
|
2209
|
+
detachedTranscriptPath:
|
|
2210
|
+
deletion.phase === "transcript" ? deletion.detachedTranscriptPath : active.detachedTranscriptPath,
|
|
2211
|
+
},
|
|
2212
|
+
lock,
|
|
2213
|
+
);
|
|
2214
|
+
throw new Error("durability_failed");
|
|
2215
|
+
}
|
|
2216
|
+
fsyncManagedParent(target.path);
|
|
2217
|
+
await publishCleanupCompleted(scope, tombstone, target, lock);
|
|
2218
|
+
}
|
|
2219
|
+
return { kind: deletedAny ? "deleted" : "already_deleted", tombstonePath: tombstone };
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
const code = expectedFailure(error);
|
|
2222
|
+
return { kind: "error", code, message: error instanceof Error ? error.message : "Managed deletion failed." };
|
|
2223
|
+
} finally {
|
|
2224
|
+
if (lock) await lock.release().catch(() => undefined);
|
|
2225
|
+
}
|
|
2226
|
+
}
|