@gajae-code/coding-agent 0.11.1 → 0.11.3
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 +64 -0
- package/dist/types/cli/config-cli.d.ts +11 -1
- package/dist/types/cli/launch-disposition.d.ts +23 -0
- 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 +141 -11
- 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-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 +4 -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/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 +19 -17
- package/dist/types/modes/jobs-observer.d.ts +2 -0
- package/dist/types/modes/prompt-action-autocomplete.d.ts +4 -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 +20 -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 +20 -13
- 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/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/html-format.d.ts +3 -2
- package/dist/types/sdk/bus/index.d.ts +29 -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 +46 -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/sdk/session.d.ts +2 -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 +5 -0
- package/dist/types/tools/search-tool-bm25.d.ts +3 -4
- package/dist/types/tools/subagent.d.ts +1 -0
- package/dist/types/utils/event-bus.d.ts +1 -1
- package/dist/types/utils/file-mentions.d.ts +3 -3
- package/dist/types/web/search/index.d.ts +3 -1
- package/package.json +21 -9
- package/scripts/build-sdk-package-smoke.ts +34 -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 +49 -6
- package/src/cli/config-cli.ts +75 -2
- package/src/cli/launch-disposition.ts +42 -0
- 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 +214 -12
- 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 +327 -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 +29 -5
- package/src/gjc-runtime/ultragoal-pipeline.ts +306 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +84 -1311
- 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 +127 -30
- 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/event-controller.ts +3 -2
- package/src/modes/controllers/extension-ui-controller.ts +24 -1
- 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 +289 -1150
- package/src/modes/jobs-observer.ts +3 -0
- package/src/modes/prompt-action-autocomplete.ts +7 -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 +71 -16
- 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 +45 -18
- 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 +1 -1
- package/src/sdk/broker/ensure.ts +3 -1
- 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 +10 -2
- 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/html-format.ts +20 -3
- package/src/sdk/bus/index.ts +782 -94
- package/src/sdk/bus/lifecycle-control-runtime.ts +91 -33
- 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 +1389 -237
- 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 +44 -2
- 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 +462 -480
- package/src/session/agent-session.ts +1700 -583
- 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 +36 -21
- 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 +19 -7
- package/src/tools/read.ts +219 -64
- package/src/tools/resource-gc.ts +2 -2
- package/src/tools/search-tool-bm25.ts +11 -74
- package/src/tools/subagent.ts +43 -2
- package/src/utils/event-bus.ts +9 -4
- package/src/utils/file-mentions.ts +4 -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
|
@@ -3,7 +3,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
3
3
|
import * as fsSync from "node:fs";
|
|
4
4
|
import * as fs from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import type { NativeExactUnlinkResult } from "@gajae-code/natives";
|
|
7
|
+
import * as native from "@gajae-code/natives";
|
|
8
|
+
import { resolveEquivalentPath } from "@gajae-code/utils";
|
|
7
9
|
|
|
8
10
|
import {
|
|
9
11
|
ensureLaunchWorktree,
|
|
@@ -11,18 +13,25 @@ import {
|
|
|
11
13
|
type GjcLaunchWorktreePlan,
|
|
12
14
|
planLaunchWorktree,
|
|
13
15
|
} from "../../gjc-runtime/launch-worktree";
|
|
14
|
-
|
|
15
|
-
import { SessionManager } from "../../session/session-manager";
|
|
16
|
+
import { validateManagedArtifactTree } from "../../session/internal/managed-session-storage";
|
|
16
17
|
import {
|
|
17
18
|
FileSessionStorage,
|
|
18
19
|
SessionDeleteVerificationError,
|
|
20
|
+
type SessionStorageFileIdentity,
|
|
21
|
+
type SessionStorageSnapshot,
|
|
22
|
+
type VerifiedSessionDeleteResult,
|
|
19
23
|
type VerifiedSessionDeleteTarget,
|
|
20
24
|
} from "../../session/session-storage";
|
|
21
25
|
import { SdkClient, SdkClientError } from "../client/client";
|
|
26
|
+
import {
|
|
27
|
+
type LogicalSessionCandidate,
|
|
28
|
+
listManagedSessionCandidates,
|
|
29
|
+
type ManagedSessionScope,
|
|
30
|
+
resolveManagedSessionScope,
|
|
31
|
+
} from "../session-directory";
|
|
22
32
|
import type { SdkStartupFailure, SdkStartupRollbackResult } from "../startup-capability";
|
|
23
|
-
|
|
24
|
-
import
|
|
25
|
-
|
|
33
|
+
import type { Broker, BrokerCleanupEvidence, BrokerCleanupIdentity, BrokerResponse } from "./broker";
|
|
34
|
+
import { decodeLifecycleUtf8, parseLifecycleJson } from "./lifecycle-codec";
|
|
26
35
|
import type {
|
|
27
36
|
LifecycleCleanupProof,
|
|
28
37
|
LifecycleDurableEffectsReceipt,
|
|
@@ -51,6 +60,9 @@ const MAX_READY_TIMEOUT_MS = 60_000;
|
|
|
51
60
|
const POLL_MS = 50;
|
|
52
61
|
const CLOSE_TIMEOUT_MS = 2_000;
|
|
53
62
|
const MAX_RECEIVED_AT_SKEW_MS = 5_000;
|
|
63
|
+
const MAX_LIFECYCLE_METADATA_BYTES = 4096;
|
|
64
|
+
const MAX_EFFECT_MARKER_LENGTH = 128;
|
|
65
|
+
const MAX_PROCESS_INCARNATION_LENGTH = 256;
|
|
54
66
|
|
|
55
67
|
export interface LifecycleDeadlines {
|
|
56
68
|
receivedAt: number;
|
|
@@ -101,6 +113,13 @@ const lifecycleTimingsForTest = new WeakMap<Broker, LifecycleTiming>();
|
|
|
101
113
|
type LifecycleCommand = SdkInternalSpawnCommand | { file: string; args: string[] };
|
|
102
114
|
type LifecycleCommandResolver = () => LifecycleCommand;
|
|
103
115
|
const lifecycleCommandResolversForTest = new WeakMap<Broker, LifecycleCommandResolver>();
|
|
116
|
+
const lifecycleCleanupHooksForTest = new WeakMap<Broker, () => void>();
|
|
117
|
+
|
|
118
|
+
/** Test-only hook for simulating a crash immediately after one exact lifecycle detach. */
|
|
119
|
+
export function setLifecycleCleanupHookForTest(broker: Broker, hook: (() => void) | undefined): void {
|
|
120
|
+
if (hook) lifecycleCleanupHooksForTest.set(broker, hook);
|
|
121
|
+
else lifecycleCleanupHooksForTest.delete(broker);
|
|
122
|
+
}
|
|
104
123
|
|
|
105
124
|
export function setLifecycleCommandResolverForTest(
|
|
106
125
|
broker: Broker,
|
|
@@ -172,6 +191,7 @@ export interface SessionLifecycleTranscriptIdentity {
|
|
|
172
191
|
size: number;
|
|
173
192
|
mtimeMs: number;
|
|
174
193
|
mtimeNs: string;
|
|
194
|
+
sha256: string;
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
export interface SessionLifecycleLaunchRequest {
|
|
@@ -211,7 +231,9 @@ function isSessionLifecycleTranscriptIdentity(value: unknown): value is SessionL
|
|
|
211
231
|
Number.isFinite(identity.mtimeMs) &&
|
|
212
232
|
identity.mtimeMs >= 0 &&
|
|
213
233
|
typeof identity.mtimeNs === "string" &&
|
|
214
|
-
/^\d+$/.test(identity.mtimeNs)
|
|
234
|
+
/^\d+$/.test(identity.mtimeNs) &&
|
|
235
|
+
typeof identity.sha256 === "string" &&
|
|
236
|
+
/^[a-f0-9]{64}$/.test(identity.sha256)
|
|
215
237
|
);
|
|
216
238
|
}
|
|
217
239
|
|
|
@@ -286,6 +308,23 @@ type SessionLaunch = {
|
|
|
286
308
|
};
|
|
287
309
|
|
|
288
310
|
type CleanupEvidence = BrokerCleanupEvidence;
|
|
311
|
+
type CleanupIdentity = {
|
|
312
|
+
dev: bigint;
|
|
313
|
+
ino: bigint;
|
|
314
|
+
size: number;
|
|
315
|
+
mtimeNs: bigint;
|
|
316
|
+
sha256: string;
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
function serializeCleanupIdentity(identity: CleanupIdentity): BrokerCleanupIdentity {
|
|
320
|
+
return {
|
|
321
|
+
dev: identity.dev.toString(),
|
|
322
|
+
ino: identity.ino.toString(),
|
|
323
|
+
size: identity.size,
|
|
324
|
+
mtimeNs: identity.mtimeNs.toString(),
|
|
325
|
+
sha256: identity.sha256,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
289
328
|
|
|
290
329
|
const fail = (code: string, message: string, cleanup?: CleanupEvidence): BrokerResponse => ({
|
|
291
330
|
ok: false,
|
|
@@ -384,6 +423,7 @@ type ResumeScope = {
|
|
|
384
423
|
size: number;
|
|
385
424
|
mtimeMs: number;
|
|
386
425
|
mtimeNs: bigint;
|
|
426
|
+
sha256: string;
|
|
387
427
|
};
|
|
388
428
|
};
|
|
389
429
|
function sameResumeLocator(record: LiveResumeRecord, cwd: string, root: string): boolean {
|
|
@@ -399,7 +439,8 @@ function sameResumeSessionIdentity(left: ResumeScope, right: ResumeScope): boole
|
|
|
399
439
|
left.sessionIdentity.ino === right.sessionIdentity.ino &&
|
|
400
440
|
left.sessionIdentity.size === right.sessionIdentity.size &&
|
|
401
441
|
left.sessionIdentity.mtimeMs === right.sessionIdentity.mtimeMs &&
|
|
402
|
-
left.sessionIdentity.mtimeNs === right.sessionIdentity.mtimeNs
|
|
442
|
+
left.sessionIdentity.mtimeNs === right.sessionIdentity.mtimeNs &&
|
|
443
|
+
left.sessionIdentity.sha256 === right.sessionIdentity.sha256
|
|
403
444
|
);
|
|
404
445
|
}
|
|
405
446
|
function sameLiveResumeRecord(expected: LiveResumeRecord, current: LiveResumeRecord): boolean {
|
|
@@ -424,6 +465,7 @@ function serializeTranscriptIdentity(identity: {
|
|
|
424
465
|
size: number;
|
|
425
466
|
mtimeMs: number;
|
|
426
467
|
mtimeNs: bigint;
|
|
468
|
+
sha256: string;
|
|
427
469
|
}): SessionLifecycleTranscriptIdentity {
|
|
428
470
|
return {
|
|
429
471
|
dev: identity.dev.toString(),
|
|
@@ -431,32 +473,56 @@ function serializeTranscriptIdentity(identity: {
|
|
|
431
473
|
size: identity.size,
|
|
432
474
|
mtimeMs: identity.mtimeMs,
|
|
433
475
|
mtimeNs: identity.mtimeNs.toString(),
|
|
476
|
+
sha256: identity.sha256,
|
|
434
477
|
};
|
|
435
478
|
}
|
|
479
|
+
async function managedCandidates(
|
|
480
|
+
broker: Broker,
|
|
481
|
+
cwd: string,
|
|
482
|
+
label: "Saved" | "Source",
|
|
483
|
+
): Promise<
|
|
484
|
+
| {
|
|
485
|
+
candidates: readonly LogicalSessionCandidate[];
|
|
486
|
+
migrationPolicy: "copy-retain" | "disabled";
|
|
487
|
+
scope: ManagedSessionScope;
|
|
488
|
+
}
|
|
489
|
+
| BrokerResponse
|
|
490
|
+
> {
|
|
491
|
+
const resolved = await resolveManagedSessionScope({ cwd, agentDir: broker.settings.agentDir });
|
|
492
|
+
if (resolved.kind !== "resolved")
|
|
493
|
+
return fail("invalid_input", `${label} session scope is invalid: ${resolved.message}`);
|
|
494
|
+
const migration = await broker.settings.resolveDirectoryMigration(cwd);
|
|
495
|
+
if (migration !== "copy-retain" && migration !== "disabled")
|
|
496
|
+
return fail("invalid_input", "Broker directory migration policy is invalid.");
|
|
497
|
+
const listed = await listManagedSessionCandidates({ scope: resolved.scope });
|
|
498
|
+
if (listed.kind !== "complete")
|
|
499
|
+
return fail("invalid_input", `${label} session storage could not be verified for the requested workspace.`);
|
|
500
|
+
return { candidates: listed.owned, migrationPolicy: migration, scope: resolved.scope };
|
|
501
|
+
}
|
|
436
502
|
|
|
437
|
-
function validateSavedTranscript(
|
|
503
|
+
async function validateSavedTranscript(
|
|
438
504
|
broker: Broker,
|
|
439
505
|
cwd: string,
|
|
440
506
|
suppliedPath: string | undefined,
|
|
441
507
|
expectedSessionId: string | undefined,
|
|
442
508
|
label: "Saved" | "Source",
|
|
443
|
-
): ValidatedTranscript | BrokerResponse {
|
|
444
|
-
const inventory =
|
|
445
|
-
|
|
446
|
-
});
|
|
447
|
-
if (inventory.kind !== "complete")
|
|
448
|
-
return fail("invalid_input", `${label} session storage could not be verified for the requested workspace.`);
|
|
509
|
+
): Promise<ValidatedTranscript | BrokerResponse> {
|
|
510
|
+
const inventory = await managedCandidates(broker, cwd, label);
|
|
511
|
+
if ("ok" in inventory) return inventory;
|
|
449
512
|
const canonicalPath = suppliedPath ? path.resolve(suppliedPath) : undefined;
|
|
450
513
|
const matches = inventory.candidates.filter(
|
|
451
514
|
candidate =>
|
|
452
515
|
(canonicalPath === undefined || candidate.path === canonicalPath) &&
|
|
453
|
-
(expectedSessionId === undefined || candidate.
|
|
516
|
+
(expectedSessionId === undefined || candidate.sessionId === expectedSessionId),
|
|
454
517
|
);
|
|
455
|
-
if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.
|
|
518
|
+
if (matches.length !== 1 || !isCanonicalSessionId(matches[0]!.sessionId))
|
|
456
519
|
return fail("invalid_input", `${label} saved session does not match the requested workspace and session id.`);
|
|
457
520
|
const match = matches[0]!;
|
|
458
|
-
|
|
521
|
+
if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy")
|
|
522
|
+
return fail("legacy_migration_disabled", `${label} legacy session migration is disabled for this workspace.`);
|
|
523
|
+
return { path: match.path, id: match.sessionId, identity: serializeTranscriptIdentity(match.identity) };
|
|
459
524
|
}
|
|
525
|
+
|
|
460
526
|
async function validateLiveResumeScope(
|
|
461
527
|
broker: Broker,
|
|
462
528
|
input: Input,
|
|
@@ -499,18 +565,18 @@ async function validateLiveResumeScope(
|
|
|
499
565
|
return fail("endpoint_stale", "Live session does not match the requested resume scope.");
|
|
500
566
|
const sessionPath = text(input.sessionPath);
|
|
501
567
|
if (!sessionPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
|
|
502
|
-
const inventory =
|
|
503
|
-
|
|
504
|
-
});
|
|
505
|
-
if (inventory.kind !== "complete")
|
|
568
|
+
const inventory = await managedCandidates(broker, cwd, "Saved");
|
|
569
|
+
if ("ok" in inventory)
|
|
506
570
|
return fail("endpoint_stale", "Requested saved session could not be verified for the requested workspace.");
|
|
507
571
|
const canonicalSessionPath = path.resolve(sessionPath);
|
|
508
572
|
const matches = inventory.candidates.filter(
|
|
509
|
-
candidate => candidate.
|
|
573
|
+
candidate => candidate.sessionId === requestedSessionId && candidate.path === canonicalSessionPath,
|
|
510
574
|
);
|
|
511
575
|
if (matches.length !== 1)
|
|
512
576
|
return fail("endpoint_stale", "Requested saved session does not match the live session scope.");
|
|
513
577
|
const session = matches[0]!;
|
|
578
|
+
if (inventory.migrationPolicy === "disabled" && matches[0]!.provenance === "legacy")
|
|
579
|
+
return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace.");
|
|
514
580
|
return {
|
|
515
581
|
cwd,
|
|
516
582
|
stateRoot: root,
|
|
@@ -609,15 +675,25 @@ function hasObservedProcessExit(pid: number): boolean {
|
|
|
609
675
|
|
|
610
676
|
function isEffectMarker(value: unknown): value is EffectMarker {
|
|
611
677
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
612
|
-
const marker = value as
|
|
678
|
+
const marker = value as Record<string, unknown>;
|
|
613
679
|
return (
|
|
614
680
|
typeof marker.pid === "number" &&
|
|
615
681
|
Number.isSafeInteger(marker.pid) &&
|
|
616
682
|
marker.pid > 0 &&
|
|
617
683
|
typeof marker.effectMarker === "string" &&
|
|
618
|
-
marker.effectMarker
|
|
684
|
+
/^[A-Za-z0-9._-]+$/.test(marker.effectMarker) &&
|
|
685
|
+
marker.effectMarker.length <= MAX_EFFECT_MARKER_LENGTH &&
|
|
619
686
|
typeof marker.incarnation === "string" &&
|
|
620
|
-
marker.incarnation.length > 0
|
|
687
|
+
marker.incarnation.length > 0 &&
|
|
688
|
+
marker.incarnation.length <= MAX_PROCESS_INCARNATION_LENGTH
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function isExactEffectMarker(value: unknown): value is EffectMarker {
|
|
693
|
+
return (
|
|
694
|
+
isEffectMarker(value) &&
|
|
695
|
+
Object.keys(value).length === 3 &&
|
|
696
|
+
Object.keys(value).every(key => key === "pid" || key === "effectMarker" || key === "incarnation")
|
|
621
697
|
);
|
|
622
698
|
}
|
|
623
699
|
|
|
@@ -627,8 +703,10 @@ function sameEffectMarker(left: EffectMarker, right: EffectMarker): boolean {
|
|
|
627
703
|
|
|
628
704
|
async function readEffectMarker(file: string): Promise<EffectMarker | undefined> {
|
|
629
705
|
try {
|
|
630
|
-
const
|
|
631
|
-
|
|
706
|
+
const captured = captureLifecycleFile(file, true, true);
|
|
707
|
+
if (!captured) return undefined;
|
|
708
|
+
const marker: unknown = parseLifecycleJson(captured.bytes);
|
|
709
|
+
return isExactEffectMarker(marker) ? marker : undefined;
|
|
632
710
|
} catch {
|
|
633
711
|
return undefined;
|
|
634
712
|
}
|
|
@@ -711,7 +789,8 @@ function isLifecycleTranscriptEvidence(value: unknown): value is LifecycleTransc
|
|
|
711
789
|
Object.keys(record).length === 2 &&
|
|
712
790
|
typeof record.digest === "string" &&
|
|
713
791
|
/^[a-f0-9]{64}$/.test(record.digest) &&
|
|
714
|
-
isSessionLifecycleTranscriptIdentity(record.identity)
|
|
792
|
+
isSessionLifecycleTranscriptIdentity(record.identity) &&
|
|
793
|
+
record.digest === record.identity.sha256
|
|
715
794
|
);
|
|
716
795
|
}
|
|
717
796
|
|
|
@@ -767,6 +846,10 @@ export async function writeSessionLifecycleFailure(
|
|
|
767
846
|
): Promise<void> {
|
|
768
847
|
if (!isSdkStartupFailure(failure))
|
|
769
848
|
throw new Error("Lifecycle startup failure does not satisfy the canonical failure contract.");
|
|
849
|
+
if (transcript && !isLifecycleTranscriptEvidence(transcript))
|
|
850
|
+
throw new Error(
|
|
851
|
+
"Lifecycle startup failure transcript evidence does not bind its content digest to its identity.",
|
|
852
|
+
);
|
|
770
853
|
|
|
771
854
|
const incarnation = ownerIncarnation ?? processIncarnation(process.pid);
|
|
772
855
|
if (!incarnation) return;
|
|
@@ -814,24 +897,43 @@ export async function writeSessionLifecycleFailure(
|
|
|
814
897
|
async function readLifecycleFailureArtifact(
|
|
815
898
|
file: string,
|
|
816
899
|
expected: EffectMarker,
|
|
817
|
-
): Promise<
|
|
900
|
+
): Promise<
|
|
901
|
+
| {
|
|
902
|
+
artifact: LifecycleFailureArtifact;
|
|
903
|
+
bytes: Buffer;
|
|
904
|
+
digest: string;
|
|
905
|
+
identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string };
|
|
906
|
+
}
|
|
907
|
+
| undefined
|
|
908
|
+
> {
|
|
818
909
|
let handle: fs.FileHandle | undefined;
|
|
819
910
|
try {
|
|
820
911
|
handle = await fs.open(file, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
|
|
821
|
-
const stat = await handle.stat();
|
|
822
|
-
if (!stat.isFile() || stat.size >
|
|
823
|
-
const bytes = Buffer.alloc(stat.size + 1);
|
|
912
|
+
const stat = await handle.stat({ bigint: true });
|
|
913
|
+
if (!stat.isFile() || stat.size > 4096n) return undefined;
|
|
914
|
+
const bytes = Buffer.alloc(Number(stat.size) + 1);
|
|
824
915
|
const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
825
916
|
if (bytesRead > 4096) return undefined;
|
|
826
917
|
const raw = bytes.subarray(0, bytesRead);
|
|
827
|
-
const value: unknown =
|
|
918
|
+
const value: unknown = parseLifecycleJson(raw);
|
|
828
919
|
if (
|
|
829
920
|
!isLifecycleFailureArtifact(value) ||
|
|
830
921
|
!sameEffectMarker(value, expected) ||
|
|
831
|
-
canonicalJson(value) !== raw
|
|
922
|
+
canonicalJson(value) !== decodeLifecycleUtf8(raw)
|
|
832
923
|
)
|
|
833
924
|
return undefined;
|
|
834
|
-
return {
|
|
925
|
+
return {
|
|
926
|
+
artifact: value,
|
|
927
|
+
bytes: raw,
|
|
928
|
+
digest: createHash("sha256").update(raw).digest("hex"),
|
|
929
|
+
identity: {
|
|
930
|
+
dev: stat.dev,
|
|
931
|
+
ino: stat.ino,
|
|
932
|
+
size: stat.size,
|
|
933
|
+
mtimeNs: stat.mtimeNs,
|
|
934
|
+
sha256: createHash("sha256").update(raw).digest("hex"),
|
|
935
|
+
},
|
|
936
|
+
};
|
|
835
937
|
} catch {
|
|
836
938
|
return undefined;
|
|
837
939
|
} finally {
|
|
@@ -839,6 +941,721 @@ async function readLifecycleFailureArtifact(
|
|
|
839
941
|
}
|
|
840
942
|
}
|
|
841
943
|
|
|
944
|
+
function exactUnlinkLifecycleFile(
|
|
945
|
+
file: string,
|
|
946
|
+
identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string },
|
|
947
|
+
plannedPath: string,
|
|
948
|
+
): NativeExactUnlinkResult {
|
|
949
|
+
return native.exactUnlink(file, { ...identity, quarantineName: path.basename(plannedPath) });
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
type LifecycleCleanupFile = NonNullable<BrokerCleanupEvidence["lifecycleFiles"]>[number];
|
|
953
|
+
|
|
954
|
+
function sameLifecycleCleanupIdentity(
|
|
955
|
+
left: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string },
|
|
956
|
+
right: BrokerCleanupIdentity,
|
|
957
|
+
): boolean {
|
|
958
|
+
return (
|
|
959
|
+
left.dev.toString() === right.dev &&
|
|
960
|
+
left.ino.toString() === right.ino &&
|
|
961
|
+
left.size === BigInt(right.size) &&
|
|
962
|
+
left.mtimeNs.toString() === right.mtimeNs &&
|
|
963
|
+
left.sha256 === right.sha256
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function lifecycleCleanupPlan(
|
|
968
|
+
root: string,
|
|
969
|
+
id: string,
|
|
970
|
+
expected: EffectMarker,
|
|
971
|
+
evidence: { identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string } },
|
|
972
|
+
): CleanupEvidence {
|
|
973
|
+
const directory = path.join(root, "sdk");
|
|
974
|
+
const candidates = [
|
|
975
|
+
lifecycleFailurePath(root, id, expected.effectMarker),
|
|
976
|
+
path.join(directory, `${id}.json`),
|
|
977
|
+
lifecycleReadyPath(root, id),
|
|
978
|
+
lifecycleMarkerPath(root, id),
|
|
979
|
+
];
|
|
980
|
+
const files: LifecycleCleanupFile[] = candidates.flatMap(file => {
|
|
981
|
+
const captured = captureLifecycleFile(
|
|
982
|
+
file,
|
|
983
|
+
true,
|
|
984
|
+
file === lifecycleMarkerPath(root, id) || file === lifecycleReadyPath(root, id),
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
if (!captured) return [];
|
|
988
|
+
if (file === lifecycleMarkerPath(root, id) || file === lifecycleReadyPath(root, id)) {
|
|
989
|
+
let marker: unknown;
|
|
990
|
+
try {
|
|
991
|
+
marker = parseLifecycleJson(captured.bytes);
|
|
992
|
+
} catch {
|
|
993
|
+
throw new Error("Lifecycle marker changed before cleanup intent persistence.");
|
|
994
|
+
}
|
|
995
|
+
if (!isExactEffectMarker(marker) || !sameEffectMarker(marker, expected))
|
|
996
|
+
throw new Error("Lifecycle marker changed before cleanup intent persistence.");
|
|
997
|
+
}
|
|
998
|
+
if (file.endsWith(`${id}.json`)) {
|
|
999
|
+
let endpoint: { pid?: unknown };
|
|
1000
|
+
try {
|
|
1001
|
+
endpoint = parseLifecycleJson(captured.bytes) as { pid?: unknown };
|
|
1002
|
+
} catch {
|
|
1003
|
+
throw new Error("Lifecycle endpoint changed before cleanup intent persistence.");
|
|
1004
|
+
}
|
|
1005
|
+
if (endpoint.pid !== expected.pid)
|
|
1006
|
+
throw new Error("Lifecycle endpoint changed before cleanup intent persistence.");
|
|
1007
|
+
}
|
|
1008
|
+
const identity = file === candidates[0] ? evidence.identity : captured.identity;
|
|
1009
|
+
if (
|
|
1010
|
+
file === candidates[0] &&
|
|
1011
|
+
!sameLifecycleCleanupIdentity(
|
|
1012
|
+
captured.identity,
|
|
1013
|
+
serializeCleanupIdentity({ ...identity, size: Number(identity.size) }),
|
|
1014
|
+
)
|
|
1015
|
+
)
|
|
1016
|
+
throw new Error("Lifecycle failure artifact changed before cleanup intent persistence.");
|
|
1017
|
+
const attempt = 1;
|
|
1018
|
+
const suffix = randomUUID();
|
|
1019
|
+
return [
|
|
1020
|
+
{
|
|
1021
|
+
path: file,
|
|
1022
|
+
identity: serializeCleanupIdentity({ ...identity, size: Number(identity.size) }),
|
|
1023
|
+
attempt,
|
|
1024
|
+
plannedPath: path.join(directory, `.gjc-delete-${suffix}-${path.basename(file)}`),
|
|
1025
|
+
},
|
|
1026
|
+
];
|
|
1027
|
+
});
|
|
1028
|
+
return { phase: "lifecycle", sessionId: id, metadataRoot: root, lifecycleFiles: files };
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function escapeRegExp(value: string): string {
|
|
1032
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function isCanonicalLifecycleCleanupOriginal(root: string, id: string, original: string): boolean {
|
|
1036
|
+
const directory = path.join(path.resolve(root), "sdk");
|
|
1037
|
+
if (path.dirname(original) !== directory) return false;
|
|
1038
|
+
const basename = path.basename(original);
|
|
1039
|
+
return (
|
|
1040
|
+
basename === `${id}.json` ||
|
|
1041
|
+
basename === `${id}.lifecycle.json` ||
|
|
1042
|
+
basename === `${id}.lifecycle.ready.json` ||
|
|
1043
|
+
new RegExp(`^${escapeRegExp(id)}\\.lifecycle\\.failure\\.[A-Za-z0-9._-]{1,128}\\.json$`).test(basename)
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function lifecycleCleanupHasMixedMetadataSchema(cleanup: CleanupEvidence): boolean {
|
|
1048
|
+
return [
|
|
1049
|
+
cleanup.metadataIdentity,
|
|
1050
|
+
cleanup.metadataPath,
|
|
1051
|
+
cleanup.metadataAttempt,
|
|
1052
|
+
cleanup.plannedMetadataPath,
|
|
1053
|
+
cleanup.detachedMetadataPath,
|
|
1054
|
+
cleanup.metadataCompleted,
|
|
1055
|
+
].some(value => value !== undefined);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function isCleanupIdentity(value: unknown): value is BrokerCleanupIdentity {
|
|
1059
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1060
|
+
const identity = value as Record<string, unknown>;
|
|
1061
|
+
return (
|
|
1062
|
+
Object.keys(identity).length === 5 &&
|
|
1063
|
+
Object.keys(identity).every(
|
|
1064
|
+
key => key === "dev" || key === "ino" || key === "size" || key === "mtimeNs" || key === "sha256",
|
|
1065
|
+
) &&
|
|
1066
|
+
typeof identity.dev === "string" &&
|
|
1067
|
+
/^\d+$/.test(identity.dev) &&
|
|
1068
|
+
typeof identity.ino === "string" &&
|
|
1069
|
+
/^\d+$/.test(identity.ino) &&
|
|
1070
|
+
typeof identity.size === "number" &&
|
|
1071
|
+
Number.isSafeInteger(identity.size) &&
|
|
1072
|
+
identity.size >= 0 &&
|
|
1073
|
+
typeof identity.mtimeNs === "string" &&
|
|
1074
|
+
/^\d+$/.test(identity.mtimeNs) &&
|
|
1075
|
+
typeof identity.sha256 === "string" &&
|
|
1076
|
+
/^[a-f0-9]{64}$/.test(identity.sha256)
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function isLifecycleCleanupFile(value: unknown): value is LifecycleCleanupFile {
|
|
1081
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
1082
|
+
const file = value as Record<string, unknown>;
|
|
1083
|
+
const allowed = new Set(["path", "identity", "attempt", "plannedPath", "detachedPath", "completed"]);
|
|
1084
|
+
return (
|
|
1085
|
+
Object.keys(file).every(key => allowed.has(key)) &&
|
|
1086
|
+
typeof file.path === "string" &&
|
|
1087
|
+
file.path.length > 0 &&
|
|
1088
|
+
typeof file.plannedPath === "string" &&
|
|
1089
|
+
file.plannedPath.length > 0 &&
|
|
1090
|
+
isCleanupIdentity(file.identity) &&
|
|
1091
|
+
typeof file.attempt === "number" &&
|
|
1092
|
+
Number.isSafeInteger(file.attempt) &&
|
|
1093
|
+
file.attempt > 0 &&
|
|
1094
|
+
(file.detachedPath === undefined || (typeof file.detachedPath === "string" && file.detachedPath.length > 0)) &&
|
|
1095
|
+
(file.completed === undefined || file.completed === true)
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function isLifecycleCleanupEvidence(cleanup: CleanupEvidence): boolean {
|
|
1100
|
+
if (typeof cleanup !== "object" || cleanup === null || Array.isArray(cleanup)) return false;
|
|
1101
|
+
const record = cleanup as Record<string, unknown>;
|
|
1102
|
+
const allowed = new Set(["phase", "sessionId", "metadataRoot", "lifecycleDeleteMetadata", "lifecycleFiles"]);
|
|
1103
|
+
return (
|
|
1104
|
+
Object.keys(record).every(key => allowed.has(key)) &&
|
|
1105
|
+
record.phase === "lifecycle" &&
|
|
1106
|
+
typeof record.sessionId === "string" &&
|
|
1107
|
+
isCanonicalSessionId(record.sessionId) &&
|
|
1108
|
+
typeof record.metadataRoot === "string" &&
|
|
1109
|
+
record.metadataRoot.length > 0 &&
|
|
1110
|
+
Array.isArray(record.lifecycleFiles) &&
|
|
1111
|
+
record.lifecycleFiles.length > 0 &&
|
|
1112
|
+
record.lifecycleFiles.length <= 4 &&
|
|
1113
|
+
(record.lifecycleDeleteMetadata === undefined || record.lifecycleDeleteMetadata === true) &&
|
|
1114
|
+
record.lifecycleFiles.every(isLifecycleCleanupFile)
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function validateLifecycleCleanupShape(cleanup: CleanupEvidence): BrokerResponse | undefined {
|
|
1119
|
+
if (
|
|
1120
|
+
!isLifecycleCleanupEvidence(cleanup) ||
|
|
1121
|
+
lifecycleCleanupHasMixedMetadataSchema(cleanup) ||
|
|
1122
|
+
(cleanup.lifecycleDeleteMetadata === true && cleanup.lifecycleFiles!.length > 2)
|
|
1123
|
+
)
|
|
1124
|
+
return fail("terminal_uncertain", "Lifecycle cleanup replay lacks a complete unambiguous schema.");
|
|
1125
|
+
const files = cleanup.lifecycleFiles!;
|
|
1126
|
+
const paths = new Set<string>();
|
|
1127
|
+
for (const file of files) {
|
|
1128
|
+
if (!validateLifecycleCleanupFile(cleanup.metadataRoot!, cleanup.sessionId!, file))
|
|
1129
|
+
return fail("terminal_uncertain", "Lifecycle cleanup replay contains an invalid path authority.");
|
|
1130
|
+
const entryPaths = new Map<string, "path" | "plannedPath" | "detachedPath">();
|
|
1131
|
+
for (const [field, candidate] of [
|
|
1132
|
+
["path", file.path],
|
|
1133
|
+
["plannedPath", file.plannedPath],
|
|
1134
|
+
["detachedPath", file.detachedPath],
|
|
1135
|
+
] as const) {
|
|
1136
|
+
if (candidate === undefined) continue;
|
|
1137
|
+
const resolved = path.resolve(candidate);
|
|
1138
|
+
const previousField = entryPaths.get(resolved);
|
|
1139
|
+
if (previousField !== undefined) {
|
|
1140
|
+
if (
|
|
1141
|
+
(previousField === "plannedPath" && field === "detachedPath") ||
|
|
1142
|
+
(previousField === "detachedPath" && field === "plannedPath")
|
|
1143
|
+
)
|
|
1144
|
+
continue;
|
|
1145
|
+
return fail("terminal_uncertain", "Lifecycle cleanup replay contains duplicate path authority.");
|
|
1146
|
+
}
|
|
1147
|
+
entryPaths.set(resolved, field);
|
|
1148
|
+
if (paths.has(resolved))
|
|
1149
|
+
return fail("terminal_uncertain", "Lifecycle cleanup replay contains duplicate path authority.");
|
|
1150
|
+
paths.add(resolved);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return undefined;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
function validateLifecycleCleanupFile(root: string, id: string, file: LifecycleCleanupFile): boolean {
|
|
1157
|
+
const directory = path.join(path.resolve(root), "sdk");
|
|
1158
|
+
const original = path.resolve(file.path);
|
|
1159
|
+
const planned = path.resolve(file.plannedPath);
|
|
1160
|
+
if (
|
|
1161
|
+
!isCanonicalLifecycleCleanupOriginal(root, id, original) ||
|
|
1162
|
+
path.dirname(planned) !== directory ||
|
|
1163
|
+
!path.basename(planned).startsWith(".gjc-delete-") ||
|
|
1164
|
+
(file.detachedPath !== undefined && path.dirname(path.resolve(file.detachedPath)) !== directory)
|
|
1165
|
+
)
|
|
1166
|
+
return false;
|
|
1167
|
+
return isCleanupIdentity(file.identity);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function lifecycleCleanupCandidates(file: LifecycleCleanupFile): string[] {
|
|
1171
|
+
return [file.path, file.detachedPath, file.plannedPath].filter(
|
|
1172
|
+
(value, index, values): value is string => typeof value === "string" && values.indexOf(value) === index,
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
function lifecycleMetadataReplayFiles(cleanup: CleanupEvidence): LifecycleCleanupFile[] | undefined {
|
|
1177
|
+
if (cleanup.lifecycleDeleteMetadata !== true) return undefined;
|
|
1178
|
+
return cleanup.lifecycleFiles?.length ? cleanup.lifecycleFiles : undefined;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function absentLifecyclePath(file: string): boolean {
|
|
1182
|
+
try {
|
|
1183
|
+
fsSync.lstatSync(file);
|
|
1184
|
+
return false;
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function isLifecycleCleanupResponse(value: LifecycleFileCapture | BrokerResponse | undefined): value is BrokerResponse {
|
|
1191
|
+
return typeof value === "object" && value !== null && "ok" in value;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function validateLifecycleMetadataReplay(cleanup: CleanupEvidence): BrokerResponse | undefined {
|
|
1195
|
+
const files = lifecycleMetadataReplayFiles(cleanup);
|
|
1196
|
+
if (!files) return undefined;
|
|
1197
|
+
const root = path.resolve(cleanup.metadataRoot!);
|
|
1198
|
+
const id = cleanup.sessionId!;
|
|
1199
|
+
const markerPath = lifecycleMarkerPath(root, id);
|
|
1200
|
+
const readyPath = lifecycleReadyPath(root, id);
|
|
1201
|
+
const authorities = new Set<string>();
|
|
1202
|
+
const candidates = new Set<string>();
|
|
1203
|
+
for (const file of files) {
|
|
1204
|
+
const authority = path.resolve(file.path);
|
|
1205
|
+
if ((authority !== markerPath && authority !== readyPath) || authorities.has(authority))
|
|
1206
|
+
return fail("terminal_uncertain", "Lifecycle metadata replay contains duplicate or non-canonical authority.");
|
|
1207
|
+
authorities.add(authority);
|
|
1208
|
+
for (const candidate of lifecycleCleanupCandidates(file)) {
|
|
1209
|
+
const resolved = path.resolve(candidate);
|
|
1210
|
+
if (candidates.has(resolved))
|
|
1211
|
+
return fail("terminal_uncertain", "Lifecycle metadata replay contains duplicate candidate authority.");
|
|
1212
|
+
candidates.add(resolved);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
const markerEntry = files.find(file => path.resolve(file.path) === markerPath);
|
|
1216
|
+
const readyEntry = files.find(file => path.resolve(file.path) === readyPath);
|
|
1217
|
+
const capture = (file: string): LifecycleFileCapture | undefined | BrokerResponse => {
|
|
1218
|
+
try {
|
|
1219
|
+
return captureLifecycleFile(file, true, true);
|
|
1220
|
+
} catch {
|
|
1221
|
+
return fail("terminal_uncertain", "Lifecycle metadata sibling could not be safely inspected.");
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
const marker = capture(markerPath);
|
|
1225
|
+
const ready = capture(readyPath);
|
|
1226
|
+
if (isLifecycleCleanupResponse(marker)) return marker;
|
|
1227
|
+
if (isLifecycleCleanupResponse(ready)) return ready;
|
|
1228
|
+
if (marker && (!markerEntry || !sameLifecycleCleanupIdentity(marker.identity, markerEntry.identity)))
|
|
1229
|
+
return fail("terminal_uncertain", "Lifecycle marker sibling lacks exact replay authority.");
|
|
1230
|
+
if (ready && (!readyEntry || !sameLifecycleCleanupIdentity(ready.identity, readyEntry.identity)))
|
|
1231
|
+
return fail("terminal_uncertain", "Lifecycle readiness sibling lacks exact replay authority.");
|
|
1232
|
+
for (const file of files) {
|
|
1233
|
+
let activeCandidates = 0;
|
|
1234
|
+
for (const candidate of lifecycleCleanupCandidates(file)) {
|
|
1235
|
+
let current: LifecycleFileCapture | undefined;
|
|
1236
|
+
try {
|
|
1237
|
+
current = captureLifecycleFile(candidate, true, true);
|
|
1238
|
+
} catch {
|
|
1239
|
+
return fail("terminal_uncertain", "Lifecycle metadata candidate could not be safely inspected.");
|
|
1240
|
+
}
|
|
1241
|
+
if (!current) continue;
|
|
1242
|
+
if (!sameLifecycleCleanupIdentity(current.identity, file.identity))
|
|
1243
|
+
return fail("terminal_uncertain", "Lifecycle metadata candidate lacks exact replay authority.");
|
|
1244
|
+
activeCandidates++;
|
|
1245
|
+
}
|
|
1246
|
+
if (file.completed && activeCandidates > 0)
|
|
1247
|
+
return fail(
|
|
1248
|
+
"terminal_uncertain",
|
|
1249
|
+
"Lifecycle cleanup receipt marks a metadata target complete while a candidate remains.",
|
|
1250
|
+
);
|
|
1251
|
+
if (!file.completed && activeCandidates > 1)
|
|
1252
|
+
return fail("terminal_uncertain", "Lifecycle metadata replay has multiple active candidates.");
|
|
1253
|
+
}
|
|
1254
|
+
if (ready && !markerEntry)
|
|
1255
|
+
return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
|
|
1256
|
+
if (!ready) return undefined;
|
|
1257
|
+
let readyMarker: EffectMarker;
|
|
1258
|
+
try {
|
|
1259
|
+
const value: unknown = parseLifecycleJson(ready.bytes);
|
|
1260
|
+
if (!isExactEffectMarker(value)) throw new Error("invalid ready marker");
|
|
1261
|
+
|
|
1262
|
+
readyMarker = value;
|
|
1263
|
+
} catch {
|
|
1264
|
+
return fail("terminal_uncertain", "Lifecycle readiness metadata ownership could not be verified.");
|
|
1265
|
+
}
|
|
1266
|
+
if (!markerEntry)
|
|
1267
|
+
return fail("terminal_uncertain", "Lifecycle readiness metadata lacks canonical marker authority.");
|
|
1268
|
+
if (!marker) {
|
|
1269
|
+
if (createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== markerEntry.identity.sha256)
|
|
1270
|
+
return fail(
|
|
1271
|
+
"terminal_uncertain",
|
|
1272
|
+
"Lifecycle readiness metadata is not bound to the completed marker authority.",
|
|
1273
|
+
);
|
|
1274
|
+
return undefined;
|
|
1275
|
+
}
|
|
1276
|
+
try {
|
|
1277
|
+
const value: unknown = parseLifecycleJson(marker.bytes);
|
|
1278
|
+
if (!isExactEffectMarker(value) || !sameEffectMarker(value, readyMarker)) throw new Error("mismatched marker");
|
|
1279
|
+
} catch {
|
|
1280
|
+
return fail("terminal_uncertain", "Lifecycle metadata siblings do not share one owner marker.");
|
|
1281
|
+
}
|
|
1282
|
+
return undefined;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function lifecycleMetadataReplayAbsent(cleanup: CleanupEvidence): boolean {
|
|
1286
|
+
const files = lifecycleMetadataReplayFiles(cleanup);
|
|
1287
|
+
if (!files) return true;
|
|
1288
|
+
const root = path.resolve(cleanup.metadataRoot!);
|
|
1289
|
+
const id = cleanup.sessionId!;
|
|
1290
|
+
const required = new Set<string>([lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)]);
|
|
1291
|
+
for (const file of files) for (const candidate of lifecycleCleanupCandidates(file)) required.add(candidate);
|
|
1292
|
+
return [...required].every(absentLifecyclePath);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/**
|
|
1296
|
+
* Base dev persisted metadata cleanup one file at a time. Accept only its
|
|
1297
|
+
* identity-bound marker receipt and translate it into the current replay plan.
|
|
1298
|
+
*/
|
|
1299
|
+
function hasExactLegacyMetadataCleanupKeys(cleanup: CleanupEvidence): boolean {
|
|
1300
|
+
if (typeof cleanup !== "object" || cleanup === null || Array.isArray(cleanup)) return false;
|
|
1301
|
+
const allowed = new Set([
|
|
1302
|
+
"phase",
|
|
1303
|
+
"sessionId",
|
|
1304
|
+
"metadataRoot",
|
|
1305
|
+
"metadataIdentity",
|
|
1306
|
+
"metadataPath",
|
|
1307
|
+
"metadataAttempt",
|
|
1308
|
+
"plannedMetadataPath",
|
|
1309
|
+
"detachedMetadataPath",
|
|
1310
|
+
"metadataCompleted",
|
|
1311
|
+
]);
|
|
1312
|
+
return Object.keys(cleanup as Record<string, unknown>).every(key => allowed.has(key));
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function legacyMetadataCleanupPlan(cleanup: CleanupEvidence): CleanupEvidence | undefined {
|
|
1316
|
+
if (
|
|
1317
|
+
!hasExactLegacyMetadataCleanupKeys(cleanup) ||
|
|
1318
|
+
cleanup.phase !== "metadata" ||
|
|
1319
|
+
typeof cleanup.sessionId !== "string" ||
|
|
1320
|
+
!isCanonicalSessionId(cleanup.sessionId) ||
|
|
1321
|
+
typeof cleanup.metadataRoot !== "string" ||
|
|
1322
|
+
cleanup.metadataRoot.length === 0 ||
|
|
1323
|
+
typeof cleanup.metadataPath !== "string" ||
|
|
1324
|
+
cleanup.metadataPath.length === 0 ||
|
|
1325
|
+
!cleanup.metadataIdentity ||
|
|
1326
|
+
typeof cleanup.plannedMetadataPath !== "string" ||
|
|
1327
|
+
cleanup.plannedMetadataPath.length === 0 ||
|
|
1328
|
+
(cleanup.detachedMetadataPath !== undefined &&
|
|
1329
|
+
(typeof cleanup.detachedMetadataPath !== "string" || cleanup.detachedMetadataPath.length === 0)) ||
|
|
1330
|
+
(cleanup.metadataCompleted !== undefined && cleanup.metadataCompleted !== true)
|
|
1331
|
+
)
|
|
1332
|
+
return undefined;
|
|
1333
|
+
const root = path.resolve(cleanup.metadataRoot);
|
|
1334
|
+
const directory = path.join(root, "sdk");
|
|
1335
|
+
const markerPath = lifecycleMarkerPath(root, cleanup.sessionId);
|
|
1336
|
+
const readyPath = lifecycleReadyPath(root, cleanup.sessionId);
|
|
1337
|
+
const metadataPath = path.resolve(cleanup.metadataPath);
|
|
1338
|
+
const plannedPath = path.resolve(cleanup.plannedMetadataPath);
|
|
1339
|
+
const detachedPath = cleanup.detachedMetadataPath && path.resolve(cleanup.detachedMetadataPath);
|
|
1340
|
+
if (
|
|
1341
|
+
metadataPath !== markerPath ||
|
|
1342
|
+
path.dirname(plannedPath) !== directory ||
|
|
1343
|
+
!path.basename(plannedPath).startsWith(".gjc-delete-") ||
|
|
1344
|
+
(detachedPath !== undefined && path.dirname(detachedPath) !== directory) ||
|
|
1345
|
+
(cleanup.metadataAttempt !== undefined &&
|
|
1346
|
+
(!Number.isSafeInteger(cleanup.metadataAttempt) || cleanup.metadataAttempt < 1))
|
|
1347
|
+
)
|
|
1348
|
+
return undefined;
|
|
1349
|
+
const persistedIdentity = cleanupIdentity(cleanup.metadataIdentity);
|
|
1350
|
+
if (!persistedIdentity) return undefined;
|
|
1351
|
+
|
|
1352
|
+
const captureExactRegular = (
|
|
1353
|
+
file: string,
|
|
1354
|
+
): { kind: "absent" } | { kind: "present"; capture: LifecycleFileCapture } | undefined => {
|
|
1355
|
+
try {
|
|
1356
|
+
const stat = fsSync.lstatSync(file);
|
|
1357
|
+
if (stat.isSymbolicLink() || !stat.isFile()) return undefined;
|
|
1358
|
+
const capture = captureLifecycleFile(file, true, true);
|
|
1359
|
+
|
|
1360
|
+
return capture ? { kind: "present", capture } : undefined;
|
|
1361
|
+
} catch (error) {
|
|
1362
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? { kind: "absent" } : undefined;
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
|
|
1366
|
+
const markerCandidates = [metadataPath, detachedPath, plannedPath].filter(
|
|
1367
|
+
(candidate, index, candidates): candidate is string =>
|
|
1368
|
+
typeof candidate === "string" && candidates.indexOf(candidate) === index,
|
|
1369
|
+
);
|
|
1370
|
+
let activeMarker: LifecycleFileCapture | undefined;
|
|
1371
|
+
for (const candidate of markerCandidates) {
|
|
1372
|
+
const current = captureExactRegular(candidate);
|
|
1373
|
+
if (!current) return undefined;
|
|
1374
|
+
if (current.kind === "absent") continue;
|
|
1375
|
+
if (!sameLifecycleCleanupIdentity(current.capture.identity, serializeCleanupIdentity(persistedIdentity)))
|
|
1376
|
+
return undefined;
|
|
1377
|
+
if (activeMarker) return undefined;
|
|
1378
|
+
activeMarker = current.capture;
|
|
1379
|
+
}
|
|
1380
|
+
const markerCompleted = !activeMarker;
|
|
1381
|
+
// Legacy receipts can crash after exact marker unlink and before recording metadataCompleted.
|
|
1382
|
+
// With every authorized marker candidate absent, only the persisted marker digest may bind a ready sibling.
|
|
1383
|
+
let marker: EffectMarker | undefined;
|
|
1384
|
+
if (activeMarker) {
|
|
1385
|
+
try {
|
|
1386
|
+
const value: unknown = parseLifecycleJson(activeMarker.bytes);
|
|
1387
|
+
if (!isExactEffectMarker(value)) return undefined;
|
|
1388
|
+
|
|
1389
|
+
marker = value;
|
|
1390
|
+
} catch {
|
|
1391
|
+
return undefined;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
const ready = captureExactRegular(readyPath);
|
|
1396
|
+
if (!ready) return undefined;
|
|
1397
|
+
let readyMarker: EffectMarker | undefined;
|
|
1398
|
+
if (ready.kind === "present") {
|
|
1399
|
+
try {
|
|
1400
|
+
const value: unknown = parseLifecycleJson(ready.capture.bytes);
|
|
1401
|
+
if (!isExactEffectMarker(value)) return undefined;
|
|
1402
|
+
|
|
1403
|
+
readyMarker = value;
|
|
1404
|
+
} catch {
|
|
1405
|
+
return undefined;
|
|
1406
|
+
}
|
|
1407
|
+
if (marker && !sameEffectMarker(marker, readyMarker)) return undefined;
|
|
1408
|
+
if (!marker && createHash("sha256").update(canonicalJson(readyMarker)).digest("hex") !== persistedIdentity.sha256)
|
|
1409
|
+
return undefined;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
return {
|
|
1413
|
+
phase: "lifecycle",
|
|
1414
|
+
sessionId: cleanup.sessionId,
|
|
1415
|
+
metadataRoot: root,
|
|
1416
|
+
lifecycleDeleteMetadata: true,
|
|
1417
|
+
lifecycleFiles: [
|
|
1418
|
+
{
|
|
1419
|
+
path: metadataPath,
|
|
1420
|
+
identity: serializeCleanupIdentity({
|
|
1421
|
+
...(activeMarker?.identity ?? persistedIdentity),
|
|
1422
|
+
size: Number((activeMarker?.identity ?? persistedIdentity).size),
|
|
1423
|
+
}),
|
|
1424
|
+
attempt: cleanup.metadataAttempt ?? 1,
|
|
1425
|
+
plannedPath,
|
|
1426
|
+
...(detachedPath ? { detachedPath } : {}),
|
|
1427
|
+
...(markerCompleted ? { completed: true as const } : {}),
|
|
1428
|
+
},
|
|
1429
|
+
...(ready.kind === "present"
|
|
1430
|
+
? [
|
|
1431
|
+
{
|
|
1432
|
+
path: readyPath,
|
|
1433
|
+
identity: serializeCleanupIdentity({
|
|
1434
|
+
...ready.capture.identity,
|
|
1435
|
+
size: Number(ready.capture.identity.size),
|
|
1436
|
+
}),
|
|
1437
|
+
attempt: 1,
|
|
1438
|
+
plannedPath: path.join(directory, `.gjc-delete-${randomUUID()}-${path.basename(readyPath)}`),
|
|
1439
|
+
},
|
|
1440
|
+
]
|
|
1441
|
+
: []),
|
|
1442
|
+
],
|
|
1443
|
+
};
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function lifecycleDeleteMetadataCleanupPlan(
|
|
1447
|
+
metadataRoot: string,
|
|
1448
|
+
id: string,
|
|
1449
|
+
files: ReadonlyArray<{ metadataPath: string; metadata: LifecycleFileCapture }>,
|
|
1450
|
+
): CleanupEvidence {
|
|
1451
|
+
return {
|
|
1452
|
+
phase: "lifecycle",
|
|
1453
|
+
sessionId: id,
|
|
1454
|
+
metadataRoot,
|
|
1455
|
+
lifecycleDeleteMetadata: true,
|
|
1456
|
+
lifecycleFiles: files.map(({ metadataPath, metadata }) => ({
|
|
1457
|
+
path: metadataPath,
|
|
1458
|
+
identity: serializeCleanupIdentity({
|
|
1459
|
+
dev: metadata.identity.dev,
|
|
1460
|
+
ino: metadata.identity.ino,
|
|
1461
|
+
size: Number(metadata.identity.size),
|
|
1462
|
+
mtimeNs: metadata.identity.mtimeNs,
|
|
1463
|
+
sha256: metadata.identity.sha256,
|
|
1464
|
+
}),
|
|
1465
|
+
attempt: 1,
|
|
1466
|
+
plannedPath: path.join(
|
|
1467
|
+
path.dirname(metadataPath),
|
|
1468
|
+
`.gjc-delete-${randomUUID()}-${path.basename(metadataPath)}`,
|
|
1469
|
+
),
|
|
1470
|
+
})),
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
type LifecycleDeleteMetadataPreflight = { cleanup: CleanupEvidence } | BrokerResponse;
|
|
1475
|
+
|
|
1476
|
+
/**
|
|
1477
|
+
* Capture lifecycle metadata before deleting any saved user data. A fresh delete
|
|
1478
|
+
* may only clean metadata owned by one dead lifecycle process.
|
|
1479
|
+
*/
|
|
1480
|
+
function preflightLifecycleDeleteMetadata(
|
|
1481
|
+
root: string,
|
|
1482
|
+
id: string,
|
|
1483
|
+
record: { pid: number } | undefined,
|
|
1484
|
+
readIncarnation: (pid: number) => string | undefined,
|
|
1485
|
+
): LifecycleDeleteMetadataPreflight {
|
|
1486
|
+
const metadataPaths = [lifecycleMarkerPath(root, id), lifecycleReadyPath(root, id)];
|
|
1487
|
+
const lifecycleMetadata: Array<{
|
|
1488
|
+
metadataPath: string;
|
|
1489
|
+
metadata: LifecycleFileCapture;
|
|
1490
|
+
marker: EffectMarker;
|
|
1491
|
+
}> = [];
|
|
1492
|
+
for (const metadataPath of metadataPaths) {
|
|
1493
|
+
let metadata: LifecycleFileCapture | undefined;
|
|
1494
|
+
try {
|
|
1495
|
+
metadata = captureLifecycleFile(metadataPath, true, true);
|
|
1496
|
+
} catch {
|
|
1497
|
+
return fail("terminal_uncertain", "Lifecycle metadata path is occupied by an unsafe object.");
|
|
1498
|
+
}
|
|
1499
|
+
if (!metadata) continue;
|
|
1500
|
+
let marker: unknown;
|
|
1501
|
+
try {
|
|
1502
|
+
marker = parseLifecycleJson(metadata.bytes);
|
|
1503
|
+
} catch {
|
|
1504
|
+
return fail("terminal_uncertain", "Lifecycle metadata ownership could not be verified.");
|
|
1505
|
+
}
|
|
1506
|
+
if (
|
|
1507
|
+
!isExactEffectMarker(marker) ||
|
|
1508
|
+
(record && marker.pid !== record.pid) ||
|
|
1509
|
+
observeProcess(marker.pid, marker.incarnation, readIncarnation) !== "exited"
|
|
1510
|
+
)
|
|
1511
|
+
return fail("terminal_uncertain", "Lifecycle metadata ownership could not be verified.");
|
|
1512
|
+
lifecycleMetadata.push({ metadataPath, metadata, marker });
|
|
1513
|
+
}
|
|
1514
|
+
const canonicalLifecycleMarker = lifecycleMetadata.find(
|
|
1515
|
+
metadata => metadata.metadataPath === lifecycleMarkerPath(root, id),
|
|
1516
|
+
);
|
|
1517
|
+
const lifecycleReadyMarker = lifecycleMetadata.find(
|
|
1518
|
+
metadata => metadata.metadataPath === lifecycleReadyPath(root, id),
|
|
1519
|
+
);
|
|
1520
|
+
if (lifecycleReadyMarker && !canonicalLifecycleMarker)
|
|
1521
|
+
return fail(
|
|
1522
|
+
"terminal_uncertain",
|
|
1523
|
+
"Lifecycle readiness metadata lacks canonical marker authority for fresh cleanup.",
|
|
1524
|
+
);
|
|
1525
|
+
if (
|
|
1526
|
+
canonicalLifecycleMarker &&
|
|
1527
|
+
lifecycleReadyMarker &&
|
|
1528
|
+
!sameEffectMarker(canonicalLifecycleMarker.marker, lifecycleReadyMarker.marker)
|
|
1529
|
+
)
|
|
1530
|
+
return fail("terminal_uncertain", "Lifecycle metadata siblings do not share one owner marker.");
|
|
1531
|
+
return { cleanup: lifecycleDeleteMetadataCleanupPlan(root, id, lifecycleMetadata) };
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
async function reconcileLifecycleCleanup(
|
|
1535
|
+
broker: Broker,
|
|
1536
|
+
identity: string,
|
|
1537
|
+
cleanup: CleanupEvidence,
|
|
1538
|
+
completion: BrokerResponse = fail("spawn_failed", "No ready SDK endpoint remains available."),
|
|
1539
|
+
): Promise<BrokerResponse> {
|
|
1540
|
+
const shapeValidation = validateLifecycleCleanupShape(cleanup);
|
|
1541
|
+
if (shapeValidation) return shapeValidation;
|
|
1542
|
+
let activeCleanup =
|
|
1543
|
+
cleanup.lifecycleDeleteMetadata === true || completion.ok
|
|
1544
|
+
? { ...cleanup, lifecycleDeleteMetadata: true as const }
|
|
1545
|
+
: cleanup;
|
|
1546
|
+
const metadataReplayValidation = validateLifecycleMetadataReplay(activeCleanup);
|
|
1547
|
+
if (metadataReplayValidation) return metadataReplayValidation;
|
|
1548
|
+
for (let index = 0; index < activeCleanup.lifecycleFiles!.length; index++) {
|
|
1549
|
+
const file = activeCleanup.lifecycleFiles![index];
|
|
1550
|
+
if (!validateLifecycleCleanupFile(activeCleanup.metadataRoot!, activeCleanup.sessionId!, file))
|
|
1551
|
+
return fail("terminal_uncertain", "Lifecycle cleanup replay contains an invalid path authority.");
|
|
1552
|
+
const candidates = lifecycleCleanupCandidates(file);
|
|
1553
|
+
if (file.completed) {
|
|
1554
|
+
for (const candidate of candidates) {
|
|
1555
|
+
try {
|
|
1556
|
+
fsSync.lstatSync(candidate);
|
|
1557
|
+
return fail(
|
|
1558
|
+
"terminal_uncertain",
|
|
1559
|
+
"Lifecycle cleanup receipt marks a target complete while an authorized candidate remains.",
|
|
1560
|
+
);
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
1563
|
+
return fail("terminal_uncertain", "Lifecycle cleanup completion could not be safely inspected.");
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
continue;
|
|
1567
|
+
}
|
|
1568
|
+
let activePath: string | undefined;
|
|
1569
|
+
let captured: LifecycleFileCapture | undefined;
|
|
1570
|
+
let foundUnauthorized = false;
|
|
1571
|
+
for (const candidate of candidates) {
|
|
1572
|
+
try {
|
|
1573
|
+
const stat = fsSync.lstatSync(candidate);
|
|
1574
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1575
|
+
foundUnauthorized = true;
|
|
1576
|
+
continue;
|
|
1577
|
+
}
|
|
1578
|
+
} catch (error) {
|
|
1579
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
|
|
1580
|
+
foundUnauthorized = true;
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
const current = captureLifecycleFile(candidate, true, true);
|
|
1584
|
+
|
|
1585
|
+
if (!current) {
|
|
1586
|
+
foundUnauthorized = true;
|
|
1587
|
+
continue;
|
|
1588
|
+
}
|
|
1589
|
+
if (sameLifecycleCleanupIdentity(current.identity, file.identity) && !activePath) {
|
|
1590
|
+
activePath = candidate;
|
|
1591
|
+
captured = current;
|
|
1592
|
+
} else {
|
|
1593
|
+
foundUnauthorized = true;
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
if (foundUnauthorized)
|
|
1597
|
+
return fail("terminal_uncertain", "Lifecycle cleanup target identity changed before reconciliation.");
|
|
1598
|
+
if (!captured || !activePath) {
|
|
1599
|
+
const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
|
|
1600
|
+
candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
|
|
1601
|
+
);
|
|
1602
|
+
activeCleanup = { ...activeCleanup, lifecycleFiles };
|
|
1603
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
1604
|
+
response: fail("cleanup_pending", "Lifecycle cleanup completion was durably reconciled.", activeCleanup),
|
|
1605
|
+
});
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
if (file.detachedPath || activePath === file.plannedPath) {
|
|
1610
|
+
const nextFile: LifecycleCleanupFile = {
|
|
1611
|
+
...file,
|
|
1612
|
+
detachedPath: activePath,
|
|
1613
|
+
attempt: (file.attempt ?? 1) + 1,
|
|
1614
|
+
plannedPath: path.join(path.dirname(file.path), `.gjc-delete-${randomUUID()}-${path.basename(file.path)}`),
|
|
1615
|
+
};
|
|
1616
|
+
const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
|
|
1617
|
+
candidateIndex === index ? nextFile : candidate,
|
|
1618
|
+
);
|
|
1619
|
+
activeCleanup = { ...activeCleanup, lifecycleFiles };
|
|
1620
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
1621
|
+
response: fail(
|
|
1622
|
+
"cleanup_pending",
|
|
1623
|
+
"Lifecycle retry cleanup is preauthorized for durable reconciliation.",
|
|
1624
|
+
activeCleanup,
|
|
1625
|
+
),
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
const currentFile = activeCleanup.lifecycleFiles![index];
|
|
1629
|
+
const result = native.exactUnlink(activePath, {
|
|
1630
|
+
...captured.identity,
|
|
1631
|
+
quarantineName: path.basename(currentFile.plannedPath),
|
|
1632
|
+
});
|
|
1633
|
+
if (!result.ok) {
|
|
1634
|
+
const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
|
|
1635
|
+
candidateIndex === index && result.detachedPath
|
|
1636
|
+
? { ...candidate, detachedPath: result.detachedPath }
|
|
1637
|
+
: candidate,
|
|
1638
|
+
);
|
|
1639
|
+
return fail("cleanup_pending", `Lifecycle cleanup remains pending: ${result.code ?? "unknown"}`, {
|
|
1640
|
+
...activeCleanup,
|
|
1641
|
+
lifecycleFiles,
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
const lifecycleFiles = activeCleanup.lifecycleFiles!.map((candidate, candidateIndex) =>
|
|
1645
|
+
candidateIndex === index ? { ...candidate, completed: true as const } : candidate,
|
|
1646
|
+
);
|
|
1647
|
+
activeCleanup = { ...activeCleanup, lifecycleFiles };
|
|
1648
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
1649
|
+
response: fail("cleanup_pending", "Lifecycle cleanup completion was durably reconciled.", activeCleanup),
|
|
1650
|
+
});
|
|
1651
|
+
lifecycleCleanupHooksForTest.get(broker)?.();
|
|
1652
|
+
}
|
|
1653
|
+
if (!lifecycleMetadataReplayAbsent(activeCleanup))
|
|
1654
|
+
return fail("terminal_uncertain", "Lifecycle metadata replay left an authorized sibling behind.");
|
|
1655
|
+
await syncDirectory(path.join(activeCleanup.metadataRoot!, "sdk"));
|
|
1656
|
+
return completion;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
842
1659
|
async function readSessionLifecycleFailure(
|
|
843
1660
|
root: string,
|
|
844
1661
|
id: string,
|
|
@@ -882,25 +1699,116 @@ async function hasOwnedReadinessEvidence(
|
|
|
882
1699
|
);
|
|
883
1700
|
}
|
|
884
1701
|
|
|
1702
|
+
type LifecycleFileCapture = {
|
|
1703
|
+
bytes: Buffer;
|
|
1704
|
+
identity: { dev: bigint; ino: bigint; size: bigint; mtimeNs: bigint; sha256: string };
|
|
1705
|
+
digest: string;
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
function captureLifecycleFile(file: string, requireRegular = false, bounded = false): LifecycleFileCapture | undefined {
|
|
1709
|
+
let descriptor: number | undefined;
|
|
1710
|
+
try {
|
|
1711
|
+
const preflight = bounded ? fsSync.lstatSync(file, { bigint: true }) : undefined;
|
|
1712
|
+
if (
|
|
1713
|
+
preflight &&
|
|
1714
|
+
(!preflight.isFile() || preflight.size === 0n || preflight.size > BigInt(MAX_LIFECYCLE_METADATA_BYTES))
|
|
1715
|
+
) {
|
|
1716
|
+
if (requireRegular) throw new Error("Lifecycle metadata is not a bounded regular file.");
|
|
1717
|
+
return undefined;
|
|
1718
|
+
}
|
|
1719
|
+
descriptor = fsSync.openSync(file, fsSync.constants.O_RDONLY | fsSync.constants.O_NOFOLLOW);
|
|
1720
|
+
const stat = fsSync.fstatSync(descriptor, { bigint: true });
|
|
1721
|
+
if (
|
|
1722
|
+
!stat.isFile() ||
|
|
1723
|
+
(bounded &&
|
|
1724
|
+
(stat.size === 0n ||
|
|
1725
|
+
stat.size > BigInt(MAX_LIFECYCLE_METADATA_BYTES) ||
|
|
1726
|
+
!preflight ||
|
|
1727
|
+
stat.dev !== preflight.dev ||
|
|
1728
|
+
stat.ino !== preflight.ino))
|
|
1729
|
+
) {
|
|
1730
|
+
if (requireRegular) throw new Error("Lifecycle cleanup candidate is not an exact bounded regular file.");
|
|
1731
|
+
return undefined;
|
|
1732
|
+
}
|
|
1733
|
+
const bytes = fsSync.readFileSync(descriptor);
|
|
1734
|
+
const current = fsSync.fstatSync(descriptor, { bigint: true });
|
|
1735
|
+
if (
|
|
1736
|
+
!current.isFile() ||
|
|
1737
|
+
current.dev !== stat.dev ||
|
|
1738
|
+
current.ino !== stat.ino ||
|
|
1739
|
+
current.size !== stat.size ||
|
|
1740
|
+
current.mtimeNs !== stat.mtimeNs
|
|
1741
|
+
)
|
|
1742
|
+
return undefined;
|
|
1743
|
+
return {
|
|
1744
|
+
bytes,
|
|
1745
|
+
identity: {
|
|
1746
|
+
dev: stat.dev,
|
|
1747
|
+
ino: stat.ino,
|
|
1748
|
+
size: stat.size,
|
|
1749
|
+
mtimeNs: stat.mtimeNs,
|
|
1750
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1751
|
+
},
|
|
1752
|
+
digest: createHash("sha256").update(bytes).digest("hex"),
|
|
1753
|
+
};
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
1756
|
+
throw error;
|
|
1757
|
+
} finally {
|
|
1758
|
+
if (descriptor !== undefined) fsSync.closeSync(descriptor);
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
885
1762
|
async function removeOwnedLifecycleArtifacts(root: string, id: string, expected: EffectMarker): Promise<boolean> {
|
|
886
1763
|
const marker = await readEffectMarker(lifecycleMarkerPath(root, id));
|
|
887
1764
|
if (!marker || !sameEffectMarker(marker, expected)) return false;
|
|
888
1765
|
const endpointPath = path.join(root, "sdk", `${id}.json`);
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1766
|
+
const plannedEndpointPath = path.join(
|
|
1767
|
+
path.dirname(endpointPath),
|
|
1768
|
+
`.gjc-delete-endpoint-${expected.effectMarker}-${path.basename(endpointPath)}`,
|
|
1769
|
+
);
|
|
1770
|
+
const retryEndpointPath = path.join(
|
|
1771
|
+
path.dirname(endpointPath),
|
|
1772
|
+
`.gjc-delete-endpoint-retry-${expected.effectMarker}-${path.basename(endpointPath)}`,
|
|
1773
|
+
);
|
|
1774
|
+
const finalEndpointPath = path.join(
|
|
1775
|
+
path.dirname(endpointPath),
|
|
1776
|
+
`.gjc-delete-endpoint-final-${expected.effectMarker}-${path.basename(endpointPath)}`,
|
|
1777
|
+
);
|
|
1778
|
+
const endpointSource = [endpointPath, plannedEndpointPath, retryEndpointPath, finalEndpointPath].find(candidate => {
|
|
1779
|
+
try {
|
|
1780
|
+
return fsSync.lstatSync(candidate).isFile();
|
|
1781
|
+
} catch {
|
|
1782
|
+
return false;
|
|
1783
|
+
}
|
|
1784
|
+
});
|
|
1785
|
+
const endpoint = endpointSource ? captureLifecycleFile(endpointSource) : undefined;
|
|
1786
|
+
if (endpoint && endpointSource) {
|
|
1787
|
+
let parsed: { pid?: unknown };
|
|
1788
|
+
try {
|
|
1789
|
+
parsed = parseLifecycleJson(endpoint.bytes) as { pid?: unknown };
|
|
1790
|
+
} catch {
|
|
1791
|
+
return false;
|
|
1792
|
+
}
|
|
1793
|
+
if (parsed.pid !== expected.pid || !hasObservedProcessExit(expected.pid)) return false;
|
|
1794
|
+
if (createHash("sha256").update(endpoint.bytes).digest("hex") !== endpoint.digest) return false;
|
|
1795
|
+
const endpointRemoval = exactUnlinkLifecycleFile(
|
|
1796
|
+
endpointSource,
|
|
1797
|
+
endpoint.identity,
|
|
1798
|
+
endpointSource === endpointPath
|
|
1799
|
+
? plannedEndpointPath
|
|
1800
|
+
: endpointSource === plannedEndpointPath
|
|
1801
|
+
? retryEndpointPath
|
|
1802
|
+
: finalEndpointPath,
|
|
1803
|
+
);
|
|
1804
|
+
if (!endpointRemoval.ok) return false;
|
|
895
1805
|
}
|
|
896
|
-
if (!(await endpointRemoved(root, id))) return false;
|
|
897
1806
|
const currentMarker = await readEffectMarker(lifecycleMarkerPath(root, id));
|
|
898
1807
|
if (!currentMarker || !sameEffectMarker(currentMarker, expected)) return false;
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
}
|
|
1808
|
+
const readyPath = lifecycleReadyPath(root, id);
|
|
1809
|
+
const ready = captureLifecycleFile(readyPath, true, true);
|
|
1810
|
+
if (ready && createHash("sha256").update(ready.bytes).digest("hex") !== ready.digest) return false;
|
|
1811
|
+
// Readiness mutation is deferred to the same ledger-backed cleanup transaction.
|
|
904
1812
|
return true;
|
|
905
1813
|
}
|
|
906
1814
|
|
|
@@ -1019,7 +1927,7 @@ async function terminateSpawnedChild(
|
|
|
1019
1927
|
expected &&
|
|
1020
1928
|
!broker.index.hasHostRegistrationForLifecycle(id, pid, expected.effectMarker)
|
|
1021
1929
|
)
|
|
1022
|
-
return
|
|
1930
|
+
return true;
|
|
1023
1931
|
const registeredBeforeTermination =
|
|
1024
1932
|
rollbackGeneration === undefined || rollbackGeneration === null
|
|
1025
1933
|
? undefined
|
|
@@ -1291,7 +2199,7 @@ async function launchInput(
|
|
|
1291
2199
|
if (!requested) return fail("invalid_input", "sessionId is required to resume a saved session.");
|
|
1292
2200
|
const savedPath = text(input.sessionPath);
|
|
1293
2201
|
if (!savedPath) return fail("invalid_input", "sessionPath is required to resume a saved session.");
|
|
1294
|
-
const saved = validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
|
|
2202
|
+
const saved = await validateSavedTranscript(broker, cwd, savedPath, requested, "Saved");
|
|
1295
2203
|
if ("ok" in saved) return saved;
|
|
1296
2204
|
return {
|
|
1297
2205
|
id: requested,
|
|
@@ -1310,7 +2218,7 @@ async function launchInput(
|
|
|
1310
2218
|
const sourceSessionPath = text(input.sourceSessionPath) ?? text(input.sourcePath) ?? text(input.sessionPath);
|
|
1311
2219
|
if (!sourceSessionId && !sourceSessionPath)
|
|
1312
2220
|
return fail("invalid_input", "sourceSessionId or sourceSessionPath is required to fork a session.");
|
|
1313
|
-
const source = validateSavedTranscript(broker, sourceCwd, sourceSessionPath, sourceSessionId, "Source");
|
|
2221
|
+
const source = await validateSavedTranscript(broker, sourceCwd, sourceSessionPath, sourceSessionId, "Source");
|
|
1314
2222
|
if ("ok" in source) return source;
|
|
1315
2223
|
return {
|
|
1316
2224
|
id: randomUUID(),
|
|
@@ -1326,22 +2234,149 @@ async function launchInput(
|
|
|
1326
2234
|
};
|
|
1327
2235
|
}
|
|
1328
2236
|
|
|
1329
|
-
function within(root: string, candidate: string): boolean {
|
|
1330
|
-
const relative = path.relative(root, candidate);
|
|
1331
|
-
return relative !== "" && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
|
|
1332
|
-
}
|
|
1333
2237
|
type ValidatedDelete = {
|
|
1334
2238
|
storage: FileSessionStorage;
|
|
1335
2239
|
target: VerifiedSessionDeleteTarget;
|
|
1336
2240
|
metadataRoot: string;
|
|
1337
2241
|
};
|
|
2242
|
+
function cleanupIdentity(
|
|
2243
|
+
identity: BrokerCleanupEvidence["transcriptIdentity"],
|
|
2244
|
+
allowEmptySha256 = false,
|
|
2245
|
+
): SessionStorageFileIdentity | undefined {
|
|
2246
|
+
if (
|
|
2247
|
+
!identity ||
|
|
2248
|
+
!/^[0-9]+$/.test(identity.dev) ||
|
|
2249
|
+
!/^[0-9]+$/.test(identity.ino) ||
|
|
2250
|
+
!Number.isSafeInteger(identity.size) ||
|
|
2251
|
+
identity.size < 0 ||
|
|
2252
|
+
!/^[0-9]+$/.test(identity.mtimeNs) ||
|
|
2253
|
+
(!allowEmptySha256 && !/^[a-f0-9]{64}$/.test(identity.sha256)) ||
|
|
2254
|
+
(allowEmptySha256 && identity.sha256 !== "" && !/^[a-f0-9]{64}$/.test(identity.sha256))
|
|
2255
|
+
)
|
|
2256
|
+
return undefined;
|
|
2257
|
+
return {
|
|
2258
|
+
dev: BigInt(identity.dev),
|
|
2259
|
+
ino: BigInt(identity.ino),
|
|
2260
|
+
size: identity.size,
|
|
2261
|
+
mtimeNs: BigInt(identity.mtimeNs),
|
|
2262
|
+
sha256: identity.sha256,
|
|
2263
|
+
};
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function replayDeleteTarget(cleanup: CleanupEvidence): ValidatedDelete | BrokerResponse {
|
|
2267
|
+
const transcriptIdentity = cleanupIdentity(cleanup.transcriptIdentity);
|
|
2268
|
+
if (
|
|
2269
|
+
(cleanup.phase !== "artifacts" && cleanup.phase !== "transcript") ||
|
|
2270
|
+
!cleanup.sessionId ||
|
|
2271
|
+
!isCanonicalSessionId(cleanup.sessionId) ||
|
|
2272
|
+
!cleanup.sessionsRoot ||
|
|
2273
|
+
!cleanup.transcriptPath ||
|
|
2274
|
+
!cleanup.cwd ||
|
|
2275
|
+
!cleanup.metadataRoot ||
|
|
2276
|
+
!transcriptIdentity
|
|
2277
|
+
) {
|
|
2278
|
+
return fail("terminal_uncertain", "Cleanup replay lacks a complete ledger-bound deletion target.");
|
|
2279
|
+
}
|
|
2280
|
+
const artifactsIdentity = cleanupIdentity(cleanup.artifactsIdentity, true);
|
|
2281
|
+
const artifactTreeIdentity =
|
|
2282
|
+
cleanup.artifactsRemoved === true || !cleanup.artifactTree
|
|
2283
|
+
? undefined
|
|
2284
|
+
: cleanupIdentity(cleanup.artifactTree.identity, true);
|
|
2285
|
+
if (cleanup.artifactsRemoved !== true && cleanup.artifactTree && !artifactTreeIdentity)
|
|
2286
|
+
return fail("terminal_uncertain", "Artifact tree cleanup lacks its ledger-bound identity.");
|
|
2287
|
+
|
|
2288
|
+
if (cleanup.detachedArtifactsPath && !artifactsIdentity)
|
|
2289
|
+
return fail("terminal_uncertain", "Detached artifact cleanup lacks its ledger-bound identity.");
|
|
2290
|
+
const plannedArtifactsPath = cleanup.plannedArtifactsPath;
|
|
2291
|
+
const plannedTranscriptPath = cleanup.plannedTranscriptPath;
|
|
2292
|
+
if (
|
|
2293
|
+
(plannedArtifactsPath &&
|
|
2294
|
+
(path.dirname(plannedArtifactsPath) !== path.dirname(cleanup.transcriptPath) ||
|
|
2295
|
+
!path.basename(plannedArtifactsPath).startsWith(".gjc-delete-"))) ||
|
|
2296
|
+
(plannedTranscriptPath &&
|
|
2297
|
+
(path.dirname(plannedTranscriptPath) !== path.dirname(cleanup.transcriptPath) ||
|
|
2298
|
+
!path.basename(plannedTranscriptPath).startsWith(".gjc-delete-"))) ||
|
|
2299
|
+
(cleanup.artifactsRemoved !== true &&
|
|
2300
|
+
cleanup.artifactTree &&
|
|
2301
|
+
(path.dirname(cleanup.artifactTree.plannedPath) !== path.dirname(cleanup.transcriptPath) ||
|
|
2302
|
+
!path.basename(cleanup.artifactTree.plannedPath).startsWith(".gjc-delete-") ||
|
|
2303
|
+
(cleanup.artifactTree.detachedPath !== undefined &&
|
|
2304
|
+
path.dirname(cleanup.artifactTree.detachedPath) !== path.dirname(cleanup.transcriptPath))))
|
|
2305
|
+
)
|
|
2306
|
+
return fail("terminal_uncertain", "Cleanup replay has invalid preauthorized quarantine paths.");
|
|
2307
|
+
if (
|
|
2308
|
+
cleanup.phase === "transcript" &&
|
|
2309
|
+
cleanup.artifactsRemoved === true &&
|
|
2310
|
+
![cleanup.transcriptPath, cleanup.detachedTranscriptPath, plannedTranscriptPath].some(
|
|
2311
|
+
candidate => typeof candidate === "string" && fsSync.existsSync(candidate),
|
|
2312
|
+
)
|
|
2313
|
+
)
|
|
2314
|
+
return { ok: true, result: { sessionId: cleanup.sessionId } };
|
|
2315
|
+
const artifactRemovingPath =
|
|
2316
|
+
cleanup.artifactsRemoved === true
|
|
2317
|
+
? undefined
|
|
2318
|
+
: cleanup.artifactTree
|
|
2319
|
+
? `${cleanup.artifactTree.plannedPath}.removing`
|
|
2320
|
+
: plannedArtifactsPath
|
|
2321
|
+
? `${plannedArtifactsPath}.removing`
|
|
2322
|
+
: undefined;
|
|
2323
|
+
const recoveredDetachedArtifactsPath =
|
|
2324
|
+
cleanup.detachedArtifactsPath && fsSync.existsSync(cleanup.detachedArtifactsPath)
|
|
2325
|
+
? cleanup.detachedArtifactsPath
|
|
2326
|
+
: artifactRemovingPath && fsSync.existsSync(artifactRemovingPath)
|
|
2327
|
+
? artifactRemovingPath
|
|
2328
|
+
: plannedArtifactsPath &&
|
|
2329
|
+
!fsSync.existsSync(cleanup.transcriptPath.slice(0, -6)) &&
|
|
2330
|
+
fsSync.existsSync(plannedArtifactsPath)
|
|
2331
|
+
? plannedArtifactsPath
|
|
2332
|
+
: undefined;
|
|
2333
|
+
const recoveredDetachedTranscriptPath =
|
|
2334
|
+
cleanup.detachedTranscriptPath && fsSync.existsSync(cleanup.detachedTranscriptPath)
|
|
2335
|
+
? cleanup.detachedTranscriptPath
|
|
2336
|
+
: plannedTranscriptPath &&
|
|
2337
|
+
!fsSync.existsSync(cleanup.transcriptPath) &&
|
|
2338
|
+
fsSync.existsSync(plannedTranscriptPath)
|
|
2339
|
+
? plannedTranscriptPath
|
|
2340
|
+
: undefined;
|
|
2341
|
+
return {
|
|
2342
|
+
storage: new FileSessionStorage(),
|
|
2343
|
+
target: {
|
|
2344
|
+
sessionsRoot: cleanup.sessionsRoot,
|
|
2345
|
+
transcriptPath: cleanup.transcriptPath,
|
|
2346
|
+
sessionId: cleanup.sessionId,
|
|
2347
|
+
cwd: cleanup.cwd,
|
|
2348
|
+
transcriptIdentity,
|
|
2349
|
+
...(cleanup.artifactsRemoved === true ? { artifactsRemoved: true } : {}),
|
|
2350
|
+
...(cleanup.artifactsRemoved !== true && artifactsIdentity
|
|
2351
|
+
? { expectedArtifactsIdentity: artifactsIdentity }
|
|
2352
|
+
: {}),
|
|
2353
|
+
...(cleanup.artifactsRemoved !== true && recoveredDetachedArtifactsPath
|
|
2354
|
+
? { detachedArtifactsPath: recoveredDetachedArtifactsPath }
|
|
2355
|
+
: {}),
|
|
2356
|
+
...(recoveredDetachedTranscriptPath ? { detachedTranscriptPath: recoveredDetachedTranscriptPath } : {}),
|
|
2357
|
+
...(plannedArtifactsPath ? { plannedArtifactsPath } : {}),
|
|
2358
|
+
...(plannedTranscriptPath ? { plannedTranscriptPath } : {}),
|
|
2359
|
+
...(cleanup.artifactsRemoved !== true && cleanup.artifactTree && artifactTreeIdentity
|
|
2360
|
+
? {
|
|
2361
|
+
expectedArtifactsIdentity: artifactTreeIdentity,
|
|
2362
|
+
expectedArtifactsTree: cleanup.artifactTree.snapshot,
|
|
2363
|
+
detachedArtifactsPath: cleanup.artifactTree.detachedPath ?? recoveredDetachedArtifactsPath,
|
|
2364
|
+
plannedArtifactsPath: cleanup.artifactTree.plannedPath,
|
|
2365
|
+
}
|
|
2366
|
+
: {}),
|
|
2367
|
+
},
|
|
2368
|
+
metadataRoot: cleanup.metadataRoot,
|
|
2369
|
+
};
|
|
2370
|
+
}
|
|
1338
2371
|
|
|
1339
2372
|
async function validateDeletePath(
|
|
1340
2373
|
broker: Broker,
|
|
1341
2374
|
input: Input,
|
|
1342
2375
|
id: string,
|
|
1343
2376
|
record: { locator: { repo: string; stateRoot: string } } | undefined,
|
|
2377
|
+
cleanup?: CleanupEvidence,
|
|
1344
2378
|
): Promise<ValidatedDelete | BrokerResponse> {
|
|
2379
|
+
if (cleanup) return replayDeleteTarget(cleanup);
|
|
1345
2380
|
const sessionPath = text(input.sessionPath);
|
|
1346
2381
|
const cwd = lifecycleCwd(input);
|
|
1347
2382
|
if (!sessionPath || !cwd)
|
|
@@ -1355,62 +2390,48 @@ async function validateDeletePath(
|
|
|
1355
2390
|
)
|
|
1356
2391
|
return fail("invalid_input", "session.delete locator does not match the indexed session.");
|
|
1357
2392
|
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
if (
|
|
1365
|
-
return fail("invalid_input", "session.delete path is
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
} catch {
|
|
1370
|
-
return fail("not_found", "Requested saved session does not exist.");
|
|
1371
|
-
}
|
|
1372
|
-
let resolved: string;
|
|
1373
|
-
try {
|
|
1374
|
-
resolved = await fs.realpath(candidate);
|
|
1375
|
-
} catch {
|
|
1376
|
-
return fail("not_found", "Requested saved session does not exist.");
|
|
1377
|
-
}
|
|
1378
|
-
if (!within(storageRoot, resolved))
|
|
1379
|
-
return fail("invalid_input", "session.delete path resolves outside the configured session storage root.");
|
|
2393
|
+
const inventory = await managedCandidates(broker, cwd, "Saved");
|
|
2394
|
+
if ("ok" in inventory) return inventory;
|
|
2395
|
+
const candidatePath = path.resolve(sessionPath);
|
|
2396
|
+
const matches = inventory.candidates.filter(
|
|
2397
|
+
candidate => candidate.path === candidatePath && candidate.sessionId === id,
|
|
2398
|
+
);
|
|
2399
|
+
if (matches.length !== 1)
|
|
2400
|
+
return fail("invalid_input", "session.delete path is not an owned managed session for the configured cwd.");
|
|
2401
|
+
const match = matches[0]!;
|
|
2402
|
+
if (inventory.migrationPolicy === "disabled" && match.provenance === "legacy")
|
|
2403
|
+
return fail("legacy_migration_disabled", "Saved legacy session migration is disabled for this workspace.");
|
|
1380
2404
|
|
|
1381
2405
|
const storage = new FileSessionStorage();
|
|
1382
|
-
let snapshot:
|
|
2406
|
+
let snapshot: SessionStorageSnapshot;
|
|
1383
2407
|
try {
|
|
1384
|
-
snapshot = storage.readSnapshotSync(
|
|
2408
|
+
snapshot = storage.readSnapshotSync(candidatePath);
|
|
1385
2409
|
} catch {
|
|
1386
2410
|
return fail("not_found", "Requested saved session does not exist or cannot be read.");
|
|
1387
2411
|
}
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
return fail("invalid_input", "session.delete transcript cwd does not match the configured cwd.");
|
|
1398
|
-
const transcriptCwd = header.cwd;
|
|
1399
|
-
const headerCwd = await fs.realpath(transcriptCwd).catch(() => path.resolve(transcriptCwd));
|
|
1400
|
-
const requestedCwd = await fs.realpath(cwd).catch(() => cwd);
|
|
1401
|
-
if (headerCwd !== requestedCwd)
|
|
1402
|
-
return fail("invalid_input", "session.delete transcript cwd does not match the configured cwd.");
|
|
1403
|
-
} catch {
|
|
1404
|
-
return fail("invalid_input", "Requested saved session has an invalid header.");
|
|
1405
|
-
}
|
|
2412
|
+
const digest = createHash("sha256").update(snapshot.bytes).digest("hex");
|
|
2413
|
+
if (
|
|
2414
|
+
snapshot.stat.dev !== match.identity.dev ||
|
|
2415
|
+
snapshot.stat.ino !== match.identity.ino ||
|
|
2416
|
+
snapshot.stat.size !== match.identity.size ||
|
|
2417
|
+
snapshot.stat.mtimeNs !== match.identity.mtimeNs ||
|
|
2418
|
+
digest !== match.identity.sha256
|
|
2419
|
+
)
|
|
2420
|
+
return fail("invalid_input", "session.delete session changed after managed ownership was verified.");
|
|
1406
2421
|
return {
|
|
1407
2422
|
storage,
|
|
1408
2423
|
target: {
|
|
1409
|
-
sessionsRoot:
|
|
1410
|
-
transcriptPath:
|
|
2424
|
+
sessionsRoot: inventory.scope.sessionsRoot,
|
|
2425
|
+
transcriptPath: candidatePath,
|
|
1411
2426
|
sessionId: id,
|
|
1412
2427
|
cwd,
|
|
1413
|
-
transcriptIdentity: {
|
|
2428
|
+
transcriptIdentity: {
|
|
2429
|
+
dev: snapshot.stat.dev,
|
|
2430
|
+
ino: snapshot.stat.ino,
|
|
2431
|
+
size: snapshot.stat.size,
|
|
2432
|
+
mtimeNs: snapshot.stat.mtimeNs,
|
|
2433
|
+
sha256: digest,
|
|
2434
|
+
},
|
|
1414
2435
|
},
|
|
1415
2436
|
metadataRoot: requestedRoot,
|
|
1416
2437
|
};
|
|
@@ -1530,8 +2551,9 @@ async function executeLifecycleResponse(
|
|
|
1530
2551
|
operation: string,
|
|
1531
2552
|
input: Input,
|
|
1532
2553
|
identity: string,
|
|
2554
|
+
cleanup?: CleanupEvidence,
|
|
1533
2555
|
): Promise<BrokerResponse> {
|
|
1534
|
-
const requestedSessionId = sessionId(input);
|
|
2556
|
+
const requestedSessionId = cleanup && operation === "session.delete" ? cleanup.sessionId : sessionId(input);
|
|
1535
2557
|
if (requestedSessionId !== undefined && !isCanonicalSessionId(requestedSessionId))
|
|
1536
2558
|
return fail("invalid_input", "sessionId must be a canonical safe identifier.");
|
|
1537
2559
|
const requestedSourceSessionId = text(input.sourceSessionId) ?? text(input.sourceId);
|
|
@@ -1785,7 +2807,7 @@ async function executeLifecycleResponse(
|
|
|
1785
2807
|
};
|
|
1786
2808
|
}
|
|
1787
2809
|
|
|
1788
|
-
const id = sessionId(input);
|
|
2810
|
+
const id = cleanup && operation === "session.delete" ? cleanup.sessionId : sessionId(input);
|
|
1789
2811
|
if (!id) return fail("invalid_input", "sessionId is required.");
|
|
1790
2812
|
if (!isCanonicalSessionId(id)) return fail("invalid_input", "sessionId must be a canonical safe identifier.");
|
|
1791
2813
|
await broker.index.refresh();
|
|
@@ -1914,12 +2936,102 @@ async function executeLifecycleResponse(
|
|
|
1914
2936
|
if (record?.terminalUncertain)
|
|
1915
2937
|
return fail("terminal_uncertain", "Session ownership is uncertain and cannot be deleted safely.");
|
|
1916
2938
|
if (record?.live) return fail("live_session", "Refusing to delete a live session; close it first.");
|
|
1917
|
-
const validated = await validateDeletePath(broker, input, id, record);
|
|
2939
|
+
const validated = await validateDeletePath(broker, input, id, record, cleanup);
|
|
1918
2940
|
if ("ok" in validated) return validated;
|
|
1919
|
-
|
|
1920
|
-
|
|
2941
|
+
const metadataPreflight = preflightLifecycleDeleteMetadata(validated.metadataRoot, id, record, value =>
|
|
2942
|
+
processIncarnationForBroker(broker, value),
|
|
2943
|
+
);
|
|
2944
|
+
if ("ok" in metadataPreflight) return metadataPreflight;
|
|
2945
|
+
const metadataCleanup = metadataPreflight.cleanup;
|
|
2946
|
+
let cleanupTarget: VerifiedSessionDeleteTarget = {
|
|
2947
|
+
...validated.target,
|
|
2948
|
+
...(validated.target.plannedArtifactsPath &&
|
|
2949
|
+
validated.target.detachedArtifactsPath !== validated.target.plannedArtifactsPath
|
|
2950
|
+
? {}
|
|
2951
|
+
: {
|
|
2952
|
+
plannedArtifactsPath: path.join(
|
|
2953
|
+
path.dirname(validated.target.transcriptPath),
|
|
2954
|
+
`.gjc-delete-${randomUUID()}-artifacts`,
|
|
2955
|
+
),
|
|
2956
|
+
}),
|
|
2957
|
+
...(validated.target.plannedTranscriptPath &&
|
|
2958
|
+
validated.target.detachedTranscriptPath !== validated.target.plannedTranscriptPath
|
|
2959
|
+
? {}
|
|
2960
|
+
: {
|
|
2961
|
+
plannedTranscriptPath: path.join(
|
|
2962
|
+
path.dirname(validated.target.transcriptPath),
|
|
2963
|
+
`.gjc-delete-${randomUUID()}-transcript`,
|
|
2964
|
+
),
|
|
2965
|
+
}),
|
|
2966
|
+
};
|
|
2967
|
+
if (!cleanupTarget.artifactsRemoved && !cleanupTarget.expectedArtifactsIdentity) {
|
|
2968
|
+
const artifactsPath = cleanupTarget.transcriptPath.slice(0, -6);
|
|
2969
|
+
try {
|
|
2970
|
+
const stat = fsSync.lstatSync(artifactsPath, { bigint: true });
|
|
2971
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
2972
|
+
return fail("terminal_uncertain", "Artifact cleanup target is not an exact directory.");
|
|
2973
|
+
validateManagedArtifactTree(artifactsPath);
|
|
2974
|
+
const tree = native.snapshotDirectoryTree(artifactsPath);
|
|
2975
|
+
if (!tree.ok || !tree.snapshot)
|
|
2976
|
+
return fail(
|
|
2977
|
+
"terminal_uncertain",
|
|
2978
|
+
`Artifact tree authority could not be captured: ${tree.code ?? "unknown"}`,
|
|
2979
|
+
);
|
|
2980
|
+
cleanupTarget = {
|
|
2981
|
+
...cleanupTarget,
|
|
2982
|
+
expectedArtifactsIdentity: {
|
|
2983
|
+
dev: stat.dev,
|
|
2984
|
+
ino: stat.ino,
|
|
2985
|
+
size: Number(stat.size),
|
|
2986
|
+
mtimeNs: stat.mtimeNs,
|
|
2987
|
+
sha256: "",
|
|
2988
|
+
},
|
|
2989
|
+
expectedArtifactsTree: tree.snapshot,
|
|
2990
|
+
};
|
|
2991
|
+
} catch (error) {
|
|
2992
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
const preauthorizedCleanup: CleanupEvidence = {
|
|
2996
|
+
phase: cleanupTarget.artifactsRemoved ? "transcript" : "artifacts",
|
|
2997
|
+
sessionId: cleanupTarget.sessionId,
|
|
2998
|
+
sessionsRoot: cleanupTarget.sessionsRoot,
|
|
2999
|
+
transcriptPath: cleanupTarget.transcriptPath,
|
|
3000
|
+
cwd: cleanupTarget.cwd,
|
|
3001
|
+
...(cleanupTarget.artifactsRemoved ? { artifactsRemoved: true } : {}),
|
|
3002
|
+
metadataRoot: validated.metadataRoot,
|
|
3003
|
+
transcriptIdentity: serializeCleanupIdentity(cleanupTarget.transcriptIdentity),
|
|
3004
|
+
...(cleanupTarget.expectedArtifactsIdentity
|
|
3005
|
+
? { artifactsIdentity: serializeCleanupIdentity(cleanupTarget.expectedArtifactsIdentity) }
|
|
3006
|
+
: {}),
|
|
3007
|
+
...(cleanupTarget.expectedArtifactsIdentity && cleanupTarget.plannedArtifactsPath
|
|
3008
|
+
? {
|
|
3009
|
+
artifactTree: {
|
|
3010
|
+
identity: serializeCleanupIdentity(cleanupTarget.expectedArtifactsIdentity),
|
|
3011
|
+
snapshot: cleanupTarget.expectedArtifactsTree!,
|
|
3012
|
+
plannedPath: cleanupTarget.plannedArtifactsPath,
|
|
3013
|
+
...(cleanupTarget.detachedArtifactsPath
|
|
3014
|
+
? { detachedPath: cleanupTarget.detachedArtifactsPath }
|
|
3015
|
+
: {}),
|
|
3016
|
+
},
|
|
3017
|
+
}
|
|
3018
|
+
: {}),
|
|
3019
|
+
|
|
3020
|
+
...(cleanupTarget.plannedArtifactsPath ? { plannedArtifactsPath: cleanupTarget.plannedArtifactsPath } : {}),
|
|
3021
|
+
...(cleanupTarget.plannedTranscriptPath ? { plannedTranscriptPath: cleanupTarget.plannedTranscriptPath } : {}),
|
|
3022
|
+
};
|
|
3023
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
3024
|
+
intendedSessionId: id,
|
|
3025
|
+
effectMarker: randomUUID(),
|
|
3026
|
+
response: fail(
|
|
3027
|
+
"cleanup_pending",
|
|
3028
|
+
"Saved session cleanup is preauthorized for durable reconciliation.",
|
|
3029
|
+
preauthorizedCleanup,
|
|
3030
|
+
),
|
|
3031
|
+
});
|
|
3032
|
+
let deleted: VerifiedSessionDeleteResult;
|
|
1921
3033
|
try {
|
|
1922
|
-
deleted = await validated.storage.deleteSessionVerified(
|
|
3034
|
+
deleted = await validated.storage.deleteSessionVerified(cleanupTarget);
|
|
1923
3035
|
} catch (error) {
|
|
1924
3036
|
if (error instanceof SessionDeleteVerificationError)
|
|
1925
3037
|
return fail(
|
|
@@ -1931,27 +3043,95 @@ async function executeLifecycleResponse(
|
|
|
1931
3043
|
`Unable to delete saved session artifacts: ${error instanceof Error ? error.message : String(error)}`,
|
|
1932
3044
|
);
|
|
1933
3045
|
}
|
|
3046
|
+
if (deleted.kind === "artifacts_removed") {
|
|
3047
|
+
const transcriptPhaseCleanup = {
|
|
3048
|
+
...preauthorizedCleanup,
|
|
3049
|
+
phase: "transcript" as const,
|
|
3050
|
+
artifactsRemoved: true,
|
|
3051
|
+
...(preauthorizedCleanup.artifactTree
|
|
3052
|
+
? { artifactTree: { ...preauthorizedCleanup.artifactTree, completed: true as const } }
|
|
3053
|
+
: {}),
|
|
3054
|
+
};
|
|
3055
|
+
|
|
3056
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
3057
|
+
intendedSessionId: id,
|
|
3058
|
+
response: fail(
|
|
3059
|
+
"cleanup_pending",
|
|
3060
|
+
"Saved session artifacts were removed; transcript cleanup is preauthorized.",
|
|
3061
|
+
transcriptPhaseCleanup,
|
|
3062
|
+
),
|
|
3063
|
+
});
|
|
3064
|
+
deleted = await validated.storage.deleteSessionVerified({
|
|
3065
|
+
...cleanupTarget,
|
|
3066
|
+
expectedArtifactsIdentity: undefined,
|
|
3067
|
+
detachedArtifactsPath: undefined,
|
|
3068
|
+
artifactsRemoved: true,
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
1934
3071
|
if (deleted.kind === "cleanup_pending")
|
|
1935
3072
|
return fail(
|
|
1936
3073
|
"cleanup_pending",
|
|
1937
3074
|
`Saved session cleanup is pending in ${deleted.phase}: ${deleted.error.message}`,
|
|
1938
3075
|
{
|
|
1939
3076
|
phase: deleted.phase,
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
3077
|
+
sessionId: validated.target.sessionId,
|
|
3078
|
+
sessionsRoot: validated.target.sessionsRoot,
|
|
3079
|
+
transcriptPath: validated.target.transcriptPath,
|
|
3080
|
+
cwd: validated.target.cwd,
|
|
3081
|
+
metadataRoot: validated.metadataRoot,
|
|
3082
|
+
transcriptIdentity: serializeCleanupIdentity(deleted.transcriptIdentity),
|
|
1944
3083
|
...(deleted.phase === "artifacts" && deleted.artifactsIdentity
|
|
3084
|
+
? { artifactsIdentity: serializeCleanupIdentity(deleted.artifactsIdentity) }
|
|
3085
|
+
: {}),
|
|
3086
|
+
...(deleted.phase === "artifacts" && deleted.artifactsIdentity && cleanupTarget.plannedArtifactsPath
|
|
1945
3087
|
? {
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
3088
|
+
artifactTree: {
|
|
3089
|
+
identity: serializeCleanupIdentity(deleted.artifactsIdentity),
|
|
3090
|
+
snapshot: deleted.artifactsTree,
|
|
3091
|
+
plannedPath: cleanupTarget.plannedArtifactsPath,
|
|
3092
|
+
...(deleted.detachedArtifactsPath ? { detachedPath: deleted.detachedArtifactsPath } : {}),
|
|
1949
3093
|
},
|
|
1950
3094
|
}
|
|
1951
3095
|
: {}),
|
|
3096
|
+
...(deleted.phase === "artifacts" ? { detachedArtifactsPath: deleted.detachedArtifactsPath } : {}),
|
|
3097
|
+
...(deleted.phase === "transcript" && deleted.detachedTranscriptPath
|
|
3098
|
+
? { detachedTranscriptPath: deleted.detachedTranscriptPath }
|
|
3099
|
+
: {}),
|
|
3100
|
+
...(deleted.phase === "transcript" ? { artifactsRemoved: true } : {}),
|
|
3101
|
+
...(deleted.phase === "transcript" &&
|
|
3102
|
+
cleanupTarget.plannedArtifactsPath &&
|
|
3103
|
+
cleanupTarget.expectedArtifactsIdentity
|
|
3104
|
+
? {
|
|
3105
|
+
artifactTree: {
|
|
3106
|
+
identity: serializeCleanupIdentity(cleanupTarget.expectedArtifactsIdentity),
|
|
3107
|
+
snapshot: cleanupTarget.expectedArtifactsTree!,
|
|
3108
|
+
plannedPath: cleanupTarget.plannedArtifactsPath,
|
|
3109
|
+
completed: true as const,
|
|
3110
|
+
},
|
|
3111
|
+
}
|
|
3112
|
+
: {}),
|
|
3113
|
+
...(cleanupTarget.plannedArtifactsPath
|
|
3114
|
+
? { plannedArtifactsPath: cleanupTarget.plannedArtifactsPath }
|
|
3115
|
+
: {}),
|
|
3116
|
+
...(cleanupTarget.plannedTranscriptPath
|
|
3117
|
+
? { plannedTranscriptPath: cleanupTarget.plannedTranscriptPath }
|
|
3118
|
+
: {}),
|
|
1952
3119
|
},
|
|
1953
3120
|
);
|
|
1954
3121
|
|
|
3122
|
+
const completion = { ok: true, result: { sessionId: id } } as const;
|
|
3123
|
+
if (metadataCleanup.lifecycleFiles?.length) {
|
|
3124
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
3125
|
+
intendedSessionId: id,
|
|
3126
|
+
response: fail(
|
|
3127
|
+
"cleanup_pending",
|
|
3128
|
+
"Lifecycle metadata cleanup is preauthorized for durable reconciliation.",
|
|
3129
|
+
metadataCleanup,
|
|
3130
|
+
),
|
|
3131
|
+
});
|
|
3132
|
+
const reconciled = await reconcileLifecycleCleanup(broker, identity, metadataCleanup, completion);
|
|
3133
|
+
if (!reconciled.ok) return reconciled;
|
|
3134
|
+
}
|
|
1955
3135
|
if (record)
|
|
1956
3136
|
await broker.index.append({
|
|
1957
3137
|
type: "session_closed",
|
|
@@ -1960,18 +3140,7 @@ async function executeLifecycleResponse(
|
|
|
1960
3140
|
endpointGeneration: record.endpointGeneration,
|
|
1961
3141
|
pid: record.pid,
|
|
1962
3142
|
});
|
|
1963
|
-
|
|
1964
|
-
await fs.rm(lifecycleMarkerPath(validated.metadataRoot, id), { force: true });
|
|
1965
|
-
} catch (error) {
|
|
1966
|
-
return fail(
|
|
1967
|
-
"cleanup_pending",
|
|
1968
|
-
`Saved session was deleted but lifecycle metadata cleanup is pending: ${
|
|
1969
|
-
error instanceof Error ? error.message : String(error)
|
|
1970
|
-
}`,
|
|
1971
|
-
{ phase: "metadata" },
|
|
1972
|
-
);
|
|
1973
|
-
}
|
|
1974
|
-
return { ok: true, result: { sessionId: id } };
|
|
3143
|
+
return completion;
|
|
1975
3144
|
}
|
|
1976
3145
|
return fail("invalid_input", "Unknown lifecycle operation.");
|
|
1977
3146
|
}
|
|
@@ -2036,6 +3205,35 @@ async function exactCleanupProof(
|
|
|
2036
3205
|
: undefined;
|
|
2037
3206
|
}
|
|
2038
3207
|
|
|
3208
|
+
function validateLifecycleDeleteMetadataBinding(
|
|
3209
|
+
broker: Broker,
|
|
3210
|
+
operation: string,
|
|
3211
|
+
input: Input,
|
|
3212
|
+
identity: string,
|
|
3213
|
+
cleanup: CleanupEvidence,
|
|
3214
|
+
): BrokerResponse | undefined {
|
|
3215
|
+
if (operation !== "session.delete")
|
|
3216
|
+
return fail("terminal_uncertain", "Lifecycle delete metadata cleanup is not authorized for this operation.");
|
|
3217
|
+
const requestedId = sessionId(input);
|
|
3218
|
+
const cwd = lifecycleCwd(input);
|
|
3219
|
+
const requestedRoot = stateRoot(input, cwd);
|
|
3220
|
+
if (
|
|
3221
|
+
!requestedId ||
|
|
3222
|
+
!isCanonicalSessionId(requestedId) ||
|
|
3223
|
+
!cwd ||
|
|
3224
|
+
!requestedRoot ||
|
|
3225
|
+
!hasDefaultStateRoot(cwd, requestedRoot) ||
|
|
3226
|
+
cleanup.sessionId !== requestedId ||
|
|
3227
|
+
!cleanup.metadataRoot ||
|
|
3228
|
+
path.resolve(cleanup.metadataRoot) !== requestedRoot
|
|
3229
|
+
)
|
|
3230
|
+
return fail("terminal_uncertain", "Lifecycle delete metadata cleanup does not match the normalized request.");
|
|
3231
|
+
const recordedRoot = broker.ledger.get(identity)?.effectIntent?.stateRoot;
|
|
3232
|
+
if (recordedRoot && path.resolve(recordedRoot) !== requestedRoot)
|
|
3233
|
+
return fail("terminal_uncertain", "Lifecycle delete metadata cleanup does not match the recorded workspace.");
|
|
3234
|
+
return undefined;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
2039
3237
|
export interface LifecycleExecutionOutcome {
|
|
2040
3238
|
response: BrokerResponse;
|
|
2041
3239
|
durableEffects?: LifecycleDurableEffectsReceipt;
|
|
@@ -2049,8 +3247,59 @@ export async function executeLifecycle(
|
|
|
2049
3247
|
operation: string,
|
|
2050
3248
|
input: Input,
|
|
2051
3249
|
identity: string,
|
|
3250
|
+
cleanup?: CleanupEvidence,
|
|
2052
3251
|
): Promise<LifecycleExecutionOutcome> {
|
|
2053
|
-
|
|
3252
|
+
if (cleanup?.phase === "metadata") {
|
|
3253
|
+
if (operation !== "session.delete")
|
|
3254
|
+
return {
|
|
3255
|
+
response: fail(
|
|
3256
|
+
"terminal_uncertain",
|
|
3257
|
+
"Legacy metadata cleanup is not authorized for this lifecycle operation.",
|
|
3258
|
+
),
|
|
3259
|
+
};
|
|
3260
|
+
const migrated = legacyMetadataCleanupPlan(cleanup);
|
|
3261
|
+
if (!migrated)
|
|
3262
|
+
return {
|
|
3263
|
+
response: fail(
|
|
3264
|
+
"terminal_uncertain",
|
|
3265
|
+
"Legacy metadata cleanup replay lacks immutable identity-bound intent.",
|
|
3266
|
+
),
|
|
3267
|
+
};
|
|
3268
|
+
const binding = validateLifecycleDeleteMetadataBinding(broker, operation, input, identity, migrated);
|
|
3269
|
+
if (binding) return { response: binding };
|
|
3270
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
3271
|
+
response: fail(
|
|
3272
|
+
"cleanup_pending",
|
|
3273
|
+
"Legacy lifecycle metadata cleanup is preauthorized for durable reconciliation.",
|
|
3274
|
+
migrated,
|
|
3275
|
+
),
|
|
3276
|
+
});
|
|
3277
|
+
return {
|
|
3278
|
+
response: await reconcileLifecycleCleanup(broker, identity, migrated, {
|
|
3279
|
+
ok: true,
|
|
3280
|
+
result: { sessionId: migrated.sessionId },
|
|
3281
|
+
}),
|
|
3282
|
+
};
|
|
3283
|
+
}
|
|
3284
|
+
if (cleanup?.phase === "lifecycle") {
|
|
3285
|
+
const shapeValidation = validateLifecycleCleanupShape(cleanup);
|
|
3286
|
+
if (shapeValidation) return { response: shapeValidation };
|
|
3287
|
+
if (cleanup.lifecycleDeleteMetadata === true || operation === "session.delete") {
|
|
3288
|
+
const binding = validateLifecycleDeleteMetadataBinding(broker, operation, input, identity, cleanup);
|
|
3289
|
+
if (binding) return { response: binding };
|
|
3290
|
+
}
|
|
3291
|
+
return {
|
|
3292
|
+
response: await reconcileLifecycleCleanup(
|
|
3293
|
+
broker,
|
|
3294
|
+
identity,
|
|
3295
|
+
cleanup,
|
|
3296
|
+
operation === "session.delete"
|
|
3297
|
+
? { ok: true, result: { sessionId: cleanup.sessionId } }
|
|
3298
|
+
: fail("spawn_failed", "No ready SDK endpoint remains available."),
|
|
3299
|
+
),
|
|
3300
|
+
};
|
|
3301
|
+
}
|
|
3302
|
+
const response = await executeLifecycleResponse(broker, operation, input, identity, cleanup);
|
|
2054
3303
|
const entry = broker.ledger.get(identity);
|
|
2055
3304
|
const priorDurableEffects = entry?.durableEffects;
|
|
2056
3305
|
const evidenceCwd = entry?.effectIntent?.worktree?.worktreePath ?? lifecycleCwd(input);
|
|
@@ -2105,6 +3354,30 @@ export async function executeLifecycle(
|
|
|
2105
3354
|
digest: createHash("sha256").update(canonicalJson(durableEffectsBody)).digest("hex"),
|
|
2106
3355
|
}
|
|
2107
3356
|
: undefined;
|
|
3357
|
+
const lifecycleCleanupResponse =
|
|
3358
|
+
evidence && root && entry?.intendedSessionId && expected && cleanupProof
|
|
3359
|
+
? await (async () => {
|
|
3360
|
+
const cleanupIntent = lifecycleCleanupPlan(root, entry.intendedSessionId!, expected, evidence);
|
|
3361
|
+
await broker.ledger.transition(identity, "effect_started", {
|
|
3362
|
+
response: fail(
|
|
3363
|
+
"cleanup_pending",
|
|
3364
|
+
"Lifecycle failure cleanup is preauthorized for durable reconciliation.",
|
|
3365
|
+
cleanupIntent,
|
|
3366
|
+
),
|
|
3367
|
+
});
|
|
3368
|
+
return reconcileLifecycleCleanup(broker, identity, cleanupIntent);
|
|
3369
|
+
})()
|
|
3370
|
+
: undefined;
|
|
3371
|
+
if (
|
|
3372
|
+
lifecycleCleanupResponse &&
|
|
3373
|
+
!lifecycleCleanupResponse.ok &&
|
|
3374
|
+
lifecycleCleanupResponse.error.code !== "spawn_failed"
|
|
3375
|
+
)
|
|
3376
|
+
return {
|
|
3377
|
+
response: lifecycleCleanupResponse,
|
|
3378
|
+
...(durableEffects ? { durableEffects } : {}),
|
|
3379
|
+
...(startupFailure ? { startupFailure } : {}),
|
|
3380
|
+
};
|
|
2108
3381
|
const terminalResponse: BrokerResponse =
|
|
2109
3382
|
!response.ok &&
|
|
2110
3383
|
entry?.effectMarker &&
|
|
@@ -2137,21 +3410,5 @@ export async function executeLifecycle(
|
|
|
2137
3410
|
response: terminalResponse,
|
|
2138
3411
|
...(durableEffects ? { durableEffects } : {}),
|
|
2139
3412
|
...(startupFailure ? { startupFailure } : {}),
|
|
2140
|
-
...(evidence && root && entry?.intendedSessionId && cleanupProof
|
|
2141
|
-
? {
|
|
2142
|
-
deferredArtifactCleanup: async () => {
|
|
2143
|
-
const current = await readLifecycleFailureArtifact(
|
|
2144
|
-
lifecycleFailurePath(root, entry.intendedSessionId!, evidence.artifact.effectMarker),
|
|
2145
|
-
evidence.artifact,
|
|
2146
|
-
);
|
|
2147
|
-
const marker = await readEffectMarker(lifecycleMarkerPath(root, entry.intendedSessionId!));
|
|
2148
|
-
if (current?.digest !== evidence.digest || !marker || !sameEffectMarker(marker, evidence.artifact))
|
|
2149
|
-
return;
|
|
2150
|
-
await fs.rm(lifecycleFailurePath(root, entry.intendedSessionId!, evidence.artifact.effectMarker));
|
|
2151
|
-
await fs.rm(lifecycleMarkerPath(root, entry.intendedSessionId!));
|
|
2152
|
-
await syncDirectory(path.join(root, "sdk"));
|
|
2153
|
-
},
|
|
2154
|
-
}
|
|
2155
|
-
: {}),
|
|
2156
3413
|
};
|
|
2157
3414
|
}
|