@gajae-code/coding-agent 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +215 -8
- package/README.md +2 -0
- package/bin/gajaessi.js +4 -0
- package/dist/types/async/job-manager.d.ts +55 -2
- package/dist/types/async/support.d.ts +14 -0
- package/dist/types/capability/mcp.d.ts +6 -0
- package/dist/types/capability/types.d.ts +6 -0
- package/dist/types/cli/accounts-cli.d.ts +19 -0
- package/dist/types/cli/args.d.ts +3 -0
- package/dist/types/cli/config-cli.d.ts +6 -0
- package/dist/types/cli/crash-cli.d.ts +10 -0
- package/dist/types/cli/customize-doctor.d.ts +145 -0
- package/dist/types/cli/daemon-cli.d.ts +3 -1
- package/dist/types/cli/mcp-cli.d.ts +11 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/root-flags.d.ts +12 -0
- package/dist/types/cli/setup-cli.d.ts +3 -1
- package/dist/types/cli/skills-cli.d.ts +2 -1
- package/dist/types/cli/update-cli.d.ts +19 -1
- package/dist/types/cli.d.ts +6 -0
- package/dist/types/commands/accounts.d.ts +40 -0
- package/dist/types/commands/crash.d.ts +24 -0
- package/dist/types/commands/customize.d.ts +22 -0
- package/dist/types/commands/daemon.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +6 -0
- package/dist/types/commands/setup.d.ts +6 -0
- package/dist/types/commands/skills.d.ts +6 -1
- package/dist/types/commands/update.d.ts +7 -0
- package/dist/types/config/atomic-yaml-patch-worker.d.ts +25 -0
- package/dist/types/config/atomic-yaml-patch.d.ts +62 -2
- package/dist/types/config/config-schema-version.d.ts +10 -0
- package/dist/types/config/file-lock.d.ts +18 -0
- package/dist/types/config/model-profile-activation.d.ts +3 -1
- package/dist/types/config/model-registry.d.ts +10 -1
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +73 -9
- package/dist/types/config/settings.d.ts +63 -0
- package/dist/types/config/skill-settings-defaults.d.ts +26 -0
- package/dist/types/config/task-delegation.d.ts +39 -0
- package/dist/types/config/update-channel.d.ts +10 -0
- package/dist/types/coordinator-mcp/server.d.ts +5 -1
- package/dist/types/crash/index-store.d.ts +87 -0
- package/dist/types/crash/nudge.d.ts +50 -0
- package/dist/types/crash/record-loader.d.ts +17 -0
- package/dist/types/crash/report.d.ts +134 -0
- package/dist/types/crash/sanitize.d.ts +25 -0
- package/dist/types/cursor.d.ts +13 -2
- package/dist/types/customization/import.d.ts +37 -0
- package/dist/types/customization/index.d.ts +4 -0
- package/dist/types/customization/inventory.d.ts +25 -0
- package/dist/types/customization/mutations.d.ts +32 -0
- package/dist/types/customization/types.d.ts +115 -0
- package/dist/types/daemon/control-types.d.ts +8 -1
- package/dist/types/daemon/operator-contract.d.ts +4 -4
- package/dist/types/discovery/claude.d.ts +39 -1
- package/dist/types/discovery/codex.d.ts +39 -1
- package/dist/types/discovery/index.d.ts +2 -0
- package/dist/types/discovery/mcp-compat.d.ts +30 -0
- package/dist/types/edit/renderer.d.ts +23 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -4
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +6 -1
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +8 -1
- package/dist/types/extensibility/hooks/loader.d.ts +19 -5
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +22 -1
- package/dist/types/extensibility/skill-management.d.ts +107 -0
- package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +3 -1
- package/dist/types/gjc-runtime/launch-worktree.d.ts +8 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +3 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +10 -6
- package/dist/types/gjc-runtime/session-state-lock.d.ts +104 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +120 -2
- package/dist/types/gjc-runtime/state-writer.d.ts +10 -1
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +4 -2
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +8 -6
- package/dist/types/gjc-runtime/workflow-settings.d.ts +109 -0
- package/dist/types/hooks/events.d.ts +60 -0
- package/dist/types/hooks/normalize.d.ts +83 -0
- package/dist/types/hooks/skill-state.d.ts +9 -2
- package/dist/types/main.d.ts +2 -1
- package/dist/types/modes/acp/abort-scope.d.ts +13 -0
- package/dist/types/modes/acp/acp-agent.d.ts +7 -4
- package/dist/types/modes/components/customization/customization-dashboard.d.ts +44 -0
- package/dist/types/modes/components/customization/import-wizard.d.ts +21 -0
- package/dist/types/modes/components/customization/index.d.ts +1 -0
- package/dist/types/modes/components/dynamic-theme-text.d.ts +9 -0
- package/dist/types/modes/components/gajae-pet-widget.d.ts +6 -28
- package/dist/types/modes/components/irc-sidebar.d.ts +1 -0
- package/dist/types/modes/components/iterm-pet-transport.d.ts +122 -0
- package/dist/types/modes/components/oauth-selector.d.ts +10 -4
- package/dist/types/modes/components/pet-capability.d.ts +7 -21
- package/dist/types/modes/components/settings-selector.d.ts +5 -0
- package/dist/types/modes/components/status-line/gh.d.ts +6 -2
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +7 -8
- package/dist/types/modes/controllers/stt-controller.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +1 -0
- package/dist/types/modes/theme/defaults/index.d.ts +118 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/modes/utils/injected-user-submission.d.ts +10 -0
- package/dist/types/runtime-mcp/config.d.ts +9 -0
- package/dist/types/runtime-mcp/manager.d.ts +26 -1
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +4 -0
- package/dist/types/runtime-mcp/oauth-flow.d.ts +24 -1
- package/dist/types/runtime-mcp/pool-key.d.ts +2 -0
- package/dist/types/runtime-mcp/protocol.d.ts +278 -0
- package/dist/types/runtime-mcp/scope-config.d.ts +29 -0
- package/dist/types/runtime-mcp/tool-bridge.d.ts +15 -13
- package/dist/types/runtime-mcp/transports/http.d.ts +9 -0
- package/dist/types/runtime-mcp/types.d.ts +102 -10
- package/dist/types/sdk/acp/adapter.d.ts +26 -5
- package/dist/types/sdk/broker/broker.d.ts +3 -55
- package/dist/types/sdk/broker/lifecycle.d.ts +18 -12
- package/dist/types/sdk/broker/session-index.d.ts +14 -5
- package/dist/types/sdk/broker/startup-failure.d.ts +16 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +51 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -41
- package/dist/types/sdk/bus/config.d.ts +11 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +0 -1
- package/dist/types/sdk/bus/daemon-paths.d.ts +6 -0
- package/dist/types/sdk/bus/discord-conversation.d.ts +2 -0
- package/dist/types/sdk/bus/discord-daemon.d.ts +17 -12
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +4 -34
- package/dist/types/sdk/bus/inbound-reaction-ordering.d.ts +61 -0
- package/dist/types/sdk/bus/index.d.ts +85 -99
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +29 -7
- package/dist/types/sdk/bus/managed-daemon.d.ts +2 -32
- package/dist/types/sdk/bus/notification-service.d.ts +52 -0
- package/dist/types/sdk/bus/operator-runtime.d.ts +1 -1
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +16 -5
- package/dist/types/sdk/bus/reconciliation-store.d.ts +113 -5
- package/dist/types/sdk/bus/session-control.d.ts +25 -1
- package/dist/types/sdk/bus/slack-conversation.d.ts +4 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +9 -17
- package/dist/types/sdk/bus/slack-live-provider.d.ts +3 -1
- package/dist/types/sdk/bus/slack-provider.d.ts +4 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +6 -40
- package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +30 -104
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +10 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +262 -19
- package/dist/types/sdk/bus/telegram-daemon-orphan-reap.d.ts +125 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +22 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -310
- package/dist/types/sdk/bus/telegram-reference.d.ts +1 -47
- package/dist/types/sdk/bus/threaded-render.d.ts +1 -0
- package/dist/types/sdk/bus/topic-registry.d.ts +33 -17
- package/dist/types/sdk/cli/rows.d.ts +2 -11
- package/dist/types/sdk/cli/session-cli.d.ts +15 -25
- package/dist/types/sdk/client/client.d.ts +97 -1
- package/dist/types/sdk/host/control/dispatch.d.ts +0 -7
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +18 -5
- package/dist/types/sdk/host/control/runtime-gate.d.ts +19 -0
- package/dist/types/sdk/host/host.d.ts +15 -1
- package/dist/types/sdk/host/query/handlers.d.ts +5 -0
- package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
- package/dist/types/sdk/host/session-runtime.d.ts +127 -7
- package/dist/types/sdk/host/types.d.ts +1 -1
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +2 -1
- package/dist/types/sdk/lifecycle/broker-client.d.ts +8 -0
- package/dist/types/sdk/lifecycle/client.d.ts +59 -0
- package/dist/types/sdk/lifecycle/index.d.ts +3 -0
- package/dist/types/sdk/lifecycle/service.d.ts +232 -0
- package/dist/types/sdk/mcp/server.d.ts +9 -6
- package/dist/types/sdk/prompt-deadline-lease.d.ts +43 -0
- package/dist/types/sdk/prompt-deadline-manager.d.ts +28 -0
- package/dist/types/sdk/prompt-status.d.ts +10 -3
- package/dist/types/sdk/protocol/operation-registry.d.ts +5 -0
- package/dist/types/sdk/receipt-state.d.ts +17 -0
- package/dist/types/sdk/reconciliation-extensions.d.ts +2 -0
- package/dist/types/sdk/router/index.d.ts +1 -0
- package/dist/types/sdk/router/session-router.d.ts +152 -0
- package/dist/types/sdk/session-list.d.ts +22 -0
- package/dist/types/sdk/session-reconnect.d.ts +43 -1
- package/dist/types/sdk/session.d.ts +34 -1
- package/dist/types/sdk/transport/serve-cli.d.ts +1 -4
- package/dist/types/session/account-inventory.d.ts +86 -0
- package/dist/types/session/agent-session.d.ts +145 -6
- package/dist/types/session/agent-storage.d.ts +10 -0
- package/dist/types/session/coordinator-tool-label.d.ts +28 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +23 -4
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/secure-token-file.d.ts +3 -0
- package/dist/types/session/session-manager.d.ts +47 -1
- package/dist/types/session/startup-auth-config.d.ts +44 -0
- package/dist/types/session/terminal-abort.d.ts +378 -0
- package/dist/types/session/yield-queue.d.ts +7 -0
- package/dist/types/session-import/claude.d.ts +26 -0
- package/dist/types/session-import/codex.d.ts +88 -0
- package/dist/types/session-import/command.d.ts +35 -0
- package/dist/types/session-import/detect.d.ts +22 -0
- package/dist/types/session-import/index.d.ts +7 -0
- package/dist/types/session-import/provider-codex.d.ts +22 -0
- package/dist/types/session-import/provider-service.d.ts +38 -0
- package/dist/types/session-import/redact.d.ts +23 -0
- package/dist/types/session-import/service.d.ts +47 -0
- package/dist/types/session-import/types.d.ts +120 -0
- package/dist/types/setup/paseo/check.d.ts +16 -0
- package/dist/types/setup/paseo/install-saga.d.ts +115 -0
- package/dist/types/setup/paseo/json-publisher.d.ts +76 -0
- package/dist/types/setup/paseo/orchestration-preferences.d.ts +24 -0
- package/dist/types/setup/paseo/paseo-ownership.d.ts +87 -0
- package/dist/types/setup/paseo/paseo-setup.d.ts +29 -0
- package/dist/types/setup/paseo/provider-config.d.ts +35 -0
- package/dist/types/setup/paseo/remove.d.ts +14 -0
- package/dist/types/setup/paseo/result-types.d.ts +59 -0
- package/dist/types/setup/paseo/setup-deps.d.ts +73 -0
- package/dist/types/setup/paseo/skills-bridge.d.ts +44 -0
- package/dist/types/skill-state/active-state.d.ts +5 -1
- package/dist/types/slash-commands/acp-builtins.d.ts +5 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +2 -0
- package/dist/types/slash-commands/helpers/credential-switch.d.ts +12 -0
- package/dist/types/slash-commands/helpers/usage-report.d.ts +5 -6
- package/dist/types/slash-commands/types.d.ts +7 -1
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +3 -1
- package/dist/types/tools/ask-answer-registry.d.ts +6 -3
- package/dist/types/tools/bash-sleep-advisory.d.ts +14 -0
- package/dist/types/tools/bash.d.ts +2 -6
- package/dist/types/tools/browser/attach.d.ts +11 -0
- package/dist/types/tools/browser/launch.d.ts +20 -0
- package/dist/types/tools/browser/profile-discovery.d.ts +6 -0
- package/dist/types/tools/browser/render.d.ts +1 -0
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/image-gen.d.ts +10 -2
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/tools/monitor.d.ts +1 -1
- package/dist/types/tools/skill-discovery.d.ts +8 -1
- package/dist/types/tools/subagent.d.ts +2 -0
- package/dist/types/tools/ultragoal-ask-guard.d.ts +2 -2
- package/dist/types/utils/herdr-pane.d.ts +76 -0
- package/package.json +19 -9
- package/scripts/build-binary.ts +2 -1
- package/scripts/build-sdk-package-smoke.ts +2 -13
- package/scripts/capture-sticky-viewport-showcase.ts +0 -1
- package/scripts/compile-args.ts +2 -0
- package/scripts/generate-sdk-adapter-parity-manifest.ts +31 -16
- package/scripts/generate-sdk-operation-inventory.ts +28 -0
- package/scripts/generate-telegram-baseline-manifest.ts +1 -0
- package/scripts/generate-tool-catalog.ts +6 -4
- package/scripts/verify-gjc-sdk-canonicalization.ts +5 -39
- package/src/async/job-manager.ts +352 -17
- package/src/async/support.ts +25 -0
- package/src/autoresearch/dashboard.ts +28 -13
- package/src/capability/index.ts +3 -1
- package/src/capability/mcp.ts +6 -0
- package/src/capability/types.ts +6 -0
- package/src/cli/accounts-cli.ts +395 -0
- package/src/cli/args.ts +23 -0
- package/src/cli/auth-broker-cli.ts +136 -66
- package/src/cli/auth-gateway-cli.ts +94 -70
- package/src/cli/config-cli.ts +27 -2
- package/src/cli/crash-cli.ts +78 -0
- package/src/cli/customize-doctor.ts +1605 -0
- package/src/cli/daemon-cli.ts +12 -3
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/mcp-cli.ts +128 -60
- package/src/cli/notify-cli.ts +8 -14
- package/src/cli/root-flags.ts +8 -0
- package/src/cli/setup-cli.ts +145 -0
- package/src/cli/skills-cli.ts +38 -2
- package/src/cli/update-cli.ts +167 -12
- package/src/cli.ts +4 -0
- package/src/commands/accounts.ts +71 -0
- package/src/commands/crash.ts +35 -0
- package/src/commands/customize.ts +84 -0
- package/src/commands/daemon.ts +2 -2
- package/src/commands/deep-interview.ts +2 -0
- package/src/commands/launch.ts +1 -0
- package/src/commands/mcp.ts +12 -7
- package/src/commands/ralplan.ts +2 -0
- package/src/commands/sdk.ts +65 -32
- package/src/commands/setup.ts +5 -0
- package/src/commands/skills.ts +14 -4
- package/src/commands/ultragoal.ts +10 -1
- package/src/commands/update.ts +26 -15
- package/src/config/atomic-yaml-patch-worker.ts +57 -0
- package/src/config/atomic-yaml-patch.ts +769 -29
- package/src/config/config-schema-version.ts +10 -0
- package/src/config/file-lock.ts +70 -1
- package/src/config/mcp-schema.json +52 -14
- package/src/config/model-profile-activation.ts +23 -2
- package/src/config/model-profiles.ts +88 -21
- package/src/config/model-registry.ts +147 -21
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +77 -7
- package/src/config/settings.ts +4038 -199
- package/src/config/skill-settings-defaults.ts +41 -3
- package/src/config/task-delegation.ts +87 -0
- package/src/config/update-channel.ts +23 -0
- package/src/coordinator-mcp/server.ts +377 -308
- package/src/crash/index-store.ts +723 -0
- package/src/crash/nudge.ts +89 -0
- package/src/crash/record-loader.ts +149 -0
- package/src/crash/report.ts +551 -0
- package/src/crash/sanitize.ts +129 -0
- package/src/cursor.ts +36 -10
- package/src/customization/import.ts +946 -0
- package/src/customization/index.ts +4 -0
- package/src/customization/inventory.ts +335 -0
- package/src/customization/mutations.ts +141 -0
- package/src/customization/types.ts +223 -0
- package/src/daemon/control-types.ts +8 -1
- package/src/daemon/operator-contract.ts +6 -6
- package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +31 -6
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +11 -22
- package/src/defaults/gjc/skills/ralplan/SKILL.md +36 -23
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +16 -0
- package/src/discovery/claude-hooks.ts +38 -0
- package/src/discovery/claude.ts +142 -209
- package/src/discovery/codex-hooks.ts +38 -0
- package/src/discovery/codex.ts +132 -198
- package/src/discovery/helpers.ts +8 -1
- package/src/discovery/index.ts +2 -0
- package/src/discovery/mcp-compat.ts +234 -0
- package/src/discovery/mcp-json.ts +6 -0
- package/src/edit/renderer.ts +40 -0
- package/src/edit/streaming.ts +4 -2
- package/src/exec/bash-executor.ts +24 -20
- package/src/export/html/index.ts +51 -2
- package/src/extensibility/extensions/loader.ts +9 -1
- package/src/extensibility/extensions/runner.ts +17 -4
- package/src/extensibility/extensions/types.ts +31 -6
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +33 -0
- package/src/extensibility/gjc-plugins/installer.ts +9 -5
- package/src/extensibility/gjc-plugins/observability.ts +8 -2
- package/src/extensibility/gjc-plugins/registry.ts +13 -2
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +3 -1
- package/src/extensibility/gjc-plugins/schema.ts +222 -39
- package/src/extensibility/hooks/loader.ts +154 -10
- package/src/extensibility/runtime-skill-discovery.ts +257 -60
- package/src/extensibility/skill-management.ts +368 -0
- package/src/extensibility/skills.ts +52 -16
- package/src/gjc-runtime/deep-interview-runtime.ts +45 -61
- package/src/gjc-runtime/launch-worktree.ts +56 -2
- package/src/gjc-runtime/linux-proc.ts +7 -1
- package/src/gjc-runtime/ralplan-runtime.ts +100 -164
- package/src/gjc-runtime/session-state-lock.ts +992 -0
- package/src/gjc-runtime/session-state-sidecar.ts +644 -142
- package/src/gjc-runtime/state-writer.ts +39 -14
- package/src/gjc-runtime/tmux-owner-isolation.ts +5 -4
- package/src/gjc-runtime/ultragoal-guard.ts +11 -4
- package/src/gjc-runtime/ultragoal-runtime.ts +44 -41
- package/src/gjc-runtime/workflow-settings.ts +596 -0
- package/src/goals/tools/goal-tool.ts +6 -1
- package/src/hooks/events.ts +291 -0
- package/src/hooks/native-skill-hook.ts +4 -0
- package/src/hooks/normalize.ts +369 -0
- package/src/hooks/skill-state.ts +165 -46
- package/src/internal-urls/docs-index.generated.ts +41 -32
- package/src/main.ts +36 -5
- package/src/migrate/mcp-mapper.ts +25 -2
- package/src/modes/acp/abort-scope.ts +29 -0
- package/src/modes/acp/acp-agent.ts +423 -98
- package/src/modes/components/assistant-message.ts +6 -1
- package/src/modes/components/chrome.ts +4 -3
- package/src/modes/components/customization/customization-dashboard.ts +443 -0
- package/src/modes/components/customization/import-wizard.ts +372 -0
- package/src/modes/components/customization/index.ts +1 -0
- package/src/modes/components/dynamic-theme-text.ts +22 -0
- package/src/modes/components/gajae-pet-widget.ts +607 -193
- package/src/modes/components/irc-sidebar.ts +11 -0
- package/src/modes/components/iterm-pet-transport.ts +585 -0
- package/src/modes/components/model-selector.ts +1 -6
- package/src/modes/components/oauth-selector.ts +277 -86
- package/src/modes/components/pet-capability.ts +35 -31
- package/src/modes/components/settings-selector.ts +29 -5
- package/src/modes/components/status-line/gh.ts +45 -1
- package/src/modes/components/status-line/segments.ts +19 -4
- package/src/modes/components/tool-status-header.ts +13 -3
- package/src/modes/controllers/command-controller.ts +20 -8
- package/src/modes/controllers/extension-ui-controller.ts +56 -257
- package/src/modes/controllers/input-controller.ts +10 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +16 -3
- package/src/modes/controllers/selector-controller.ts +238 -77
- package/src/modes/controllers/stt-controller.ts +3 -3
- package/src/modes/interactive-mode.ts +88 -16
- package/src/modes/print-mode.ts +35 -6
- package/src/modes/runtime-init.ts +21 -68
- package/src/modes/theme/defaults/index.ts +2 -0
- package/src/modes/theme/defaults/ouroboros.json +118 -0
- package/src/modes/types.ts +1 -0
- package/src/modes/utils/injected-user-submission.ts +25 -0
- package/src/modes/utils/terminal-bell.ts +15 -1
- package/src/prompts/agent-fragments/ralplan-persistence.md +1 -1
- package/src/prompts/agent-fragments/restricted-bash.md +1 -1
- package/src/prompts/agents/critic.md +1 -1
- package/src/prompts/system/rlm-research.md +1 -1
- package/src/prompts/system/system-prompt.md +0 -2
- package/src/prompts/tools/bash.md +7 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/cron.md +6 -6
- package/src/prompts/tools/read.md +0 -2
- package/src/prompts/tools/skill-discovery.md +4 -2
- package/src/runtime-mcp/client.ts +427 -23
- package/src/runtime-mcp/config.ts +37 -5
- package/src/runtime-mcp/manager-pool.test.ts +56 -0
- package/src/runtime-mcp/manager.ts +52 -2
- package/src/runtime-mcp/oauth-discovery.ts +13 -0
- package/src/runtime-mcp/oauth-flow.ts +46 -1
- package/src/runtime-mcp/pool-key.ts +5 -1
- package/src/runtime-mcp/pool.test.ts +9 -0
- package/src/runtime-mcp/protocol.test.ts +353 -0
- package/src/runtime-mcp/protocol.ts +624 -0
- package/src/runtime-mcp/scope-config.ts +78 -0
- package/src/runtime-mcp/tool-bridge.ts +44 -10
- package/src/runtime-mcp/transports/http.ts +84 -13
- package/src/runtime-mcp/types.ts +123 -12
- package/src/sdk/acp/adapter.ts +328 -97
- package/src/sdk/broker/broker.ts +160 -741
- package/src/sdk/broker/ensure.ts +9 -1
- package/src/sdk/broker/lifecycle.ts +510 -116
- package/src/sdk/broker/operation-receipt-ledger.ts +11 -2
- package/src/sdk/broker/process-incarnation.ts +19 -0
- package/src/sdk/broker/session-index.ts +516 -117
- package/src/sdk/broker/startup-failure.ts +28 -5
- package/src/sdk/broker/transport.ts +1 -13
- package/src/sdk/bus/chat-daemon-cli.ts +4 -2
- package/src/sdk/bus/chat-daemon-control.ts +67 -6
- package/src/sdk/bus/chat-daemon-runtime.ts +183 -859
- package/src/sdk/bus/config.ts +13 -0
- package/src/sdk/bus/conversation-store.ts +59 -171
- package/src/sdk/bus/daemon-paths.ts +20 -0
- package/src/sdk/bus/discord-conversation.ts +2 -0
- package/src/sdk/bus/discord-daemon.ts +524 -126
- package/src/sdk/bus/discord-live-provider.ts +206 -79
- package/src/sdk/bus/existing-thread-readiness.ts +4 -97
- package/src/sdk/bus/inbound-reaction-ordering.ts +122 -0
- package/src/sdk/bus/index.ts +2480 -456
- package/src/sdk/bus/kind-aware-reconciliation.ts +204 -34
- package/src/sdk/bus/lifecycle-commands.ts +2 -2
- package/src/sdk/bus/managed-daemon.ts +2 -145
- package/src/sdk/bus/notification-service.ts +196 -0
- package/src/sdk/bus/operator-runtime.ts +1 -1
- package/src/sdk/bus/prompt-reconciliation.ts +24 -15
- package/src/sdk/bus/reconciliation-store.ts +553 -24
- package/src/sdk/bus/session-control.ts +49 -3
- package/src/sdk/bus/slack-conversation.ts +4 -0
- package/src/sdk/bus/slack-daemon.ts +733 -134
- package/src/sdk/bus/slack-live-provider.ts +65 -15
- package/src/sdk/bus/slack-provider.ts +4 -0
- package/src/sdk/bus/slack-thread-binding.ts +9 -104
- package/src/sdk/bus/telegram-adoption-intent.ts +299 -209
- package/src/sdk/bus/telegram-daemon-cli.ts +194 -7
- package/src/sdk/bus/telegram-daemon-contract.ts +265 -19
- package/src/sdk/bus/telegram-daemon-control.ts +3 -13
- package/src/sdk/bus/telegram-daemon-orphan-reap.ts +498 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +176 -0
- package/src/sdk/bus/telegram-daemon.ts +2552 -2280
- package/src/sdk/bus/telegram-reference.ts +1 -226
- package/src/sdk/bus/telegram-setup.ts +2 -2
- package/src/sdk/bus/threaded-render.ts +1 -0
- package/src/sdk/bus/topic-registry.ts +279 -146
- package/src/sdk/cli/rows.ts +2 -35
- package/src/sdk/cli/session-cli.ts +776 -700
- package/src/sdk/client/client.ts +892 -1
- package/src/sdk/host/control/dispatch.ts +93 -58
- package/src/sdk/host/control/index.ts +1 -1
- package/src/sdk/host/control/operations.ts +18 -10
- package/src/sdk/host/control/runtime-gate.ts +40 -0
- package/src/sdk/host/host.ts +63 -12
- package/src/sdk/host/query/handlers.ts +66 -7
- package/src/sdk/host/reverse-leases.ts +2 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +2787 -34
- package/src/sdk/host/session-runtime.ts +1489 -101
- package/src/sdk/host/surface-policy.ts +12 -8
- package/src/sdk/host/types.ts +1 -1
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +31 -59
- package/src/sdk/host/websocket-transport.ts +85 -50
- package/src/sdk/index.ts +2 -1
- package/src/sdk/lifecycle/broker-client.ts +53 -0
- package/src/sdk/lifecycle/client.ts +234 -0
- package/src/sdk/lifecycle/index.ts +3 -0
- package/src/sdk/{bus/recent-activity.ts → lifecycle/recent-sessions.ts} +5 -2
- package/src/sdk/lifecycle/service.ts +720 -0
- package/src/sdk/mcp/server.ts +226 -188
- package/src/sdk/prompt-deadline-lease.ts +55 -0
- package/src/sdk/prompt-deadline-manager.ts +148 -0
- package/src/sdk/prompt-status.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +272 -129
- package/src/sdk/protocol/operation-registry.ts +71 -8
- package/src/sdk/receipt-state.ts +43 -0
- package/src/sdk/reconciliation-extensions.ts +14 -0
- package/src/sdk/router/index.ts +1 -0
- package/src/sdk/router/session-router.ts +1658 -0
- package/src/sdk/session-list.ts +93 -0
- package/src/sdk/session-reconnect.ts +45 -1
- package/src/sdk/session.ts +716 -120
- package/src/sdk/transport/serve-cli.ts +18 -34
- package/src/session/account-inventory.ts +603 -0
- package/src/session/agent-session.ts +2873 -559
- package/src/session/agent-storage.ts +35 -0
- package/src/session/blob-store.ts +32 -1
- package/src/session/coordinator-tool-label.ts +40 -0
- package/src/session/internal/managed-session-scope.ts +4 -0
- package/src/session/internal/managed-session-storage.ts +199 -31
- package/src/session/messages.ts +1 -1
- package/src/session/secure-token-file.ts +80 -0
- package/src/session/session-manager.ts +757 -57
- package/src/session/session-storage.ts +25 -16
- package/src/session/startup-auth-config.ts +297 -0
- package/src/session/terminal-abort.ts +1253 -0
- package/src/session/yield-queue.ts +57 -15
- package/src/session-import/claude.ts +334 -0
- package/src/session-import/codex.ts +1033 -0
- package/src/session-import/command.ts +77 -0
- package/src/session-import/detect.ts +133 -0
- package/src/session-import/index.ts +7 -0
- package/src/session-import/provider-codex.ts +327 -0
- package/src/session-import/provider-service.ts +457 -0
- package/src/session-import/redact.ts +128 -0
- package/src/session-import/service.ts +1264 -0
- package/src/session-import/types.ts +148 -0
- package/src/setup/paseo/check.ts +213 -0
- package/src/setup/paseo/install-saga.ts +293 -0
- package/src/setup/paseo/json-publisher.ts +263 -0
- package/src/setup/paseo/orchestration-preferences.ts +86 -0
- package/src/setup/paseo/paseo-ownership.ts +224 -0
- package/src/setup/paseo/paseo-setup.ts +253 -0
- package/src/setup/paseo/provider-config.ts +139 -0
- package/src/setup/paseo/remove.ts +183 -0
- package/src/setup/paseo/result-types.ts +79 -0
- package/src/setup/paseo/setup-deps.ts +175 -0
- package/src/setup/paseo/skills-bridge.ts +260 -0
- package/src/skill-state/active-state.ts +21 -11
- package/src/slash-commands/acp-builtins.ts +9 -1
- package/src/slash-commands/builtin-registry.ts +117 -19
- package/src/slash-commands/helpers/credential-switch.ts +143 -0
- package/src/slash-commands/helpers/mcp.ts +9 -3
- package/src/slash-commands/helpers/usage-report.ts +61 -56
- package/src/slash-commands/types.ts +7 -2
- package/src/task/executor.ts +30 -11
- package/src/task/index.ts +66 -7
- package/src/task/render.ts +23 -9
- package/src/tools/ask-answer-registry.ts +23 -7
- package/src/tools/ask.ts +8 -4
- package/src/tools/bash-sleep-advisory.ts +115 -0
- package/src/tools/bash.ts +98 -18
- package/src/tools/browser/attach.ts +71 -3
- package/src/tools/browser/launch.ts +86 -13
- package/src/tools/browser/profile-discovery.ts +57 -7
- package/src/tools/browser/render.ts +8 -1
- package/src/tools/browser.ts +130 -21
- package/src/tools/computer.enforcement.test.ts +1 -1
- package/src/tools/computer.ts +3 -2
- package/src/tools/cron.ts +12 -1
- package/src/tools/image-gen.ts +349 -142
- package/src/tools/index.ts +8 -0
- package/src/tools/job.ts +33 -6
- package/src/tools/monitor.ts +41 -2
- package/src/tools/skill-discovery.ts +21 -29
- package/src/tools/subagent.ts +142 -24
- package/src/tools/todo-contract.ts +55 -3
- package/src/tools/todo-write.ts +4 -3
- package/src/tools/tool-catalog.generated.ts +6 -6
- package/src/tools/ultragoal-ask-guard.ts +10 -3
- package/src/utils/herdr-pane.ts +345 -0
- package/src/utils/title-generator.ts +2 -0
- package/src/web/scrapers/index.ts +6 -4
- package/dist/types/cli/malloc-env-guard.test.d.ts +0 -1
- package/dist/types/defaults/gjc-defaults.test.d.ts +0 -1
- package/dist/types/memory-backend/lazy-loading.test.d.ts +0 -1
- package/dist/types/runtime/boot-ordering.test.d.ts +0 -1
- package/dist/types/runtime/lazy-service.test.d.ts +0 -1
- package/dist/types/runtime/network-prewarm-service.test.d.ts +0 -1
- package/dist/types/runtime/workspace-tree-service.test.d.ts +0 -1
- package/dist/types/runtime-mcp/config.test.d.ts +0 -1
- package/dist/types/runtime-mcp/http.test.d.ts +0 -1
- package/dist/types/runtime-mcp/manager-pool.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool-key.test.d.ts +0 -1
- package/dist/types/runtime-mcp/pool.test.d.ts +0 -1
- package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +0 -117
- package/dist/types/sdk/bus/lifecycle-orchestrator.d.ts +0 -141
- package/dist/types/sdk/bus/telegram-cli.d.ts +0 -31
- package/dist/types/sdk/elevation/allowlist.d.ts +0 -20
- package/dist/types/sdk/elevation/audit.d.ts +0 -93
- package/dist/types/sdk/elevation/capability.d.ts +0 -3
- package/dist/types/sdk/elevation/digest.d.ts +0 -72
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +0 -85
- package/dist/types/sdk/elevation/grant-ledger.d.ts +0 -163
- package/dist/types/sdk/elevation/owner.d.ts +0 -48
- package/dist/types/sdk/host/import-graph.test.d.ts +0 -1
- package/dist/types/sdk/host/sdk-surface-parity.test.d.ts +0 -1
- package/dist/types/sdk/host/session-runtime.test.d.ts +0 -1
- package/dist/types/sdk/host/websocket-transport.lifecycle.test.d.ts +0 -1
- package/dist/types/session/auth-broker-config.d.ts +0 -13
- package/dist/types/session/heap-eviction-artifacts.test.d.ts +0 -1
- package/dist/types/skills/skills.test.d.ts +0 -1
- package/dist/types/slash-commands/notify-command.test.d.ts +0 -1
- package/dist/types/task/token-log.test.d.ts +0 -1
- package/dist/types/tools/computer.enforcement.test.d.ts +0 -1
- package/dist/types/tools/descriptors.test.d.ts +0 -1
- package/scripts/g005-daemon-smoke.ts +0 -180
- package/scripts/g011-daemon-path-smoke.ts +0 -153
- package/src/sdk/bus/lifecycle-control-runtime.ts +0 -1733
- package/src/sdk/bus/lifecycle-orchestrator.ts +0 -489
- package/src/sdk/bus/telegram-cli.ts +0 -212
- package/src/sdk/elevation/allowlist.ts +0 -54
- package/src/sdk/elevation/audit.ts +0 -208
- package/src/sdk/elevation/capability.ts +0 -41
- package/src/sdk/elevation/digest.ts +0 -129
- package/src/sdk/elevation/dispatch-receipt.ts +0 -184
- package/src/sdk/elevation/grant-ledger.ts +0 -1142
- package/src/sdk/elevation/owner.ts +0 -109
- package/src/session/auth-broker-config.ts +0 -109
- /package/dist/types/{ai-core-import-gate.test.d.ts → discovery/claude-hooks.d.ts} +0 -0
- /package/dist/types/{cli/fixture-report.test.d.ts → discovery/codex-hooks.d.ts} +0 -0
- /package/dist/types/sdk/{bus/recent-activity.d.ts → lifecycle/recent-sessions.d.ts} +0 -0
package/src/config/settings.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* For tests, `Settings.isolated()` seeds explicit user/global settings:
|
|
11
11
|
* const isolated = Settings.isolated({ "compaction.enabled": false });
|
|
12
12
|
*/
|
|
13
|
+
|
|
14
|
+
import * as nodeCrypto from "node:crypto";
|
|
13
15
|
import * as fs from "node:fs";
|
|
14
16
|
import * as os from "node:os";
|
|
15
17
|
import * as path from "node:path";
|
|
@@ -17,6 +19,7 @@ import * as util from "node:util";
|
|
|
17
19
|
import {
|
|
18
20
|
getAgentDbPath,
|
|
19
21
|
getAgentDir,
|
|
22
|
+
getConfigRootDir,
|
|
20
23
|
getCustomThemesDir,
|
|
21
24
|
getProjectDir,
|
|
22
25
|
isEnoent,
|
|
@@ -30,6 +33,7 @@ import { YAML } from "bun";
|
|
|
30
33
|
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
|
|
31
34
|
import type { ModelRole } from "../config/model-registry";
|
|
32
35
|
import { loadCapability } from "../discovery";
|
|
36
|
+
import { extractWorkflowSetting, type WorkflowSettingKey } from "../gjc-runtime/workflow-settings";
|
|
33
37
|
import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
|
|
34
38
|
import {
|
|
35
39
|
type NotificationSettingsReader,
|
|
@@ -39,6 +43,8 @@ import {
|
|
|
39
43
|
import { AgentStorage } from "../session/agent-storage";
|
|
40
44
|
import { type EditMode, type EditVariantMatch, normalizeEditMode } from "../utils/edit-mode";
|
|
41
45
|
import {
|
|
46
|
+
type AtomicYamlConfigTransaction,
|
|
47
|
+
AtomicYamlConflictError,
|
|
42
48
|
type AtomicYamlPatch,
|
|
43
49
|
applyAtomicYamlPatches,
|
|
44
50
|
applyAtomicYamlPatchesWithCurrent,
|
|
@@ -48,6 +54,7 @@ import {
|
|
|
48
54
|
enqueueAtomicYamlOperation,
|
|
49
55
|
reserveAtomicYamlUpdateSlot,
|
|
50
56
|
setByPath,
|
|
57
|
+
withAtomicYamlConfigTransaction,
|
|
51
58
|
} from "./atomic-yaml-patch";
|
|
52
59
|
import { isModelSelectorValue, type ModelSelectorValue, normalizeModelSelectorValue } from "./model-selector-value";
|
|
53
60
|
|
|
@@ -79,6 +86,97 @@ export interface RawSettings {
|
|
|
79
86
|
|
|
80
87
|
const UNSAFE_EDIT_VARIANT_PATTERNS = new Set(["__proto__", "constructor", "prototype"]);
|
|
81
88
|
|
|
89
|
+
const CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS: readonly WorkflowSettingKey[] = [
|
|
90
|
+
"gjc.deepInterview.ambiguityThreshold",
|
|
91
|
+
"gjc.ralplan.autoHandoff",
|
|
92
|
+
"gjc.ralplan.maxIterations",
|
|
93
|
+
"gjc.ralplan.maxReviewPassesPerLane",
|
|
94
|
+
"gjc.ultragoal.nudgeBudget",
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
type StrictInvalidEvidenceEntry = { key: WorkflowSettingKey; value: unknown };
|
|
98
|
+
|
|
99
|
+
/** Pre-publication target state of a key the project migration will write. */
|
|
100
|
+
type ProjectTargetBeforeState = { present: boolean; value: unknown };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Guaranteed-invalid placeholder values persisted into project `config.yml` as
|
|
104
|
+
* fallback strict evidence when the retained `.gjc/settings.json` is malformed
|
|
105
|
+
* or unreadable AND the strict-invalid evidence sidecar (`.gjc/state/`) cannot
|
|
106
|
+
* be written. The strict resolver reads config.yml only, so each value keeps
|
|
107
|
+
* the ralplan exit-2 error observable until the user repairs the source.
|
|
108
|
+
*/
|
|
109
|
+
const MALFORMED_SOURCE_STRICT_FALLBACK: readonly { key: WorkflowSettingKey; value: unknown }[] = [
|
|
110
|
+
{ key: "gjc.ralplan.autoHandoff", value: "invalid-autoHandoff" },
|
|
111
|
+
{ key: "gjc.ralplan.maxIterations", value: -1 },
|
|
112
|
+
{ key: "gjc.ralplan.maxReviewPassesPerLane", value: -1 },
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
type StrictInvalidEvidence =
|
|
116
|
+
| { version: 2; malformed: true; source: string }
|
|
117
|
+
| { version: 2; malformed?: false; keys: readonly StrictInvalidEvidenceEntry[]; source: string };
|
|
118
|
+
|
|
119
|
+
const WORKFLOW_MIGRATION_MARKER_VERSION = 1;
|
|
120
|
+
|
|
121
|
+
type WorkflowMigrationMarker = {
|
|
122
|
+
version: 1;
|
|
123
|
+
status: "pending" | "complete";
|
|
124
|
+
sourcePath: string;
|
|
125
|
+
backupPath: string;
|
|
126
|
+
targetPath: string;
|
|
127
|
+
/** Canonical (realpath) agent dir at migration time; a symlink repointed
|
|
128
|
+
* afterwards must not be treated as the same migration target. */
|
|
129
|
+
canonicalTargetDir?: string;
|
|
130
|
+
/** `dev:ino` of the target config.yml at migration time; detects a
|
|
131
|
+
* same-pathname profile REPLACEMENT (deleted + recreated), which realpath
|
|
132
|
+
* alone cannot. */
|
|
133
|
+
canonicalTargetIdentity?: string;
|
|
134
|
+
/** `dev:ino` of the config.yml FILE that received the migration write; a
|
|
135
|
+
* later atomic editor save or file replacement yields a new inode that must
|
|
136
|
+
* not be published as migration-owned. */
|
|
137
|
+
targetFileIdentity?: string;
|
|
138
|
+
sourceSha256: string;
|
|
139
|
+
migratedKeys: WorkflowSettingKey[];
|
|
140
|
+
startedAt: string;
|
|
141
|
+
/** The prior source hash (the migration-write ownership basis) when the
|
|
142
|
+
* reconcile rewrites the marker as pending; the resume accepts a backup
|
|
143
|
+
* matching either the new hash (after refresh) or this prior hash. */
|
|
144
|
+
priorSourceSha256?: string;
|
|
145
|
+
/** Per-key sha256 of the values written by an interrupted reconcile; the
|
|
146
|
+
* resume recognizes a target matching a recorded repair value as the
|
|
147
|
+
* reconcile's own write even after a further source edit. */
|
|
148
|
+
repairValueHashes?: Record<string, string>;
|
|
149
|
+
/** True once the reconcile's target repairs were actually applied (the
|
|
150
|
+
* pending marker is rewritten after the CAS-protected apply succeeds);
|
|
151
|
+
* only then are repairValueHashes treated as committed-write evidence. */
|
|
152
|
+
repairsApplied?: boolean;
|
|
153
|
+
/** Per-key sha256 of the target values BEFORE the interrupted reconcile's
|
|
154
|
+
* repairs; the resume recognizes a repair value as committed when the
|
|
155
|
+
* target CHANGED from this recorded state (even if the post-apply marker
|
|
156
|
+
* rewrite was not reached). */
|
|
157
|
+
preRepairTargetHashes?: Record<string, string>;
|
|
158
|
+
completedAt?: string;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Test-only seams for the config-root workflow migration. Production code
|
|
162
|
+
* never sets these; tests use them to interleave external filesystem changes
|
|
163
|
+
* at exact points of the migration state machine (mirroring
|
|
164
|
+
* `FileLockTestHooks` in file-lock.ts).
|
|
165
|
+
*/
|
|
166
|
+
export const SettingsMigrationTestHooks: {
|
|
167
|
+
/** Fires after the no-replace backup copy is created and its identity
|
|
168
|
+
* (inode + sha256) has been captured, immediately before the source is
|
|
169
|
+
* re-hashed for the move verification. */
|
|
170
|
+
afterBackupIdentityCaptured?: (backupPath: string) => void | Promise<void>;
|
|
171
|
+
/** Fires after a quarantined backup is verified as this run's file,
|
|
172
|
+
* immediately before the quarantined entry is unlinked. */
|
|
173
|
+
beforeQuarantineRemoval?: (backupPath: string) => void | Promise<void>;
|
|
174
|
+
/** Fires immediately before the project migration's POST-publication marker
|
|
175
|
+
* re-read, after the migrated values already committed: test seams use it
|
|
176
|
+
* to make the marker unreadable so the rollback path is exercised. */
|
|
177
|
+
beforeProjectMarkerMerge?: () => void | Promise<void>;
|
|
178
|
+
} = {};
|
|
179
|
+
|
|
82
180
|
type SettingsPatch = {
|
|
83
181
|
readonly path: string;
|
|
84
182
|
readonly value: unknown | undefined;
|
|
@@ -122,6 +220,13 @@ export interface SettingsOptions {
|
|
|
122
220
|
inMemory?: boolean;
|
|
123
221
|
/** Initial overrides */
|
|
124
222
|
overrides?: Partial<Record<SettingPath, unknown>>;
|
|
223
|
+
/**
|
|
224
|
+
* Read the canonical config.yml from disk but never persist: no DB open,
|
|
225
|
+
* no legacy/config-root/project migrations, no file writes, renames, locks,
|
|
226
|
+
* or mtime changes. Used by read-only inspection surfaces (`gjc customize
|
|
227
|
+
* doctor`). When true, inMemory must be false so config.yml is still read.
|
|
228
|
+
*/
|
|
229
|
+
readonly?: boolean;
|
|
125
230
|
}
|
|
126
231
|
|
|
127
232
|
function summarizeSettingsOptions(options: SettingsOptions | null): {
|
|
@@ -171,6 +276,36 @@ function isAtomicSettingsPath(path: string): boolean {
|
|
|
171
276
|
// Path Utilities
|
|
172
277
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
173
278
|
|
|
279
|
+
/**
|
|
280
|
+
* Flatten an object to its leaf dotted paths (nested and dotted keys alike),
|
|
281
|
+
* used to verify publication proof before retiring a legacy source.
|
|
282
|
+
*/
|
|
283
|
+
function flattenObjectPaths(node: unknown, prefix: string[] = []): string[] {
|
|
284
|
+
if (node === null || typeof node !== "object" || Array.isArray(node)) {
|
|
285
|
+
return [prefix.join(".")];
|
|
286
|
+
}
|
|
287
|
+
const paths: string[] = [];
|
|
288
|
+
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
|
289
|
+
paths.push(...flattenObjectPaths(value, [...prefix, key]));
|
|
290
|
+
}
|
|
291
|
+
return paths;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Whether a dotted path has a value present in an object (used to verify
|
|
296
|
+
* publication proof before retiring a legacy source).
|
|
297
|
+
*/
|
|
298
|
+
function hasPathValue(obj: Record<string, unknown>, dottedPath: string): boolean {
|
|
299
|
+
let node: unknown = obj;
|
|
300
|
+
for (const segment of dottedPath.split(".")) {
|
|
301
|
+
if (node === null || typeof node !== "object" || !(segment in node)) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
node = (node as Record<string, unknown>)[segment];
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
174
309
|
/**
|
|
175
310
|
* Get a nested value from an object by path segments.
|
|
176
311
|
*/
|
|
@@ -236,6 +371,13 @@ function rawSettingsRecord(value: unknown): RawSettings | undefined {
|
|
|
236
371
|
return value as RawSettings;
|
|
237
372
|
}
|
|
238
373
|
|
|
374
|
+
const LEGACY_CUSTOM_IMAGE_PROVIDER_DIAGNOSTIC =
|
|
375
|
+
"Legacy custom image settings cannot be migrated automatically. Move the endpoint and credential to models.yml, configure an image-capable OpenAI Responses model, and then set modelRoles.image. The legacy settings were retained unchanged.";
|
|
376
|
+
|
|
377
|
+
function hasLegacyCustomImageProvider(raw: RawSettings): boolean {
|
|
378
|
+
return rawSettingsRecord(raw.providers)?.image === "custom";
|
|
379
|
+
}
|
|
380
|
+
|
|
239
381
|
function shallowModelSelectorRecord(value: unknown): Record<string, ModelSelectorValue> {
|
|
240
382
|
const record = rawSettingsRecord(value);
|
|
241
383
|
if (!record) return {};
|
|
@@ -375,6 +517,9 @@ export class Settings implements NotificationSettingsReader {
|
|
|
375
517
|
/** Legacy fallback migration warnings emitted once per settings instance. */
|
|
376
518
|
#legacyFallbackMigrationWarnings = 0;
|
|
377
519
|
#legacyFallbackMigrationGlobalFingerprint: string | undefined;
|
|
520
|
+
/** Legacy custom-image migration error emitted once per settings instance. */
|
|
521
|
+
#legacyCustomImageProviderDiagnosticLogged = false;
|
|
522
|
+
|
|
378
523
|
#schemaReport: SettingsSchemaReport = { issues: [], valid: true };
|
|
379
524
|
#schemaMigrationPending = false;
|
|
380
525
|
/** A newer config schema must never be rewritten by legacy migrations. */
|
|
@@ -389,12 +534,15 @@ export class Settings implements NotificationSettingsReader {
|
|
|
389
534
|
|
|
390
535
|
/** Whether to persist changes */
|
|
391
536
|
#persist: boolean;
|
|
537
|
+
/** Read-only inspection mode: config.yml is read but never written/migrated. */
|
|
538
|
+
#readonly: boolean;
|
|
392
539
|
|
|
393
540
|
private constructor(options: SettingsOptions = {}) {
|
|
394
541
|
this.#cwd = path.normalize(options.cwd ?? getProjectDir());
|
|
395
542
|
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
|
|
396
543
|
this.#configPath = options.inMemory ? null : path.resolve(this.#agentDir, "config.yml");
|
|
397
|
-
this.#persist = !options.inMemory;
|
|
544
|
+
this.#persist = !options.inMemory && !options.readonly;
|
|
545
|
+
this.#readonly = options.readonly === true;
|
|
398
546
|
|
|
399
547
|
if (options.overrides) {
|
|
400
548
|
for (const [key, value] of Object.entries(options.overrides)) {
|
|
@@ -450,6 +598,19 @@ export class Settings implements NotificationSettingsReader {
|
|
|
450
598
|
const instance = new Settings(options);
|
|
451
599
|
return instance.#load();
|
|
452
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* Load settings for read-only inspection without any persistence side-effects:
|
|
603
|
+
* no DB open, no legacy/config-root/project migrations, no file writes,
|
|
604
|
+
* renames, locks, or mtime changes. Reads the canonical config.yml and
|
|
605
|
+
* discovers/merges project settings exactly like the durable path, but the
|
|
606
|
+
* result is a transient snapshot. Does not affect the global singleton.
|
|
607
|
+
*
|
|
608
|
+
* Used by `gjc customize doctor` to honor the read-only product contract.
|
|
609
|
+
*/
|
|
610
|
+
static loadReadonly(options: { cwd?: string; agentDir?: string }): Promise<Settings> {
|
|
611
|
+
const instance = new Settings({ ...options, readonly: true });
|
|
612
|
+
return instance.#load();
|
|
613
|
+
}
|
|
453
614
|
|
|
454
615
|
/**
|
|
455
616
|
* Create an isolated instance for testing with explicit user/global settings.
|
|
@@ -1157,23 +1318,59 @@ export class Settings implements NotificationSettingsReader {
|
|
|
1157
1318
|
// ─────────────────────────────────────────────────────────────────────────
|
|
1158
1319
|
|
|
1159
1320
|
async #load(): Promise<Settings> {
|
|
1160
|
-
// Project settings load (loadCapability scans cwd) is independent of the
|
|
1161
|
-
// persist chain (storage open → legacy migration → global config.yml read),
|
|
1162
|
-
// so kick it off first and await after the persist chain completes. The
|
|
1163
|
-
// persist steps remain sequential: migration may write config.yml, which
|
|
1164
|
-
// #loadYaml then reads; migration's db fallback needs #storage opened.
|
|
1165
|
-
const projectPromise = this.#loadProjectSettings();
|
|
1166
|
-
|
|
1167
1321
|
try {
|
|
1168
1322
|
if (this.#persist) {
|
|
1169
|
-
|
|
1170
|
-
|
|
1323
|
+
// The legacy agent-dir/database settings merge into config.yml BEFORE
|
|
1324
|
+
// the workflow migrations create the file: #migrateAgentDirAndDatabaseLegacy
|
|
1325
|
+
// drains the database rows even when config.yml already exists, but a
|
|
1326
|
+
// first upgrade run must not let the config-root workflow migration
|
|
1327
|
+
// create the file while the database has not been opened yet. Open the
|
|
1328
|
+
// database best-effort, retrying immediately; if it is genuinely
|
|
1329
|
+
// unavailable, run the workflow migrations anyway so their overrides
|
|
1330
|
+
// are never lost, then re-open and drain afterwards.
|
|
1331
|
+
if (!this.#storage) {
|
|
1332
|
+
try {
|
|
1333
|
+
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
|
|
1334
|
+
} catch {
|
|
1335
|
+
try {
|
|
1336
|
+
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
|
|
1337
|
+
} catch {
|
|
1338
|
+
// Continue without the database; the final retry below
|
|
1339
|
+
// re-opens it for the rest of the load.
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
await this.#migrateAgentDirAndDatabaseLegacy();
|
|
1344
|
+
// The workflow settings migrations write config.yml directly and must
|
|
1345
|
+
// run BEFORE project discovery: project discovery strips the retired
|
|
1346
|
+
// workflow keys from the retained .gjc/settings.json, so on a first
|
|
1347
|
+
// load the migrated config.yml value must already exist when the
|
|
1348
|
+
// project layer is scanned - otherwise settings.get()/gjc config
|
|
1349
|
+
// get/list return the schema default for one cycle.
|
|
1350
|
+
await this.#migrateConfigRootWorkflowSettings();
|
|
1351
|
+
await this.#migrateProjectWorkflowSettings();
|
|
1352
|
+
if (!this.#storage) {
|
|
1353
|
+
// The database was unavailable during the merge: re-open it now
|
|
1354
|
+
// (an unavailable/corrupt database still fails the full load, as
|
|
1355
|
+
// before) and drain the legacy settings into the already-created
|
|
1356
|
+
// config.yml - the merge handles an existing file absent-only.
|
|
1357
|
+
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
|
|
1358
|
+
await this.#migrateAgentDirAndDatabaseLegacy();
|
|
1359
|
+
}
|
|
1171
1360
|
this.#global = await this.#loadYaml(this.#configPath!);
|
|
1361
|
+
} else if (this.#readonly && this.#configPath) {
|
|
1362
|
+
// Read-only inspection path (e.g. `gjc customize doctor`): read
|
|
1363
|
+
// config.yml from disk without opening the DB, running legacy/
|
|
1364
|
+
// config-root/project migrations, or writing/renaming anything.
|
|
1365
|
+
// The effective configuration semantics are otherwise identical:
|
|
1366
|
+
// the same YAML parse, schema reconciliation, in-memory migration,
|
|
1367
|
+
// and project-layer discovery/merge are applied.
|
|
1368
|
+
this.#global = await this.#loadYaml(this.#configPath);
|
|
1172
1369
|
}
|
|
1173
1370
|
if (this.#schemaMigrationPending)
|
|
1174
1371
|
this.#recordLegacyFallbackMigrationPatch("configSchemaVersion", CONFIG_SCHEMA_VERSION);
|
|
1175
1372
|
|
|
1176
|
-
this.#project = await
|
|
1373
|
+
this.#project = await this.#loadProjectSettings();
|
|
1177
1374
|
|
|
1178
1375
|
await this.#normalizeAfterLoad();
|
|
1179
1376
|
if (this.#schemaReport.issues.length > 0) {
|
|
@@ -1270,6 +1467,22 @@ export class Settings implements NotificationSettingsReader {
|
|
|
1270
1467
|
}
|
|
1271
1468
|
const migrated = this.#migrateRawSettings(parsedRaw);
|
|
1272
1469
|
const reconciled = reconcileSettingsSchema(migrated);
|
|
1470
|
+
if (filePath === this.#configPath && hasLegacyCustomImageProvider(parsedRaw)) {
|
|
1471
|
+
reconciled.report.issues.push({
|
|
1472
|
+
path: "providers.image",
|
|
1473
|
+
kind: "invalid",
|
|
1474
|
+
detail: LEGACY_CUSTOM_IMAGE_PROVIDER_DIAGNOSTIC,
|
|
1475
|
+
});
|
|
1476
|
+
reconciled.report.valid = false;
|
|
1477
|
+
if (!this.#legacyCustomImageProviderDiagnosticLogged) {
|
|
1478
|
+
this.#legacyCustomImageProviderDiagnosticLogged = true;
|
|
1479
|
+
logger.error("Settings: legacy custom image provider requires manual migration", {
|
|
1480
|
+
configPath: filePath,
|
|
1481
|
+
modelsPath: path.join(this.#agentDir, "models.yml"),
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1273
1486
|
if (typeof configSchemaVersion === "number" && configSchemaVersion > CONFIG_SCHEMA_VERSION) {
|
|
1274
1487
|
reconciled.report.issues.push({
|
|
1275
1488
|
path: "configSchemaVersion",
|
|
@@ -1287,20 +1500,65 @@ export class Settings implements NotificationSettingsReader {
|
|
|
1287
1500
|
let merged: RawSettings = {};
|
|
1288
1501
|
for (const item of result.items as SettingsCapabilityItem[]) {
|
|
1289
1502
|
if (item.level !== "project") continue;
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1503
|
+
// Retained project settings.json remains discoverable for non-workflow
|
|
1504
|
+
// settings, but workflow keys durably migrated to config.yml must not be
|
|
1505
|
+
// resurrected after `gjc config unset`. Unowned keys stay visible as the
|
|
1506
|
+
// resolver's fallback when migration could not publish ownership.
|
|
1507
|
+
const data = item.path.endsWith(`${path.sep}settings.json`)
|
|
1508
|
+
? await this.#stripRetiredWorkflowKeys(item.path, structuredClone(item.data as RawSettings))
|
|
1509
|
+
: (item.data as RawSettings);
|
|
1510
|
+
const { settings, rejectedNotifications, rejectedCredentialPins } =
|
|
1511
|
+
this.#stripProjectNotificationSettings(data);
|
|
1293
1512
|
if (rejectedNotifications) {
|
|
1294
1513
|
logger.warn("Settings: ignoring project notification settings", { path: item.path });
|
|
1295
1514
|
}
|
|
1515
|
+
if (rejectedCredentialPins) {
|
|
1516
|
+
logger.warn("Settings: ignoring project auth.credentialPins; pins are global-only", { path: item.path });
|
|
1517
|
+
}
|
|
1296
1518
|
merged = this.#deepMerge(merged, settings);
|
|
1297
1519
|
}
|
|
1298
1520
|
return this.#migrateRawSettings(merged);
|
|
1299
|
-
} catch {
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
// A malformed project file is tolerated (its layer is skipped), but a
|
|
1523
|
+
// marker-read failure (EACCES/EISDIR/transient I/O - an fs error with
|
|
1524
|
+
// a code, not a parse error) must PROPAGATE: silently returning {}
|
|
1525
|
+
// would drop the entire project layer, including valid config.yml
|
|
1526
|
+
// values, on a transient failure.
|
|
1527
|
+
if (
|
|
1528
|
+
error !== null &&
|
|
1529
|
+
typeof error === "object" &&
|
|
1530
|
+
"code" in error &&
|
|
1531
|
+
(error as { code?: unknown }).code !== "ENOENT"
|
|
1532
|
+
) {
|
|
1533
|
+
throw error;
|
|
1534
|
+
}
|
|
1300
1535
|
return {};
|
|
1301
1536
|
}
|
|
1302
1537
|
}
|
|
1303
1538
|
|
|
1539
|
+
/**
|
|
1540
|
+
* Remove workflow keys from retained project settings.json only when the
|
|
1541
|
+
* migrated-keys marker durably records config.yml ownership. Unowned keys are
|
|
1542
|
+
* normalized from legacy flat dotted form to the nested settings shape so the
|
|
1543
|
+
* generic Settings view agrees with the workflow resolver's fallback.
|
|
1544
|
+
*/
|
|
1545
|
+
async #stripRetiredWorkflowKeys(sourcePath: string, settings: RawSettings): Promise<RawSettings> {
|
|
1546
|
+
const owned = await this.#readProjectMigratedKeys(sourcePath);
|
|
1547
|
+
for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
|
|
1548
|
+
if (owned.has(key)) {
|
|
1549
|
+
if (Object.hasOwn(settings, key)) delete settings[key];
|
|
1550
|
+
deleteByPath(settings, key.split("."));
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
if (Object.hasOwn(settings, key)) {
|
|
1554
|
+
const value = settings[key];
|
|
1555
|
+
delete settings[key];
|
|
1556
|
+
setByPath(settings, key.split("."), value);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return settings;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1304
1562
|
async #normalizeAfterLoad(): Promise<void> {
|
|
1305
1563
|
this.#sanitizeModelSelectorRecords();
|
|
1306
1564
|
this.#rebuildMerged();
|
|
@@ -1445,193 +1703,3740 @@ export class Settings implements NotificationSettingsReader {
|
|
|
1445
1703
|
logger.warn(`Settings: ${message}`);
|
|
1446
1704
|
}
|
|
1447
1705
|
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings as RawSettings));
|
|
1480
|
-
migrated = true;
|
|
1706
|
+
/**
|
|
1707
|
+
* Collect ABSENT-ONLY leaf patches from a legacy settings object against the
|
|
1708
|
+
* current config.yml root: a nested legacy value (e.g. `gjc.ultragoal.
|
|
1709
|
+
* nudgeBudget`) must not be skipped wholesale just because a sibling `gjc`
|
|
1710
|
+
* object already exists - each leaf is merged only when absent, so the
|
|
1711
|
+
* existing values (including the workflow values a migration wrote) are
|
|
1712
|
+
* never clobbered and every drained setting survives.
|
|
1713
|
+
*/
|
|
1714
|
+
/**
|
|
1715
|
+
* Source-overwrite patches for the interrupted-retirement recovery: every
|
|
1716
|
+
* leaf of the source's migrated values becomes a SET patch with the
|
|
1717
|
+
* source's value. Path semantics mirror {@link #collectAbsentLegacyPatches}
|
|
1718
|
+
* (top-level keys split on dots; keys inside records are literal segments
|
|
1719
|
+
* and are never split), so a record with a literal dotted member such as
|
|
1720
|
+
* `modelTags: { "custom.role": { name } }` survives recovery.
|
|
1721
|
+
*/
|
|
1722
|
+
#collectSourceOverwritePatches(value: RawSettings, prefix: string[] = []): AtomicYamlPatch[] {
|
|
1723
|
+
const patches: AtomicYamlPatch[] = [];
|
|
1724
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
1725
|
+
const segments = prefix.length === 0 ? [...prefix, ...key.split(".")] : [...prefix, key];
|
|
1726
|
+
if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
|
|
1727
|
+
patches.push(...this.#collectSourceOverwritePatches(entry as RawSettings, segments));
|
|
1728
|
+
} else {
|
|
1729
|
+
// A literal dotted member (e.g. `custom.role` inside a record) is
|
|
1730
|
+
// not representable in the dotted patch path format: the atomic
|
|
1731
|
+
// pipeline would re-split it into nested segments and either garble
|
|
1732
|
+
// the target or fail the publication. Exclude such leaves so the
|
|
1733
|
+
// recovery completes without corrupting the target's literal
|
|
1734
|
+
// member; the remaining source paths are republished.
|
|
1735
|
+
if (segments.some(segment => segment.includes("."))) continue;
|
|
1736
|
+
patches.push({ path: segments.join("."), op: "set" as const, value: structuredClone(entry) });
|
|
1481
1737
|
}
|
|
1482
|
-
} catch {}
|
|
1483
|
-
|
|
1484
|
-
// 3. Write merged settings through the shared atomic YAML pipeline.
|
|
1485
|
-
if (migrated && Object.keys(settings).length > 0) {
|
|
1486
|
-
try {
|
|
1487
|
-
await applyAtomicYamlPatches(
|
|
1488
|
-
this.#configPath,
|
|
1489
|
-
Object.entries(settings).map(([settingPath, value]) => ({
|
|
1490
|
-
path: settingPath,
|
|
1491
|
-
op: "set" as const,
|
|
1492
|
-
value,
|
|
1493
|
-
})),
|
|
1494
|
-
);
|
|
1495
|
-
logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
|
|
1496
|
-
} catch {}
|
|
1497
1738
|
}
|
|
1739
|
+
return patches;
|
|
1498
1740
|
}
|
|
1499
1741
|
|
|
1500
|
-
#
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1742
|
+
#collectAbsentLegacyPatches(
|
|
1743
|
+
current: Readonly<Record<string, unknown>>,
|
|
1744
|
+
legacy: Readonly<Record<string, unknown>>,
|
|
1745
|
+
prefix: string[] = [],
|
|
1746
|
+
): AtomicYamlPatch[] {
|
|
1747
|
+
const patches: AtomicYamlPatch[] = [];
|
|
1748
|
+
// Keys encountered INSIDE a record value are literal member names, not
|
|
1749
|
+
// dotted paths. A member key containing a dot (e.g. `modelTags` ->
|
|
1750
|
+
// `{ "custom.role": ... }`) cannot be addressed by the dotted patch
|
|
1751
|
+
// grammar as a leaf, so such a record is merged as a WHOLE at its
|
|
1752
|
+
// (dot-free) enclosing path instead of leaf-by-leaf. Only the top-level
|
|
1753
|
+
// legacy keys are flat dotted paths (the database's row-key format), and
|
|
1754
|
+
// they are never literal keys.
|
|
1755
|
+
if (prefix.length > 0 && Object.keys(legacy).some(key => key.includes("."))) {
|
|
1756
|
+
const currentRecord = getByPath(current, prefix);
|
|
1757
|
+
// An OCCUPIED non-record enclosing path (scalar, array, or null)
|
|
1758
|
+
// must be left unchanged: emitting a whole-record merge would
|
|
1759
|
+
// replace the modern value (e.g. `modelTags: "custom"`) and then
|
|
1760
|
+
// clear the legacy rows, violating the absent-only migration
|
|
1761
|
+
// contract. Only absent or record-valued paths participate.
|
|
1762
|
+
if (
|
|
1763
|
+
currentRecord !== undefined &&
|
|
1764
|
+
(currentRecord === null || typeof currentRecord !== "object" || Array.isArray(currentRecord))
|
|
1765
|
+
) {
|
|
1766
|
+
return patches;
|
|
1767
|
+
}
|
|
1768
|
+
patches.push({
|
|
1769
|
+
path: prefix.join("."),
|
|
1770
|
+
op: "set" as const,
|
|
1771
|
+
value:
|
|
1772
|
+
currentRecord === undefined
|
|
1773
|
+
? legacy
|
|
1774
|
+
: this.#mergeAbsentRecords(currentRecord as Record<string, unknown>, legacy),
|
|
1775
|
+
});
|
|
1776
|
+
return patches;
|
|
1505
1777
|
}
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1778
|
+
for (const [key, value] of Object.entries(legacy)) {
|
|
1779
|
+
// Split flat dotted keys into path segments ONLY on the initial call so
|
|
1780
|
+
// the absent check uses the SAME nested interpretation the patch grammar
|
|
1781
|
+
// applies when writing: a legacy database row keyed `theme.dark` must
|
|
1782
|
+
// compare against the nested `theme.dark` value, not a literal top-level
|
|
1783
|
+
// "theme.dark" key (which would report absent and then overwrite the
|
|
1784
|
+
// modern value). Keys inside record values are literal segments and are
|
|
1785
|
+
// never split.
|
|
1786
|
+
const segments = prefix.length === 0 ? [...prefix, ...key.split(".")] : [...prefix, key];
|
|
1787
|
+
const currentValue = getByPath(current, segments);
|
|
1788
|
+
if (currentValue === undefined) {
|
|
1789
|
+
patches.push({ path: segments.join("."), op: "set" as const, value });
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1792
|
+
if (
|
|
1793
|
+
currentValue !== null &&
|
|
1794
|
+
typeof currentValue === "object" &&
|
|
1795
|
+
!Array.isArray(currentValue) &&
|
|
1796
|
+
value !== null &&
|
|
1797
|
+
typeof value === "object" &&
|
|
1798
|
+
!Array.isArray(value)
|
|
1799
|
+
) {
|
|
1800
|
+
patches.push(...this.#collectAbsentLegacyPatches(current, value as Record<string, unknown>, segments));
|
|
1801
|
+
}
|
|
1511
1802
|
}
|
|
1512
|
-
return
|
|
1803
|
+
return patches;
|
|
1513
1804
|
}
|
|
1514
1805
|
|
|
1515
|
-
|
|
1516
|
-
|
|
1806
|
+
/**
|
|
1807
|
+
* Deep-merge a legacy record into the current record at the same path,
|
|
1808
|
+
* adding ONLY absent leaves so existing values are never clobbered. Every
|
|
1809
|
+
* member key is treated as a literal segment name at every level (no
|
|
1810
|
+
* dotted-path interpretation), which is what preserves a key like
|
|
1811
|
+
* `custom.role` inside a `modelTags` record during migration.
|
|
1812
|
+
*/
|
|
1813
|
+
#mergeAbsentRecords(
|
|
1814
|
+
current: Readonly<Record<string, unknown>>,
|
|
1815
|
+
legacy: Readonly<Record<string, unknown>>,
|
|
1816
|
+
): Record<string, unknown> {
|
|
1817
|
+
const merged: Record<string, unknown> = { ...current };
|
|
1818
|
+
for (const [key, value] of Object.entries(legacy)) {
|
|
1819
|
+
const existing = merged[key];
|
|
1820
|
+
if (existing === undefined) {
|
|
1821
|
+
merged[key] = value;
|
|
1822
|
+
} else if (
|
|
1823
|
+
existing !== null &&
|
|
1824
|
+
typeof existing === "object" &&
|
|
1825
|
+
!Array.isArray(existing) &&
|
|
1826
|
+
value !== null &&
|
|
1827
|
+
typeof value === "object" &&
|
|
1828
|
+
!Array.isArray(value)
|
|
1829
|
+
) {
|
|
1830
|
+
merged[key] = this.#mergeAbsentRecords(
|
|
1831
|
+
existing as Record<string, unknown>,
|
|
1832
|
+
value as Record<string, unknown>,
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
return merged;
|
|
1517
1837
|
}
|
|
1518
1838
|
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
const configuredVersion = raw.configSchemaVersion;
|
|
1522
|
-
if (configuredVersion === CONFIG_SCHEMA_VERSION) return raw;
|
|
1523
|
-
if (typeof configuredVersion === "number" && configuredVersion > CONFIG_SCHEMA_VERSION) return raw;
|
|
1839
|
+
async #migrateAgentDirAndDatabaseLegacy(): Promise<void> {
|
|
1840
|
+
if (!this.#configPath) return;
|
|
1524
1841
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1842
|
+
let settings: RawSettings = {};
|
|
1843
|
+
let migrated = false;
|
|
1844
|
+
// The source-only migrated values (tracked for the interrupted-retirement
|
|
1845
|
+
// recovery: their CURRENT values must be REPUBLISHED, not absent-only, so
|
|
1846
|
+
// a concurrent edit after the interrupted run is never silently lost).
|
|
1847
|
+
let settingsJsonMigrated: RawSettings | null = null;
|
|
1848
|
+
|
|
1849
|
+
// 1. Migrate from settings.json (one-time via the .bak rename; runs only
|
|
1850
|
+
// when config.yml is absent so it never overwrites a completed surface,
|
|
1851
|
+
// EXCEPT when a pending-retirement marker exists: a source edited after
|
|
1852
|
+
// an interrupted retirement must be RE-MIGRATED (absent-only) instead of
|
|
1853
|
+
// staying permanently ignored by the configExists guard).
|
|
1854
|
+
const configExists = await this.#pathExists(this.#configPath);
|
|
1855
|
+
const settingsJsonPath = path.join(this.#agentDir, "settings.json");
|
|
1856
|
+
const pendingRetirementPath = `${settingsJsonPath}.pending-retirement`;
|
|
1857
|
+
const settingsJsonRetirementMarkerExists = await this.#pathExists(pendingRetirementPath);
|
|
1858
|
+
let settingsJsonRetirementPending = false;
|
|
1859
|
+
let settingsJsonMarkerPersisted = false;
|
|
1860
|
+
let settingsJsonRaw: string | null = null;
|
|
1861
|
+
// Identity (inode) of the VALIDATED bytes, captured from the same
|
|
1862
|
+
// descriptor that read them: the retirement rename is bound to this
|
|
1863
|
+
// identity so a concurrent replacement is never moved to .bak.
|
|
1864
|
+
let settingsJsonIno: number | null = null;
|
|
1865
|
+
// The marker's ORIGINAL sha at method entry (captured BEFORE the
|
|
1866
|
+
// re-read rewrites it): the recovery's source-set patches must compare
|
|
1867
|
+
// the current source against the INTERRUPTED run's recorded revision,
|
|
1868
|
+
// not the just-rewritten marker.
|
|
1869
|
+
let settingsJsonMarkerShaAtEntry: string | null = null;
|
|
1870
|
+
if (settingsJsonRetirementMarkerExists) {
|
|
1871
|
+
settingsJsonMarkerShaAtEntry = await Bun.file(pendingRetirementPath)
|
|
1872
|
+
.text()
|
|
1873
|
+
.catch(() => null);
|
|
1531
1874
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1875
|
+
if (!configExists || settingsJsonRetirementMarkerExists) {
|
|
1876
|
+
try {
|
|
1877
|
+
// Read the bytes and capture the inode from the SAME descriptor:
|
|
1878
|
+
// the retirement rename is bound to the file whose bytes were
|
|
1879
|
+
// validated, so a concurrent replacement (even between the read and
|
|
1880
|
+
// a later stat) is never retired.
|
|
1881
|
+
const sourceHandle = await fs.promises.open(settingsJsonPath, "r");
|
|
1882
|
+
try {
|
|
1883
|
+
settingsJsonIno = (await sourceHandle.stat()).ino;
|
|
1884
|
+
settingsJsonRaw = await sourceHandle.readFile("utf8");
|
|
1885
|
+
} finally {
|
|
1886
|
+
await sourceHandle.close();
|
|
1887
|
+
}
|
|
1888
|
+
const parsed = JSON.parse(settingsJsonRaw);
|
|
1889
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1890
|
+
settingsJsonMigrated = this.#migrateRawSettings(parsed);
|
|
1891
|
+
settings = this.#deepMerge(settings, settingsJsonMigrated);
|
|
1892
|
+
migrated = true;
|
|
1893
|
+
// The .bak retirement is DEFERRED until the combined migration
|
|
1894
|
+
// commits: a later failure (e.g. a malformed agent.db row that
|
|
1895
|
+
// aborts the load) must leave the source discoverable for the
|
|
1896
|
+
// next load instead of stranding its only copy as a .bak.
|
|
1897
|
+
settingsJsonRetirementPending = true;
|
|
1898
|
+
// Persist the pending retirement BEFORE the publication: if the
|
|
1899
|
+
// process exits after the values commit but before the rename,
|
|
1900
|
+
// the next load recognizes the marker and completes the
|
|
1901
|
+
// retirement instead of stranding an active-looking legacy file
|
|
1902
|
+
// whose edits are silently ignored. The marker stores the
|
|
1903
|
+
// source's SHA-256 so a later edit is never retired. The payload
|
|
1904
|
+
// and parent directory are FSYNCED (like the YAML publication
|
|
1905
|
+
// it recovers) so a power loss cannot strand the source with a
|
|
1906
|
+
// lost marker while config.yml already exists. A replaced marker
|
|
1907
|
+
// (a previous interrupted run) is overwritten.
|
|
1908
|
+
try {
|
|
1909
|
+
const markerHandle = await fs.promises.open(pendingRetirementPath, "w", 0o600);
|
|
1910
|
+
try {
|
|
1911
|
+
await markerHandle.writeFile(
|
|
1912
|
+
nodeCrypto.createHash("sha256").update(settingsJsonRaw).digest("hex"),
|
|
1913
|
+
);
|
|
1914
|
+
await markerHandle.sync();
|
|
1915
|
+
} finally {
|
|
1916
|
+
await markerHandle.close();
|
|
1917
|
+
}
|
|
1918
|
+
await fs.promises
|
|
1919
|
+
.open(path.dirname(pendingRetirementPath), "r")
|
|
1920
|
+
.then(async dirHandle => {
|
|
1921
|
+
try {
|
|
1922
|
+
await dirHandle.sync();
|
|
1923
|
+
} finally {
|
|
1924
|
+
await dirHandle.close();
|
|
1925
|
+
}
|
|
1926
|
+
})
|
|
1927
|
+
.catch(() => undefined);
|
|
1928
|
+
settingsJsonMarkerPersisted = true;
|
|
1929
|
+
} catch {}
|
|
1930
|
+
}
|
|
1931
|
+
} catch {}
|
|
1536
1932
|
}
|
|
1537
1933
|
|
|
1538
|
-
// Migrate
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1934
|
+
// 2. Migrate from agent.db (self-draining: the rows are cleared after the
|
|
1935
|
+
// merge, so a later load never re-imports them; runs even when config.yml
|
|
1936
|
+
// already exists so a database that became available after an earlier
|
|
1937
|
+
// failed open is still drained). A transient read failure (e.g.
|
|
1938
|
+
// SQLITE_BUSY escaping the busy timeout) is retried with backoff; on a
|
|
1939
|
+
// persistent failure the rows did NOT participate in the publication, so
|
|
1940
|
+
// the drain below is skipped and they stay for the next load - never
|
|
1941
|
+
// delete database-only rows that were not merged into config.yml.
|
|
1942
|
+
let dbReadSucceeded = false;
|
|
1943
|
+
try {
|
|
1944
|
+
let dbSettings: RawSettings | null = null;
|
|
1945
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1946
|
+
try {
|
|
1947
|
+
dbSettings = (this.#storage?.getSettings() as RawSettings | null) ?? null;
|
|
1948
|
+
break;
|
|
1949
|
+
} catch (error) {
|
|
1950
|
+
const isSqliteBusy =
|
|
1951
|
+
error && typeof error === "object" && (error as { code?: string }).code === "SQLITE_BUSY";
|
|
1952
|
+
if (!isSqliteBusy || attempt === 2) throw error;
|
|
1953
|
+
await Bun.sleep(100 * 2 ** attempt);
|
|
1954
|
+
}
|
|
1557
1955
|
}
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
const taskObj = raw.task as Record<string, unknown> | undefined;
|
|
1562
|
-
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
|
|
1563
|
-
if (isolationObj && "enabled" in isolationObj) {
|
|
1564
|
-
if (typeof isolationObj.enabled === "boolean") {
|
|
1565
|
-
isolationObj.mode = isolationObj.enabled ? "auto" : "none";
|
|
1956
|
+
if (dbSettings) {
|
|
1957
|
+
settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings));
|
|
1958
|
+
migrated = true;
|
|
1566
1959
|
}
|
|
1567
|
-
|
|
1960
|
+
dbReadSucceeded = true;
|
|
1961
|
+
} catch (error) {
|
|
1962
|
+
// A malformed or persistently unreadable legacy database is a data
|
|
1963
|
+
// integrity problem: fail the load so it is actionable instead of
|
|
1964
|
+
// silently continuing without the previously effective rows. The
|
|
1965
|
+
// rows did NOT participate in any publication, so nothing is
|
|
1966
|
+
// drained and they stay in place for repair and the next load.
|
|
1967
|
+
this.#warnLegacyFallbackMigration(
|
|
1968
|
+
`Settings: legacy agent.db settings could not be read (${error instanceof Error ? error.message : String(error)}); failing the load so the database can be repaired`,
|
|
1969
|
+
);
|
|
1970
|
+
throw error;
|
|
1568
1971
|
}
|
|
1569
1972
|
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
if (
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1973
|
+
// 3. Write merged settings through the shared atomic YAML pipeline. When
|
|
1974
|
+
// config.yml already exists (created by the config-root workflow migration,
|
|
1975
|
+
// or a drained-then-reopened database), merge ABSENT-ONLY so the existing
|
|
1976
|
+
// values - including the workflow values the config-root migration wrote -
|
|
1977
|
+
// are never clobbered.
|
|
1978
|
+
if (
|
|
1979
|
+
migrated &&
|
|
1980
|
+
(settingsJsonRetirementPending === false || settingsJsonMarkerPersisted) &&
|
|
1981
|
+
Object.keys(settings).length > 0
|
|
1982
|
+
) {
|
|
1983
|
+
// Publication is best-effort: a write failure (e.g. a CAS conflict with
|
|
1984
|
+
// an external config.yml edit) keeps the legacy rows for the next load
|
|
1985
|
+
// to retry and must never fail the settings load.
|
|
1986
|
+
let published = false;
|
|
1987
|
+
try {
|
|
1988
|
+
// Publish under the config's own transaction so the absent-only
|
|
1989
|
+
// merge runs against the CURRENT durable state and every write is
|
|
1990
|
+
// CAS-guarded: an external editor that replaced config.yml between
|
|
1991
|
+
// the read and the rename must not have its newer file (and any
|
|
1992
|
+
// unrelated settings it carries) silently overwritten. The same
|
|
1993
|
+
// absent-only collection handles both the present and the absent
|
|
1994
|
+
// target (an absent target starts from an empty current record).
|
|
1995
|
+
// Read-only targets are rejected BEFORE any merge, mirroring the
|
|
1996
|
+
// workflow migrations' guards: a scalar/array/null root is malformed
|
|
1997
|
+
// user data (patching would replace the whole document) and a FUTURE
|
|
1998
|
+
// configSchemaVersion is never modified - in both cases the legacy
|
|
1999
|
+
// rows stay for the next load instead of being drained.
|
|
2000
|
+
published = await withAtomicYamlConfigTransaction(this.#configPath, async tx => {
|
|
2001
|
+
const root = tx.root;
|
|
2002
|
+
if (root !== undefined && (root === null || typeof root !== "object" || Array.isArray(root))) {
|
|
2003
|
+
this.#warnLegacyFallbackMigration(
|
|
2004
|
+
`Settings: database workflow migration skipped: ${this.#configPath} has a non-object or null YAML root; leaving legacy rows for the next load`,
|
|
2005
|
+
);
|
|
2006
|
+
return false;
|
|
2007
|
+
}
|
|
2008
|
+
const schemaVersion = (root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
|
|
2009
|
+
if (typeof schemaVersion === "number" && schemaVersion > CONFIG_SCHEMA_VERSION) {
|
|
2010
|
+
this.#warnLegacyFallbackMigration(
|
|
2011
|
+
`Settings: database workflow migration skipped: ${this.#configPath} is a future config schema (configSchemaVersion ${schemaVersion} > ${CONFIG_SCHEMA_VERSION}); leaving legacy rows for the next load`,
|
|
2012
|
+
);
|
|
2013
|
+
return false;
|
|
2014
|
+
}
|
|
2015
|
+
const patches = this.#collectAbsentLegacyPatches(tx.current, settings);
|
|
2016
|
+
// The interrupted-retirement recovery (a pending marker): the
|
|
2017
|
+
// source's CURRENT values are republished as SET patches (not
|
|
2018
|
+
// absent-only) so a concurrent edit after the interrupted run is
|
|
2019
|
+
// preserved instead of staying permanently ignored under the
|
|
2020
|
+
// target's older value.
|
|
2021
|
+
// The interrupted-retirement recovery (a pending marker): the
|
|
2022
|
+
// source's CURRENT values are republished as SET patches (not
|
|
2023
|
+
// absent-only) so a PROVEN post-marker source edit is preserved
|
|
2024
|
+
// instead of staying permanently ignored under the target's older
|
|
2025
|
+
// value. The source hash must DIFFER from the marker's recorded
|
|
2026
|
+
// hash: an unchanged source means the target holds the user's
|
|
2027
|
+
// current surface edits and must NOT be reverted by the stale
|
|
2028
|
+
// source values. Patch paths mirror #collectAbsentLegacyPatches
|
|
2029
|
+
// (top-level keys split on dots; keys inside records are literal
|
|
2030
|
+
// segments and are never split).
|
|
2031
|
+
const sourceSetPatches =
|
|
2032
|
+
settingsJsonRetirementMarkerExists && settingsJsonMigrated !== null
|
|
2033
|
+
? await (async (): Promise<AtomicYamlPatch[]> => {
|
|
2034
|
+
const currentSha =
|
|
2035
|
+
settingsJsonRaw !== null
|
|
2036
|
+
? nodeCrypto.createHash("sha256").update(settingsJsonRaw).digest("hex")
|
|
2037
|
+
: null;
|
|
2038
|
+
// A PROVEN post-marker source edit: the source hash
|
|
2039
|
+
// differs from the INTERRUPTED run's recorded revision
|
|
2040
|
+
// (the marker's entry-time sha). An unchanged source
|
|
2041
|
+
// means the target holds the user's current surface
|
|
2042
|
+
// edits and must NOT be reverted by the stale source
|
|
2043
|
+
// values.
|
|
2044
|
+
if (
|
|
2045
|
+
settingsJsonMarkerShaAtEntry === null ||
|
|
2046
|
+
currentSha === null ||
|
|
2047
|
+
currentSha === settingsJsonMarkerShaAtEntry
|
|
2048
|
+
) {
|
|
2049
|
+
return [];
|
|
2050
|
+
}
|
|
2051
|
+
return this.#collectSourceOverwritePatches(settingsJsonMigrated);
|
|
2052
|
+
})()
|
|
2053
|
+
: [];
|
|
2054
|
+
const allPatches = [...sourceSetPatches, ...patches];
|
|
2055
|
+
if (allPatches.length > 0) {
|
|
2056
|
+
await tx.applyPatches(allPatches);
|
|
2057
|
+
}
|
|
2058
|
+
return true;
|
|
2059
|
+
});
|
|
2060
|
+
} catch {}
|
|
2061
|
+
if (!published) return;
|
|
2062
|
+
if (!dbReadSucceeded) {
|
|
2063
|
+
// The database rows never participated in the publication (the read
|
|
2064
|
+
// failed): draining would delete rows that were not merged into
|
|
2065
|
+
// config.yml. Keep them for the next load, which retries the read.
|
|
2066
|
+
this.#warnLegacyFallbackMigration(
|
|
2067
|
+
`Settings: legacy database settings could not be read; keeping the rows for the next load`,
|
|
2068
|
+
);
|
|
2069
|
+
return;
|
|
1584
2070
|
}
|
|
2071
|
+
// Clear the drained database rows so a later load never re-imports
|
|
2072
|
+
// them (the database has no other one-time guard). A persistent drain
|
|
2073
|
+
// failure PROPAGATES instead of being converted into a successful
|
|
2074
|
+
// migration: the stale rows stay eligible for the absent-only merge, so
|
|
2075
|
+
// a later `gjc config unset` of a migrated key would be resurrected on
|
|
2076
|
+
// the next load. The migration aborts (the settings load fails loudly)
|
|
2077
|
+
// and the next load retries the drain.
|
|
2078
|
+
await this.#storage?.clearSettings();
|
|
2079
|
+
logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
|
|
1585
2080
|
}
|
|
1586
|
-
|
|
1587
|
-
//
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
2081
|
+
// The combined migration committed (database validated and the merged
|
|
2082
|
+
// values published): retire the settings.json source now. Any earlier
|
|
2083
|
+
// failure path (malformed database row, unreadable marker, drain
|
|
2084
|
+
// failure) returns above with the source still in place for the next
|
|
2085
|
+
// load.
|
|
2086
|
+
if (settingsJsonRetirementPending || (await this.#pathExists(`${settingsJsonPath}.pending-retirement`))) {
|
|
2087
|
+
// Revalidate the source BEFORE retiring it: an edit or replacement
|
|
2088
|
+
// after the bytes were read but while this method awaited database
|
|
2089
|
+
// retries, the config transaction, or the drain must NOT be moved to
|
|
2090
|
+
// the inactive .bak - the user's newer settings would be stranded
|
|
2091
|
+
// while config.yml holds the earlier bytes. A changed source stays
|
|
2092
|
+
// active for the next load to re-read.
|
|
2093
|
+
const expectedSha =
|
|
2094
|
+
settingsJsonRaw !== null
|
|
2095
|
+
? nodeCrypto.createHash("sha256").update(settingsJsonRaw).digest("hex")
|
|
2096
|
+
: await Bun.file(`${settingsJsonPath}.pending-retirement`)
|
|
2097
|
+
.text()
|
|
2098
|
+
.catch(() => null);
|
|
2099
|
+
const currentRaw = await Bun.file(settingsJsonPath)
|
|
2100
|
+
.text()
|
|
2101
|
+
.catch(() => null);
|
|
2102
|
+
// PUBLICATION PROOF: the marker alone is not proof that the merged
|
|
2103
|
+
// values were ever written - the publication may have been skipped or
|
|
2104
|
+
// conflicted (e.g. another writer created a future-schema config.yml)
|
|
2105
|
+
// after the marker was persisted. Every workflow key present in the
|
|
2106
|
+
// source must exist in the target config.yml before the source is
|
|
2107
|
+
// retired.
|
|
2108
|
+
let publicationProof = false;
|
|
2109
|
+
if (currentRaw !== null) {
|
|
2110
|
+
try {
|
|
2111
|
+
const targetRaw = await Bun.file(this.#configPath)
|
|
2112
|
+
.text()
|
|
2113
|
+
.catch(() => "");
|
|
2114
|
+
const targetRoot = (YAML.parse(targetRaw) ?? {}) as Record<string, unknown> | null;
|
|
2115
|
+
// Verify EVERY migrated path, not just the workflow keys: the
|
|
2116
|
+
// agent-dir migration merges the entire #migrateRawSettings
|
|
2117
|
+
// result (non-workflow settings included) through
|
|
2118
|
+
// #collectAbsentLegacyPatches, so recovery must prove every
|
|
2119
|
+
// post-migration path (including renamed paths) exists in the
|
|
2120
|
+
// target before retiring the source. The merged `settings`
|
|
2121
|
+
// holds the same paths the publication wrote.
|
|
2122
|
+
const migratedPaths = flattenObjectPaths(settings);
|
|
2123
|
+
publicationProof =
|
|
2124
|
+
targetRoot !== null &&
|
|
2125
|
+
typeof targetRoot === "object" &&
|
|
2126
|
+
migratedPaths.length > 0 &&
|
|
2127
|
+
migratedPaths.every(path => hasPathValue(targetRoot, path));
|
|
2128
|
+
} catch {
|
|
2129
|
+
publicationProof = false;
|
|
1599
2130
|
}
|
|
1600
2131
|
}
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
2132
|
+
let retired = false;
|
|
2133
|
+
if (
|
|
2134
|
+
expectedSha !== null &&
|
|
2135
|
+
currentRaw !== null &&
|
|
2136
|
+
nodeCrypto.createHash("sha256").update(currentRaw).digest("hex") === expectedSha &&
|
|
2137
|
+
publicationProof
|
|
2138
|
+
) {
|
|
2139
|
+
// Identity-guard the rename: only move the exact inode whose bytes
|
|
2140
|
+
// were validated (captured from the same descriptor as the read). A
|
|
2141
|
+
// replacement at any point after the validated read must stay active
|
|
2142
|
+
// (its newer settings are re-migrated by the next load via the
|
|
2143
|
+
// marker gate).
|
|
2144
|
+
const currentIno = await fs.promises
|
|
2145
|
+
.lstat(settingsJsonPath)
|
|
2146
|
+
.then(stat => stat.ino)
|
|
2147
|
+
.catch(() => null);
|
|
2148
|
+
if (settingsJsonIno !== null && settingsJsonIno === currentIno) {
|
|
2149
|
+
try {
|
|
2150
|
+
fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
|
|
2151
|
+
retired = true;
|
|
2152
|
+
} catch {}
|
|
1613
2153
|
}
|
|
1614
2154
|
}
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
2155
|
+
// The pending-retirement marker is consumed ONLY after a successful
|
|
2156
|
+
// retirement: a failed rename (Windows sharing violation, permissions)
|
|
2157
|
+
// must leave the marker so the next load retries instead of stranding
|
|
2158
|
+
// an active-looking legacy file with no retry prompt.
|
|
2159
|
+
if (retired) {
|
|
2160
|
+
try {
|
|
2161
|
+
await fs.promises.rm(`${settingsJsonPath}.pending-retirement`, { force: true });
|
|
2162
|
+
} catch {}
|
|
1619
2163
|
}
|
|
1620
2164
|
}
|
|
2165
|
+
}
|
|
1621
2166
|
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
2167
|
+
/**
|
|
2168
|
+
* One-time migration of the machine-global config-root `settings.json`
|
|
2169
|
+
* (`<configRoot>/settings.json`, normally `~/.gjc/settings.json`) workflow
|
|
2170
|
+
* keys into the environment-selected global agent `config.yml` — the
|
|
2171
|
+
* `GJC_CODING_AGENT_DIR` / `PI_CODING_AGENT_DIR` profile when set, else the
|
|
2172
|
+
* default `<configRoot>/agent/config.yml`. Runs only for the global agent
|
|
2173
|
+
* scope (an explicitly supplied temporary agentDir such as an SDK session
|
|
2174
|
+
* must never consume the machine-global source), inside one critical
|
|
2175
|
+
* section on the target config lock, and migrates only the five workflow
|
|
2176
|
+
* keys that the workflow runtimes read.
|
|
2177
|
+
*
|
|
2178
|
+
* The legacy config-root file is an orphan path: only the workflow runtimes
|
|
2179
|
+
* ever read it, and the earlier Settings migrations never covered it. Keeping
|
|
2180
|
+
* it read-only forever would leave two settings surfaces in conflict, so a
|
|
2181
|
+
* valid source is consumed exactly once (absent-only patches, no-clobber
|
|
2182
|
+
* `.bak`, durable sidecar marker) after which the runtimes' legacy fallback
|
|
2183
|
+
* still works for a user-recreated file.
|
|
2184
|
+
*/
|
|
2185
|
+
async #migrateConfigRootWorkflowSettings(): Promise<void> {
|
|
2186
|
+
if (!this.#configPath) return;
|
|
2187
|
+
// Strengthened pairing gate: only the GLOBAL agent scope may consume the
|
|
2188
|
+
// machine-global source. That includes an environment-selected non-default
|
|
2189
|
+
// profile (GJC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR); a custom/temporary
|
|
2190
|
+
// agentDir (`Settings.loadForScope` for SDK or tests) must never touch it.
|
|
2191
|
+
if (!this.#isGlobalAgentScope()) return;
|
|
2192
|
+
|
|
2193
|
+
const source = path.resolve(getConfigRootDir(), "settings.json");
|
|
2194
|
+
const backup = `${source}.bak`;
|
|
2195
|
+
const markerPath = `${source}.migrated`;
|
|
2196
|
+
const target = path.resolve(this.#configPath);
|
|
2197
|
+
// If the config root is literally the agent dir, the agent-dir migration
|
|
2198
|
+
// already owns this physical source; never double-rename it. When that
|
|
2199
|
+
// migration has ALREADY run (the agent config.yml exists) it leaves the
|
|
2200
|
+
// orphan source's workflow keys unmigrated, so this migration must still
|
|
2201
|
+
// copy them (and retire the orphan).
|
|
2202
|
+
if (source === path.resolve(path.join(this.#agentDir, "settings.json"))) {
|
|
2203
|
+
if (!(await this.#pathExists(this.#configPath))) return;
|
|
2204
|
+
this.#warnLegacyFallbackMigration(
|
|
2205
|
+
`Settings: config-root workflow migration source ${source} is the agent-dir settings file with an existing agent config.yml; migrating the workflow keys and retiring the orphan source`,
|
|
2206
|
+
);
|
|
1633
2207
|
}
|
|
1634
|
-
|
|
2208
|
+
// When GJC runs from the config root itself (typically the user's home
|
|
2209
|
+
// with the default `.gjc`), the config-root source IS the project source
|
|
2210
|
+
// (`<cwd>/.gjc/settings.json`): consuming and retiring it would delete the
|
|
2211
|
+
// project's non-workflow settings from discovery (and could remove a
|
|
2212
|
+
// tracked dotfiles copy). Defer entirely to the project migration, which
|
|
2213
|
+
// keeps the source - but first reconcile the config-root sidecars against
|
|
2214
|
+
// the agent target, which the project migration never touches.
|
|
2215
|
+
if (await this.#configRootCollidesWithProjectSource(source)) {
|
|
2216
|
+
this.#warnLegacyFallbackMigration(
|
|
2217
|
+
`Settings: config-root workflow migration skipped: ${source} is also the project settings source; the project migration handles it and the file is preserved`,
|
|
2218
|
+
);
|
|
2219
|
+
await this.#reconcileConfigRootSidecars(source, target);
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// Strict-invalid retention evidence is valid only while the source still
|
|
2224
|
+
// holds the exact invalid value that caused the abort. A repair, a
|
|
2225
|
+
// removal, or a completed migration must clear it so strict resolution
|
|
2226
|
+
// stops throwing; a still-invalid value is re-recorded below by the same
|
|
2227
|
+
// run's abort.
|
|
2228
|
+
await this.#reconcileConfigRootSidecars(source, target);
|
|
2229
|
+
|
|
2230
|
+
// Short-circuit before touching the target config.yml: with no source,
|
|
2231
|
+
// backup, or marker there is nothing to migrate, and entering the
|
|
2232
|
+
// transaction would parse the target (aborting settings load on a
|
|
2233
|
+
// malformed config.yml even when no migration is needed).
|
|
2234
|
+
const preSourceExists = await this.#pathExists(source);
|
|
2235
|
+
const preBackupExists = await this.#pathExists(backup);
|
|
2236
|
+
const preMarkerExists = await this.#pathExists(markerPath);
|
|
2237
|
+
if (!preSourceExists && !preBackupExists && !preMarkerExists) return;
|
|
2238
|
+
// Tracks whether THIS run durably wrote the pending marker and whether its
|
|
2239
|
+
// target patch committed. A CAS rejection with a pending marker written by
|
|
2240
|
+
// this run but no committed target write must clear the marker (its
|
|
2241
|
+
// migratedKeys claim ownership of never-applied patches); a prior run's
|
|
2242
|
+
// marker is retained as the only evidence its values are migration-written.
|
|
2243
|
+
let pendingMarkerWritten = false;
|
|
2244
|
+
let targetPatchCommitted = false;
|
|
2245
|
+
// A `.bak` created by ANOTHER process after the initial backupExists check
|
|
2246
|
+
// must never be removed by this migration: abort paths may delete a backup
|
|
2247
|
+
// only when this run created it (after a successful no-replace move).
|
|
2248
|
+
let backupCreatedByThisRun = false;
|
|
2249
|
+
// Identity of the backup this run created (captured right after the
|
|
2250
|
+
// no-replace move): abort paths remove the backup only through the
|
|
2251
|
+
// quarantine-based #removeIfStillOurs guard, never by pathname.
|
|
2252
|
+
let backupIno: number | null = null;
|
|
2253
|
+
try {
|
|
2254
|
+
await withAtomicYamlConfigTransaction(target, async tx => {
|
|
2255
|
+
// A config.yml written by a NEWER schema version is intentionally
|
|
2256
|
+
// read-only across Settings; the migration runs before #loadYaml
|
|
2257
|
+
// sets #futureSchemaVersion, so it must check the target schema
|
|
2258
|
+
// itself and never patch it or consume the legacy source.
|
|
2259
|
+
const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
|
|
2260
|
+
if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
|
|
2261
|
+
this.#warnLegacyFallbackMigration(
|
|
2262
|
+
`Settings: config-root workflow migration skipped: ${target} is a future config schema (configSchemaVersion ${targetSchemaVersion} > ${CONFIG_SCHEMA_VERSION})`,
|
|
2263
|
+
);
|
|
2264
|
+
// A future-schema target is read-only, but the retained legacy
|
|
2265
|
+
// source's strict errors must stay observable: the resolver reads
|
|
2266
|
+
// config.yml only, so record strict-invalid evidence when the
|
|
2267
|
+
// source holds invalid ralplan values or is malformed.
|
|
2268
|
+
await this.#retainStrictErrorsFromSource(source, `${source}.strict-invalid`);
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
const markerFileExists = await this.#pathExists(markerPath);
|
|
2272
|
+
let marker = await this.#readWorkflowMigrationMarker(markerPath);
|
|
2273
|
+
// A structurally valid marker that points at different source/backup/
|
|
2274
|
+
// target paths (e.g. the config root moved) must never suppress or
|
|
2275
|
+
// shortcut the migration; treat it as invalid.
|
|
2276
|
+
if (marker && !this.#workflowMigrationMarkerPathsMatch(marker, source, backup, target)) {
|
|
2277
|
+
this.#warnLegacyFallbackMigration(
|
|
2278
|
+
`Settings: config-root workflow migration marker at ${markerPath} does not match the current source/backup/target paths; treating it as invalid`,
|
|
2279
|
+
);
|
|
2280
|
+
marker = null;
|
|
2281
|
+
}
|
|
2282
|
+
// The marker records the identity of the directory that received (or
|
|
2283
|
+
// would receive) the migration write. If that directory was
|
|
2284
|
+
// deleted/recreated or a symlink was repointed, recovery must not
|
|
2285
|
+
// apply the marker's ownership claims to the replacement profile -
|
|
2286
|
+
// for a PENDING marker (claims never completed) and for a COMPLETE
|
|
2287
|
+
// marker (deletion recovery or reconcile would otherwise overwrite
|
|
2288
|
+
// or unset a genuine value in the new profile).
|
|
2289
|
+
if (marker?.status === "pending") {
|
|
2290
|
+
const pendingMarkerIdentity = marker.canonicalTargetIdentity;
|
|
2291
|
+
if (
|
|
2292
|
+
typeof pendingMarkerIdentity !== "string" ||
|
|
2293
|
+
pendingMarkerIdentity.length === 0 ||
|
|
2294
|
+
(await this.#statIdentity(path.dirname(target))) !== pendingMarkerIdentity
|
|
2295
|
+
) {
|
|
2296
|
+
this.#warnLegacyFallbackMigration(
|
|
2297
|
+
`Settings: config-root workflow migration pending marker at ${markerPath} lacks or mismatches the target directory identity; treating it as invalid so recovery never applies its claims to the current profile`,
|
|
2298
|
+
);
|
|
2299
|
+
marker = null;
|
|
2300
|
+
}
|
|
2301
|
+
} else if (marker?.status === "complete") {
|
|
2302
|
+
const completeMarkerIdentity = marker.canonicalTargetIdentity;
|
|
2303
|
+
if (
|
|
2304
|
+
typeof completeMarkerIdentity !== "string" ||
|
|
2305
|
+
completeMarkerIdentity.length === 0 ||
|
|
2306
|
+
(await this.#statIdentity(path.dirname(target))) !== completeMarkerIdentity
|
|
2307
|
+
) {
|
|
2308
|
+
this.#warnLegacyFallbackMigration(
|
|
2309
|
+
`Settings: config-root workflow migration complete marker at ${markerPath} lacks or mismatches the target directory identity; treating it as invalid so recovery never applies its claims to the current profile`,
|
|
2310
|
+
);
|
|
2311
|
+
marker = null;
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
if (marker?.status === "complete") {
|
|
2315
|
+
// The legacy source is RETIRED once the migration completes:
|
|
2316
|
+
// config.yml is the only settings surface and the resolver never
|
|
2317
|
+
// reads settings.json. A crash between the complete-marker
|
|
2318
|
+
// publication and the source quarantine leaves the legacy file
|
|
2319
|
+
// behind, where later user edits are silently ineffective and the
|
|
2320
|
+
// documented cleanup never completes; retire the surviving
|
|
2321
|
+
// revision idempotently here (only bytes matching the marker's
|
|
2322
|
+
// recorded hash, so a concurrent replacement is never deleted). A
|
|
2323
|
+
// POST-COMPLETION RECREATION is preserved: a user who unsets a
|
|
2324
|
+
// migrated key and restores settings.json from the .bak produces a
|
|
2325
|
+
// byte-identical file, so retire only while the target still
|
|
2326
|
+
// satisfies the completed marker's migrated keys.
|
|
2327
|
+
if (
|
|
2328
|
+
(await this.#pathExists(source)) &&
|
|
2329
|
+
(await this.#workflowMigrationTargetSatisfies(tx.root, marker))
|
|
2330
|
+
) {
|
|
2331
|
+
await this.#retireConfigRootSource(source, marker.sourceSha256);
|
|
2332
|
+
}
|
|
2333
|
+
return;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
const sourceExists = await this.#pathExists(source);
|
|
2337
|
+
const backupExists = await this.#pathExists(backup);
|
|
2338
|
+
|
|
2339
|
+
// Valid pending marker: crash-recovery proof only.
|
|
2340
|
+
if (marker?.status === "pending") {
|
|
2341
|
+
if (backupExists && !sourceExists) {
|
|
2342
|
+
// The copy path NEVER removes the source, so its absence
|
|
2343
|
+
// here is an external DELETION: honor it by reverting the
|
|
2344
|
+
// marker-owned target values, removing the backup, and
|
|
2345
|
+
// clearing the marker - instead of finalizing and silently
|
|
2346
|
+
// restoring the deleted overrides.
|
|
2347
|
+
const backupHash = await this.#sha256File(backup);
|
|
2348
|
+
if (backupHash !== marker.sourceSha256 && backupHash !== marker.priorSourceSha256) {
|
|
2349
|
+
this.#warnLegacyFallbackMigration(
|
|
2350
|
+
`Settings: config-root workflow migration pending marker cannot be verified (${backup}); leaving for diagnosis/retry`,
|
|
2351
|
+
);
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
// A deletion during a reconcile transition (the backup still
|
|
2355
|
+
// matches priorSourceSha256) must revert EVERY marker-owned
|
|
2356
|
+
// target - the marker claims them as its repairs. In the
|
|
2357
|
+
// fresh case (backup matches the marker hash) revert only
|
|
2358
|
+
// the values still matching the migration write, preserving
|
|
2359
|
+
// a newer `gjc config set` override.
|
|
2360
|
+
// A deletion during a reconcile transition accepts the
|
|
2361
|
+
// prior-hash backup, but only targets matching a verifiable
|
|
2362
|
+
// migration write (the backup) are reverted: a target the
|
|
2363
|
+
// user replaced after the transition cannot be verified
|
|
2364
|
+
// (the repaired values' source is gone) and is preserved.
|
|
2365
|
+
let deletionBackupDoc: Record<string, unknown> | null = null;
|
|
2366
|
+
// Identity of the verified backup revision (captured inside
|
|
2367
|
+
// the try): the removal below must quarantine and re-verify
|
|
2368
|
+
// before unlinking, because another process can replace the
|
|
2369
|
+
// backup while the recovery patches the target.
|
|
2370
|
+
let deletionBackupIno: number | null = null;
|
|
2371
|
+
// Verified revision hash (captured inside the try); used by
|
|
2372
|
+
// the guarded removal below.
|
|
2373
|
+
let deletionBackupHash: string | null = null;
|
|
2374
|
+
try {
|
|
2375
|
+
const deletionBackupRead = await this.#readBackupBytes(backup);
|
|
2376
|
+
deletionBackupHash = nodeCrypto
|
|
2377
|
+
.createHash("sha256")
|
|
2378
|
+
.update(Buffer.from(deletionBackupRead.bytes))
|
|
2379
|
+
.digest("hex");
|
|
2380
|
+
if (
|
|
2381
|
+
deletionBackupHash !== marker.sourceSha256 &&
|
|
2382
|
+
deletionBackupHash !== marker.priorSourceSha256
|
|
2383
|
+
) {
|
|
2384
|
+
this.#warnLegacyFallbackMigration(
|
|
2385
|
+
`Settings: the migration backup ${backup} no longer matches the marker hash; leaving source/backup/marker untouched`,
|
|
2386
|
+
);
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
deletionBackupIno = (await fs.promises.stat(backup).catch(() => null))?.ino ?? null;
|
|
2390
|
+
deletionBackupDoc = JSON.parse(deletionBackupRead.text) as Record<string, unknown>;
|
|
2391
|
+
} catch {
|
|
2392
|
+
this.#warnLegacyFallbackMigration(
|
|
2393
|
+
`Settings: could not read the migration backup ${backup} for deletion recovery; leaving source/backup/marker untouched`,
|
|
2394
|
+
);
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
const markerOwnedUnsets: AtomicYamlPatch[] = [];
|
|
2398
|
+
const markerFlatKeys: string[] = [];
|
|
2399
|
+
for (const key of marker.migratedKeys) {
|
|
2400
|
+
const targetValue = extractWorkflowSetting(tx.root, key, { flat: false });
|
|
2401
|
+
if (!targetValue.present) continue;
|
|
2402
|
+
const migratedValue = extractWorkflowSetting(deletionBackupDoc, key);
|
|
2403
|
+
if (
|
|
2404
|
+
(migratedValue.present &&
|
|
2405
|
+
this.#coerceWorkflowScalar(key, migratedValue.value) === targetValue.value) ||
|
|
2406
|
+
// A reconcile that COMMITTED its repairs left the recorded
|
|
2407
|
+
// repair values in the target: the deletion must revert
|
|
2408
|
+
// them too (they are migration writes, not user
|
|
2409
|
+
// overrides). The repair is committed only when the
|
|
2410
|
+
// post-apply flag is set (a mere change from the
|
|
2411
|
+
// pre-repair state could be a coincidental user
|
|
2412
|
+
// value).
|
|
2413
|
+
(marker.repairValueHashes?.[key] !== undefined &&
|
|
2414
|
+
nodeCrypto.createHash("sha256").update(JSON.stringify(targetValue.value)).digest("hex") ===
|
|
2415
|
+
marker.repairValueHashes[key] &&
|
|
2416
|
+
marker.repairsApplied === true)
|
|
2417
|
+
) {
|
|
2418
|
+
markerOwnedUnsets.push({ path: key, op: "unset" });
|
|
2419
|
+
if (Object.hasOwn(tx.root as Record<string, unknown>, key)) markerFlatKeys.push(key);
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
await tx.applyPatchesAndRemoveTopLevelKeys(markerOwnedUnsets, markerFlatKeys);
|
|
2423
|
+
if (deletionBackupIno !== null && deletionBackupHash !== null) {
|
|
2424
|
+
await this.#removeIfStillOurs(backup, deletionBackupIno, deletionBackupHash);
|
|
2425
|
+
}
|
|
2426
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
2427
|
+
this.#warnLegacyFallbackMigration(
|
|
2428
|
+
`Settings: config-root workflow migration cleared: ${source} was deleted during pending recovery; marker-owned target values reverted, backup removed`,
|
|
2429
|
+
);
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
if (sourceExists && backupExists) {
|
|
2433
|
+
const sourceStat = await fs.promises.stat(source).catch((error: unknown) => {
|
|
2434
|
+
// Only ENOENT means absence; a transient permission/I-O
|
|
2435
|
+
// failure must not be misread as a source edit (which
|
|
2436
|
+
// would revert marker-owned values and remove recovery
|
|
2437
|
+
// artifacts).
|
|
2438
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
2439
|
+
throw error;
|
|
2440
|
+
});
|
|
2441
|
+
const sourceHash = sourceStat ? await this.#sha256File(source) : "";
|
|
2442
|
+
// Read the backup ONCE: the hash below and the edited-source
|
|
2443
|
+
// parse use the SAME bytes (a second read could observe a
|
|
2444
|
+
// different revision).
|
|
2445
|
+
const pendingBackupRead = await this.#readBackupBytes(backup);
|
|
2446
|
+
const backupHash = nodeCrypto
|
|
2447
|
+
.createHash("sha256")
|
|
2448
|
+
.update(Buffer.from(pendingBackupRead.bytes))
|
|
2449
|
+
.digest("hex");
|
|
2450
|
+
// Capture the identity of the verified backup revision so the
|
|
2451
|
+
// recovery removal below can quarantine and re-verify before
|
|
2452
|
+
// unlinking (another process can replace the backup while
|
|
2453
|
+
// the target patches are applied).
|
|
2454
|
+
const pendingBackupIno = (await fs.promises.stat(backup).catch(() => null))?.ino ?? null;
|
|
2455
|
+
// (Reconciliation of an edited legacy source after completion is
|
|
2456
|
+
// obsolete: settings.json is retired, so a hash mismatch simply
|
|
2457
|
+
// falls through to the mismatch branch below.)
|
|
2458
|
+
if (sourceHash === marker.sourceSha256 && backupHash === marker.sourceSha256) {
|
|
2459
|
+
// Interrupted no-replace move with the target already patched:
|
|
2460
|
+
// the duplicate source is kept ACTIVE - a path-based unlink
|
|
2461
|
+
// after the identity check could delete a rename-replaced
|
|
2462
|
+
// file - and the resolver deactivates the migrated legacy
|
|
2463
|
+
// layer while the source still matches the marker hash
|
|
2464
|
+
// (reactivating it on later edits/recreates). Complete only
|
|
2465
|
+
// when the target actually contains the migrated keys.
|
|
2466
|
+
if (this.#workflowMigrationTargetSatisfies(tx.root, marker)) {
|
|
2467
|
+
const targetIdentity = await this.#workflowMigrationTargetIdentity(target);
|
|
2468
|
+
if (targetIdentity === null) {
|
|
2469
|
+
this.#warnLegacyFallbackMigration(
|
|
2470
|
+
`Settings: config-root workflow migration cannot publish completion because the target directory identity is unavailable; leaving source, backup, and marker pending`,
|
|
2471
|
+
);
|
|
2472
|
+
} else if (
|
|
2473
|
+
typeof marker.targetFileIdentity === "string" &&
|
|
2474
|
+
(await this.#targetFileIdentity(target)) !== marker.targetFileIdentity
|
|
2475
|
+
) {
|
|
2476
|
+
// The marker recorded the FILE that received the migration
|
|
2477
|
+
// write; a replaced file is a genuine override, not a
|
|
2478
|
+
// migration write - never complete behind it.
|
|
2479
|
+
this.#warnLegacyFallbackMigration(
|
|
2480
|
+
`Settings: config-root workflow migration target file ${target} was replaced after the migration write; leaving source, backup, and marker pending`,
|
|
2481
|
+
);
|
|
2482
|
+
} else {
|
|
2483
|
+
await this.#writeWorkflowMigrationMarkerAtomic(markerPath, {
|
|
2484
|
+
...marker,
|
|
2485
|
+
status: "complete",
|
|
2486
|
+
priorSourceSha256: undefined,
|
|
2487
|
+
repairValueHashes: undefined,
|
|
2488
|
+
repairsApplied: undefined,
|
|
2489
|
+
preRepairTargetHashes: undefined,
|
|
2490
|
+
...targetIdentity,
|
|
2491
|
+
completedAt: new Date().toISOString(),
|
|
2492
|
+
});
|
|
2493
|
+
}
|
|
2494
|
+
} else {
|
|
2495
|
+
this.#warnLegacyFallbackMigration(
|
|
2496
|
+
`Settings: config-root workflow migration pending marker has matching source/backup but the target lacks the migrated keys; leaving source and backup untouched`,
|
|
2497
|
+
);
|
|
2498
|
+
}
|
|
2499
|
+
} else {
|
|
2500
|
+
if (backupHash === marker.sourceSha256 && sourceHash !== marker.sourceSha256) {
|
|
2501
|
+
// The user EDITED the still-active source after the
|
|
2502
|
+
// crash: revert ONLY the marker-owned target values
|
|
2503
|
+
// that still match the migration's write (the backup
|
|
2504
|
+
// copy); a newer `gjc config set` override is
|
|
2505
|
+
// preserved. Remove the backup and the pending marker
|
|
2506
|
+
// so the next load re-runs fresh against the edited
|
|
2507
|
+
// source.
|
|
2508
|
+
let editBackupDoc: Record<string, unknown> | null = null;
|
|
2509
|
+
try {
|
|
2510
|
+
// Parse the bytes already read and verified above
|
|
2511
|
+
// (the branch requires backupHash ===
|
|
2512
|
+
// marker.sourceSha256, a subset of the check here).
|
|
2513
|
+
editBackupDoc = JSON.parse(pendingBackupRead.text) as Record<string, unknown>;
|
|
2514
|
+
} catch {
|
|
2515
|
+
this.#warnLegacyFallbackMigration(
|
|
2516
|
+
`Settings: could not read the migration backup ${backup} for edited-source recovery; leaving source/backup/marker untouched`,
|
|
2517
|
+
);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
const markerOwnedUnsets: AtomicYamlPatch[] = [];
|
|
2521
|
+
const markerFlatKeys: string[] = [];
|
|
2522
|
+
for (const key of marker.migratedKeys) {
|
|
2523
|
+
const targetValue = extractWorkflowSetting(tx.root, key, { flat: false });
|
|
2524
|
+
const migratedValue = extractWorkflowSetting(editBackupDoc, key);
|
|
2525
|
+
if (
|
|
2526
|
+
targetValue.present &&
|
|
2527
|
+
migratedValue.present &&
|
|
2528
|
+
this.#coerceWorkflowScalar(key, migratedValue.value) === targetValue.value
|
|
2529
|
+
) {
|
|
2530
|
+
markerOwnedUnsets.push({ path: key, op: "unset" });
|
|
2531
|
+
if (Object.hasOwn(tx.root as Record<string, unknown>, key)) markerFlatKeys.push(key);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
await tx.applyPatchesAndRemoveTopLevelKeys(markerOwnedUnsets, markerFlatKeys);
|
|
2535
|
+
if (pendingBackupIno !== null) {
|
|
2536
|
+
await this.#removeIfStillOurs(backup, pendingBackupIno, backupHash);
|
|
2537
|
+
}
|
|
2538
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
2539
|
+
this.#warnLegacyFallbackMigration(
|
|
2540
|
+
`Settings: config-root workflow migration pending marker source edited after a crash; stale marker-owned target values reverted, user overrides kept, backup removed, marker cleared`,
|
|
2541
|
+
);
|
|
2542
|
+
} else {
|
|
2543
|
+
this.#warnLegacyFallbackMigration(
|
|
2544
|
+
`Settings: config-root workflow migration pending marker has both source and backup with mismatched hashes; leaving untouched`,
|
|
2545
|
+
);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
if (!sourceExists && !backupExists) {
|
|
2551
|
+
this.#warnLegacyFallbackMigration(
|
|
2552
|
+
`Settings: config-root workflow migration pending marker without source or backup; leaving for diagnosis`,
|
|
2553
|
+
);
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
// pending | yes | no — fall through and re-run the idempotent fresh
|
|
2557
|
+
// transaction (absent-only patches make re-application harmless).
|
|
2558
|
+
} else if (sourceExists && backupExists) {
|
|
2559
|
+
// Absent/invalid marker with a pre-existing backup is ambiguous;
|
|
2560
|
+
// never consume or overwrite either file.
|
|
2561
|
+
this.#warnLegacyFallbackMigration(
|
|
2562
|
+
`Settings: config-root workflow migration found pre-existing ${backup} without a valid marker; leaving source and backup untouched`,
|
|
2563
|
+
);
|
|
2564
|
+
return;
|
|
2565
|
+
} else if (!sourceExists && backupExists) {
|
|
2566
|
+
// Orphan backup: values may already be in the target; keep both
|
|
2567
|
+
// recoverable and never infer completion from the backup alone.
|
|
2568
|
+
this.#warnLegacyFallbackMigration(
|
|
2569
|
+
`Settings: config-root workflow migration found orphan backup ${backup} without a marker; leaving it untouched`,
|
|
2570
|
+
);
|
|
2571
|
+
return;
|
|
2572
|
+
} else if (!sourceExists && !backupExists) {
|
|
2573
|
+
return;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
// Fresh transaction (or pending | yes | no re-run): source exists,
|
|
2577
|
+
// backup absent. All steps run under the target config lock.
|
|
2578
|
+
let sourceRaw: string;
|
|
2579
|
+
try {
|
|
2580
|
+
sourceRaw = await Bun.file(source).text();
|
|
2581
|
+
} catch (error) {
|
|
2582
|
+
// An EXISTING but unreadable source (EACCES, EIO) must keep the
|
|
2583
|
+
// strict error observable: record malformed evidence so the
|
|
2584
|
+
// direct command fails loudly instead of silently defaulting.
|
|
2585
|
+
if (!isEnoent(error)) {
|
|
2586
|
+
await this.#recordConfigRootMalformedEvidence(tx, source);
|
|
2587
|
+
}
|
|
2588
|
+
this.#warnLegacyFallbackMigration(
|
|
2589
|
+
`Settings: config-root workflow migration could not read ${source}; leaving untouched`,
|
|
2590
|
+
);
|
|
2591
|
+
return;
|
|
2592
|
+
}
|
|
2593
|
+
const sourceSha256 = nodeCrypto.createHash("sha256").update(sourceRaw).digest("hex");
|
|
2594
|
+
let sourceDoc: unknown;
|
|
2595
|
+
try {
|
|
2596
|
+
sourceDoc = JSON.parse(sourceRaw);
|
|
2597
|
+
} catch {
|
|
2598
|
+
if (await this.#configRootSourceMatches(source, sourceSha256)) {
|
|
2599
|
+
await this.#recordConfigRootMalformedEvidence(tx, source);
|
|
2600
|
+
}
|
|
2601
|
+
this.#warnLegacyFallbackMigration(
|
|
2602
|
+
`Settings: config-root workflow migration found malformed JSON in ${source}; leaving source/backup/marker unchanged`,
|
|
2603
|
+
);
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2606
|
+
// A `null` document root is malformed per the strict resolver (exit
|
|
2607
|
+
// 2); the migration must not consume it (empty keys + .bak +
|
|
2608
|
+
// complete marker would silently default). Leave the source active
|
|
2609
|
+
// so the strict failure stays loud.
|
|
2610
|
+
if (
|
|
2611
|
+
sourceDoc === null ||
|
|
2612
|
+
sourceDoc === undefined ||
|
|
2613
|
+
typeof sourceDoc !== "object" ||
|
|
2614
|
+
Array.isArray(sourceDoc)
|
|
2615
|
+
) {
|
|
2616
|
+
// A `null`/non-object root is malformed per the strict resolver
|
|
2617
|
+
// (exit 2); the migration must not consume it (empty keys +
|
|
2618
|
+
// .bak + complete marker would silently default). Under a
|
|
2619
|
+
// changed-pending recovery (crash after the patch, before the
|
|
2620
|
+
// backup), clear the stale marker-owned target patches so the
|
|
2621
|
+
// malformed source is visible to strict ralplan instead of
|
|
2622
|
+
// being shadowed by the old agent value.
|
|
2623
|
+
// Only clear patches when a backup verifies they are still the
|
|
2624
|
+
// migration write; without one the target values may be newer
|
|
2625
|
+
// overrides and must be preserved.
|
|
2626
|
+
if (marker?.status === "pending" && backupExists) {
|
|
2627
|
+
// A malformed source cannot establish ownership of the target
|
|
2628
|
+
// values. Verify and parse the backup bytes that the marker records
|
|
2629
|
+
// before clearing anything; a post-crash user override must survive.
|
|
2630
|
+
let staleBackupDoc: Record<string, unknown>;
|
|
2631
|
+
try {
|
|
2632
|
+
const staleBackupRead = await this.#readBackupBytes(backup);
|
|
2633
|
+
const staleBackupHash = nodeCrypto
|
|
2634
|
+
.createHash("sha256")
|
|
2635
|
+
.update(Buffer.from(staleBackupRead.bytes))
|
|
2636
|
+
.digest("hex");
|
|
2637
|
+
if (staleBackupHash !== marker.sourceSha256 && staleBackupHash !== marker.priorSourceSha256) {
|
|
2638
|
+
this.#warnLegacyFallbackMigration(
|
|
2639
|
+
`Settings: the migration backup ${backup} no longer matches the marker hash; leaving source/backup/marker untouched`,
|
|
2640
|
+
);
|
|
2641
|
+
return;
|
|
2642
|
+
}
|
|
2643
|
+
const parsedBackup = JSON.parse(staleBackupRead.text) as unknown;
|
|
2644
|
+
if (!parsedBackup || typeof parsedBackup !== "object" || Array.isArray(parsedBackup)) {
|
|
2645
|
+
this.#warnLegacyFallbackMigration(
|
|
2646
|
+
`Settings: the migration backup ${backup} has a non-mapping root; leaving source/backup/marker untouched`,
|
|
2647
|
+
);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
staleBackupDoc = parsedBackup as Record<string, unknown>;
|
|
2651
|
+
} catch {
|
|
2652
|
+
this.#warnLegacyFallbackMigration(
|
|
2653
|
+
`Settings: could not read the migration backup ${backup} for malformed-source recovery; leaving source/backup/marker untouched`,
|
|
2654
|
+
);
|
|
2655
|
+
return;
|
|
2656
|
+
}
|
|
2657
|
+
const staleUnsets: AtomicYamlPatch[] = [];
|
|
2658
|
+
const staleFlatKeys: string[] = [];
|
|
2659
|
+
for (const key of marker.migratedKeys) {
|
|
2660
|
+
const targetValue = extractWorkflowSetting(tx.current, key, { flat: false });
|
|
2661
|
+
if (!targetValue.present) continue;
|
|
2662
|
+
const backupValue = extractWorkflowSetting(staleBackupDoc, key);
|
|
2663
|
+
const targetHash = nodeCrypto
|
|
2664
|
+
.createHash("sha256")
|
|
2665
|
+
.update(JSON.stringify(targetValue.value))
|
|
2666
|
+
.digest("hex");
|
|
2667
|
+
const migrationOwned =
|
|
2668
|
+
(backupValue.present &&
|
|
2669
|
+
this.#coerceWorkflowScalar(key, backupValue.value) === targetValue.value) ||
|
|
2670
|
+
(marker.repairsApplied === true &&
|
|
2671
|
+
marker.repairValueHashes?.[key] !== undefined &&
|
|
2672
|
+
targetHash === marker.repairValueHashes[key]);
|
|
2673
|
+
if (!migrationOwned) continue;
|
|
2674
|
+
staleUnsets.push({ path: key, op: "unset" });
|
|
2675
|
+
if (Object.hasOwn(tx.current as Record<string, unknown>, key)) staleFlatKeys.push(key);
|
|
2676
|
+
}
|
|
2677
|
+
await tx.applyPatchesAndRemoveTopLevelKeys(staleUnsets, staleFlatKeys);
|
|
2678
|
+
}
|
|
2679
|
+
if (await this.#configRootSourceMatches(source, sourceSha256)) {
|
|
2680
|
+
await this.#recordConfigRootMalformedEvidence(tx, source);
|
|
2681
|
+
}
|
|
2682
|
+
this.#warnLegacyFallbackMigration(
|
|
2683
|
+
`Settings: config-root workflow migration found a malformed root in ${source}; leaving source/backup/marker unchanged`,
|
|
2684
|
+
);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
// A `null`/`~` YAML root is treated by #loadYaml as a malformed
|
|
2688
|
+
// config (settings stay read-only until repaired), so the migration
|
|
2689
|
+
// must treat it like the other non-object roots: abort without
|
|
2690
|
+
// writing or consuming the legacy source.
|
|
2691
|
+
if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
|
|
2692
|
+
this.#warnLegacyFallbackMigration(
|
|
2693
|
+
`Settings: config-root workflow migration target ${target} has a non-object or null YAML root; not migrating`,
|
|
2694
|
+
);
|
|
2695
|
+
return;
|
|
2696
|
+
}
|
|
2697
|
+
const targetDoc = tx.root === undefined ? {} : (tx.root as Record<string, unknown>);
|
|
2698
|
+
const migratedKeys: WorkflowSettingKey[] = [];
|
|
2699
|
+
const patches: AtomicYamlPatch[] = [];
|
|
2700
|
+
const flatKeysToRemove: string[] = [];
|
|
2701
|
+
// A pending marker means a crashed run may have left a STALE patch
|
|
2702
|
+
// in the target; if the source changed since that marker, the stale
|
|
2703
|
+
// target value must not suppress the key (it would shadow the edit
|
|
2704
|
+
// and move it to .bak). Reapply the current source value over it.
|
|
2705
|
+
const stalePendingOverride = marker?.status === "pending" && sourceSha256 !== marker.sourceSha256;
|
|
2706
|
+
// Every unresolved STRICT ralplan key is collected across the loop and
|
|
2707
|
+
// written to ONE evidence file after it, so a shared file records all
|
|
2708
|
+
// of them (the project migration does the same); a per-key write on
|
|
2709
|
+
// the first abort would leave later keys silently defaulting.
|
|
2710
|
+
const invalidStrictKeys: { key: WorkflowSettingKey; value: unknown }[] = [];
|
|
2711
|
+
for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
|
|
2712
|
+
// Only keys the crashed run actually recorded are migration-owned
|
|
2713
|
+
// in the changed-pending window: they may be reapplied, unset, or
|
|
2714
|
+
// overridden by the current source, but a key the marker did NOT
|
|
2715
|
+
// record was skipped because config.yml already held a valid
|
|
2716
|
+
// higher-precedence user value, which must never be clobbered.
|
|
2717
|
+
const staleMarkerKey = stalePendingOverride && marker?.migratedKeys.includes(key);
|
|
2718
|
+
const extracted = extractWorkflowSetting(sourceDoc, key);
|
|
2719
|
+
if (extracted.malformedParent) {
|
|
2720
|
+
if (!key.startsWith("gjc.ralplan.")) {
|
|
2721
|
+
// A malformed parent for a TOLERANT workflow key is ignored
|
|
2722
|
+
// like any other invalid tolerant value (the tolerant
|
|
2723
|
+
// runtime falls back); only strict ralplan keys must fail
|
|
2724
|
+
// loudly, so never write a global malformed marker for it.
|
|
2725
|
+
continue;
|
|
2726
|
+
}
|
|
2727
|
+
// A non-mapping workflow parent in the source (e.g.
|
|
2728
|
+
// `{"gjc":{"ralplan":"broken"}}`) is malformed legacy JSON
|
|
2729
|
+
// that strict ralplan must fail on (exit 2); completing the
|
|
2730
|
+
// migration would deactivate the source and silently use
|
|
2731
|
+
// defaults. Under a changed-pending recovery (crash after
|
|
2732
|
+
// the patch), first clear the stale marker-owned target
|
|
2733
|
+
// patches so the malformed source is visible to strict
|
|
2734
|
+
// ralplan instead of being shadowed by the old agent value.
|
|
2735
|
+
// Clear ONLY targets that match a verifiable migration write:
|
|
2736
|
+
// the backup (the migration's copy) or a committed repair
|
|
2737
|
+
// hash (repairsApplied). A target the user replaced after
|
|
2738
|
+
// the crash is a genuine override and must not be cleared
|
|
2739
|
+
// merely because the source is now malformed.
|
|
2740
|
+
if (marker?.status === "pending") {
|
|
2741
|
+
// Hash-verify the backup against the marker BEFORE trusting its
|
|
2742
|
+
// contents: an edited/corrupted backup is not evidence of what
|
|
2743
|
+
// the migration wrote, and a coincidentally matching override
|
|
2744
|
+
// must never be classified as migration-owned.
|
|
2745
|
+
let staleBackupDoc: Record<string, unknown> | null = null;
|
|
2746
|
+
if (backupExists) {
|
|
2747
|
+
try {
|
|
2748
|
+
const staleRead = await this.#readBackupBytes(backup);
|
|
2749
|
+
const staleHash = nodeCrypto
|
|
2750
|
+
.createHash("sha256")
|
|
2751
|
+
.update(Buffer.from(staleRead.bytes))
|
|
2752
|
+
.digest("hex");
|
|
2753
|
+
if (staleHash === marker.sourceSha256 || staleHash === marker.priorSourceSha256) {
|
|
2754
|
+
staleBackupDoc = JSON.parse(staleRead.text) as Record<string, unknown>;
|
|
2755
|
+
}
|
|
2756
|
+
} catch {
|
|
2757
|
+
// Unreadable or hash-mismatched backup: cannot verify
|
|
2758
|
+
// ownership; leave recovery state untouched.
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
const staleUnsets: AtomicYamlPatch[] = [];
|
|
2762
|
+
const staleFlatKeys: string[] = [];
|
|
2763
|
+
for (const ownedKey of marker.migratedKeys) {
|
|
2764
|
+
const ownedValue = extractWorkflowSetting(tx.current, ownedKey, { flat: false });
|
|
2765
|
+
if (!ownedValue.present) continue;
|
|
2766
|
+
const ownedBackupValue = staleBackupDoc
|
|
2767
|
+
? extractWorkflowSetting(staleBackupDoc, ownedKey)
|
|
2768
|
+
: { present: false, value: undefined };
|
|
2769
|
+
const ownedHash = nodeCrypto
|
|
2770
|
+
.createHash("sha256")
|
|
2771
|
+
.update(JSON.stringify(ownedValue.value))
|
|
2772
|
+
.digest("hex");
|
|
2773
|
+
const verifiable =
|
|
2774
|
+
(ownedBackupValue.present &&
|
|
2775
|
+
this.#coerceWorkflowScalar(ownedKey, ownedBackupValue.value) === ownedValue.value) ||
|
|
2776
|
+
(marker.repairsApplied === true &&
|
|
2777
|
+
marker.repairValueHashes?.[ownedKey] !== undefined &&
|
|
2778
|
+
ownedHash === marker.repairValueHashes[ownedKey]);
|
|
2779
|
+
if (verifiable) {
|
|
2780
|
+
staleUnsets.push({ path: ownedKey, op: "unset" });
|
|
2781
|
+
if (Object.hasOwn(tx.current as Record<string, unknown>, ownedKey)) {
|
|
2782
|
+
staleFlatKeys.push(ownedKey);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
await tx.applyPatchesAndRemoveTopLevelKeys(staleUnsets, staleFlatKeys);
|
|
2787
|
+
}
|
|
2788
|
+
if (await this.#configRootSourceMatches(source, sourceSha256)) {
|
|
2789
|
+
await this.#recordConfigRootMalformedEvidence(tx, source);
|
|
2790
|
+
}
|
|
2791
|
+
this.#warnLegacyFallbackMigration(
|
|
2792
|
+
`Settings: config-root workflow migration aborted: ${source} has a non-mapping parent for ${key}; leaving source/backup/marker unchanged`,
|
|
2793
|
+
);
|
|
2794
|
+
return;
|
|
2795
|
+
}
|
|
2796
|
+
if (!extracted.present) {
|
|
2797
|
+
// A key the user REMOVED from the source (after a crash that
|
|
2798
|
+
// had already patched config.yml) should drop its stale
|
|
2799
|
+
// target value, so the deletion is honored. But ownership is
|
|
2800
|
+
// verifiable only against the migration's backup copy: in
|
|
2801
|
+
// the pending no-backup recovery the target value may be a
|
|
2802
|
+
// NEWER `gjc config set` override, so never unset it
|
|
2803
|
+
// blindly - leave it and warn.
|
|
2804
|
+
if (staleMarkerKey && extractWorkflowSetting(targetDoc, key, { flat: false }).present) {
|
|
2805
|
+
if (backupExists) {
|
|
2806
|
+
// Only unset a target that STILL matches the migration's
|
|
2807
|
+
// write (the hash-verified backup copy or committed repair
|
|
2808
|
+
// evidence): a target the user edited after the crash is a
|
|
2809
|
+
// genuine override and must not be removed merely because
|
|
2810
|
+
// the backup exists.
|
|
2811
|
+
const removedKeyTarget = extractWorkflowSetting(targetDoc, key, { flat: false });
|
|
2812
|
+
let removedKeyBackupDoc: Record<string, unknown> | null = null;
|
|
2813
|
+
try {
|
|
2814
|
+
const removedKeyRead = await this.#readBackupBytes(backup);
|
|
2815
|
+
const removedKeyHash = nodeCrypto
|
|
2816
|
+
.createHash("sha256")
|
|
2817
|
+
.update(Buffer.from(removedKeyRead.bytes))
|
|
2818
|
+
.digest("hex");
|
|
2819
|
+
if (
|
|
2820
|
+
removedKeyHash === marker?.sourceSha256 ||
|
|
2821
|
+
removedKeyHash === marker?.priorSourceSha256
|
|
2822
|
+
) {
|
|
2823
|
+
removedKeyBackupDoc = JSON.parse(removedKeyRead.text) as Record<string, unknown>;
|
|
2824
|
+
}
|
|
2825
|
+
} catch {
|
|
2826
|
+
// Unreadable or hash-mismatched backup: cannot verify
|
|
2827
|
+
// ownership; keep the target value.
|
|
2828
|
+
}
|
|
2829
|
+
const removedKeyBackupValue = removedKeyBackupDoc
|
|
2830
|
+
? extractWorkflowSetting(removedKeyBackupDoc, key)
|
|
2831
|
+
: { present: false, value: undefined };
|
|
2832
|
+
const removedKeyTargetHash = nodeCrypto
|
|
2833
|
+
.createHash("sha256")
|
|
2834
|
+
.update(JSON.stringify(removedKeyTarget.value))
|
|
2835
|
+
.digest("hex");
|
|
2836
|
+
const removedKeyVerifiable =
|
|
2837
|
+
(removedKeyBackupValue.present &&
|
|
2838
|
+
this.#coerceWorkflowScalar(key, removedKeyBackupValue.value) ===
|
|
2839
|
+
removedKeyTarget.value) ||
|
|
2840
|
+
(marker?.repairsApplied === true &&
|
|
2841
|
+
marker?.repairValueHashes?.[key] !== undefined &&
|
|
2842
|
+
removedKeyTargetHash === marker?.repairValueHashes[key]);
|
|
2843
|
+
if (removedKeyVerifiable) {
|
|
2844
|
+
patches.push({ path: key, op: "unset" });
|
|
2845
|
+
if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
|
|
2846
|
+
}
|
|
2847
|
+
} else {
|
|
2848
|
+
// Ownership is unverifiable: abort the recovery (the
|
|
2849
|
+
// source stays active) instead of completing with the
|
|
2850
|
+
// key omitted from migratedKeys, which would
|
|
2851
|
+
// deactivate the edited source and leave the stale
|
|
2852
|
+
// target effective permanently.
|
|
2853
|
+
this.#warnLegacyFallbackMigration(
|
|
2854
|
+
`Settings: config-root workflow migration aborted: ${key} removed from ${source} but its target value cannot be verified as the migration write (no backup); keeping the source active`,
|
|
2855
|
+
);
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
continue;
|
|
2860
|
+
}
|
|
2861
|
+
// A *valid* present target value for this key wins: the legacy
|
|
2862
|
+
// config-root value (valid or not) is never observed by the
|
|
2863
|
+
// resolver, so skip this key entirely instead of aborting the
|
|
2864
|
+
// whole migration over a stale overridden value (unless the
|
|
2865
|
+
// target itself holds a stale patch for a marker-recorded key -
|
|
2866
|
+
// see above).
|
|
2867
|
+
const targetValue = extractWorkflowSetting(targetDoc, key, { flat: false });
|
|
2868
|
+
if (targetValue.malformedParent) {
|
|
2869
|
+
// A non-object intermediate in config.yml (e.g.
|
|
2870
|
+
// `gjc: { ralplan: "repair-me" }`) is malformed user data
|
|
2871
|
+
// that #loadYaml would report for repair; writing the
|
|
2872
|
+
// migrated value would silently replace it. Abort and leave
|
|
2873
|
+
// everything untouched.
|
|
2874
|
+
this.#warnLegacyFallbackMigration(
|
|
2875
|
+
`Settings: config-root workflow migration aborted: ${target} has a non-mapping parent for ${key}; leaving source/backup/marker untouched`,
|
|
2876
|
+
);
|
|
2877
|
+
return;
|
|
2878
|
+
}
|
|
2879
|
+
if (targetValue.present && this.#workflowKeyValueIsValid(key, targetValue.value)) {
|
|
2880
|
+
// A *valid* present target value wins unless the key is a
|
|
2881
|
+
// stale marker-owned key under a changed-pending recovery
|
|
2882
|
+
// (staleMarkerKey), where the current source value must be
|
|
2883
|
+
// reapplied over the stale patch below.
|
|
2884
|
+
if (!staleMarkerKey) {
|
|
2885
|
+
// Retry (unchanged source) or a genuine user value:
|
|
2886
|
+
// still schedule the flat-form cleanup for marker-owned
|
|
2887
|
+
// keys so a dotted top-level key left by a crash between
|
|
2888
|
+
// applyPatches and removeTopLevelKeys does not keep
|
|
2889
|
+
// config.yml rejected by the generated schema - and keep
|
|
2890
|
+
// the key in the rebuilt migratedKeys so ownership
|
|
2891
|
+
// survives the marker rewrite.
|
|
2892
|
+
if (marker?.migratedKeys.includes(key)) {
|
|
2893
|
+
// Retain ownership scope across the pre-backup crash window
|
|
2894
|
+
// (the target patch committed but the backup move did not):
|
|
2895
|
+
// the retry's own move creates the durable backup, and every
|
|
2896
|
+
// later unset verifies the target value against that backup,
|
|
2897
|
+
// so an editor's DIFFERENT value is never reclaimed.
|
|
2898
|
+
if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
|
|
2899
|
+
if (!migratedKeys.includes(key)) migratedKeys.push(key);
|
|
2900
|
+
}
|
|
2901
|
+
continue;
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
// Validate the legacy value BEFORE migrating it. An invalid
|
|
2905
|
+
// tolerant value (e.g. `"gjc.ultragoal.nudgeBudget": "bad"`)
|
|
2906
|
+
// must not be copied into the durable config.yml, where
|
|
2907
|
+
// Settings.load()/config doctor would report it on every
|
|
2908
|
+
// startup (previously the tolerant runtime simply ignored it
|
|
2909
|
+
// in settings.json and fell back to the default).
|
|
2910
|
+
if (!this.#workflowKeyValueIsValid(key, extracted.value)) {
|
|
2911
|
+
if (
|
|
2912
|
+
staleMarkerKey &&
|
|
2913
|
+
backupExists &&
|
|
2914
|
+
extractWorkflowSetting(targetDoc, key, { flat: false }).present
|
|
2915
|
+
) {
|
|
2916
|
+
// Changed-pending recovery: unset the stale crashed patch
|
|
2917
|
+
// for a marker-recorded key so the current source value
|
|
2918
|
+
// (valid or invalid, tolerant or strict) is honored -
|
|
2919
|
+
// never leave a stale target value shadowing it.
|
|
2920
|
+
patches.push({ path: key, op: "unset" });
|
|
2921
|
+
if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
|
|
2922
|
+
}
|
|
2923
|
+
// Strict ralplan keys must keep the legacy source active only
|
|
2924
|
+
// when the invalid value would actually be the winning layer:
|
|
2925
|
+
// consuming it would silently fall back to defaults instead of
|
|
2926
|
+
// failing loudly (the strict resolver throws exit 2 on the
|
|
2927
|
+
// invalid value). Tolerant keys are simply skipped.
|
|
2928
|
+
if (key.startsWith("gjc.ralplan.")) {
|
|
2929
|
+
// If the unset above was queued, apply it so the invalid
|
|
2930
|
+
// legacy source is visible (exit 2) instead of being
|
|
2931
|
+
// shadowed by the stale valid target value.
|
|
2932
|
+
if (
|
|
2933
|
+
staleMarkerKey &&
|
|
2934
|
+
backupExists &&
|
|
2935
|
+
extractWorkflowSetting(targetDoc, key, { flat: false }).present
|
|
2936
|
+
) {
|
|
2937
|
+
patches.push({ path: key, op: "unset" });
|
|
2938
|
+
if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
|
|
2939
|
+
}
|
|
2940
|
+
// Apply ALL queued repairs (earlier keys' unsets and this
|
|
2941
|
+
// key's unset) before aborting, so no stale target value
|
|
2942
|
+
// for any marker-recorded key survives in config.yml.
|
|
2943
|
+
// Only under a changed-pending recovery: in a FRESH
|
|
2944
|
+
// migration the queued patches are plain SETs for valid
|
|
2945
|
+
// keys, and applying them on an abort would write
|
|
2946
|
+
// un-marker'd partial artifacts that the target-wins
|
|
2947
|
+
// rule would freeze.
|
|
2948
|
+
if (stalePendingOverride) {
|
|
2949
|
+
// Apply only MARKER-OWNED repairs: fresh SETs for
|
|
2950
|
+
// unrecorded keys must not be committed on an abort
|
|
2951
|
+
// (the marker does not own them; committing would
|
|
2952
|
+
// shadow later source edits forever via the
|
|
2953
|
+
// valid-target guard).
|
|
2954
|
+
const repairPatches = patches.filter(patch =>
|
|
2955
|
+
marker?.migratedKeys.includes(patch.path as WorkflowSettingKey),
|
|
2956
|
+
);
|
|
2957
|
+
const repairFlatKeys = flatKeysToRemove.filter(key =>
|
|
2958
|
+
marker?.migratedKeys.includes(key as WorkflowSettingKey),
|
|
2959
|
+
);
|
|
2960
|
+
// One atomic write for the repairs + flat cleanup.
|
|
2961
|
+
if (repairPatches.length > 0 || repairFlatKeys.length > 0) {
|
|
2962
|
+
await tx.applyPatchesAndRemoveTopLevelKeys(repairPatches, repairFlatKeys);
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
invalidStrictKeys.push({ key, value: extracted.value });
|
|
2966
|
+
this.#warnLegacyFallbackMigration(
|
|
2967
|
+
`Settings: config-root workflow migration aborted: invalid strict ralplan value for ${key} in ${source}; keeping the legacy source active so gjc ralplan still fails loudly`,
|
|
2968
|
+
);
|
|
2969
|
+
continue;
|
|
2970
|
+
}
|
|
2971
|
+
continue;
|
|
2972
|
+
}
|
|
2973
|
+
// The changed-pending REAPPLY overwrites a present target value;
|
|
2974
|
+
// without a backup the migration write is unverifiable and the
|
|
2975
|
+
// target may be an editor's newer value. Abort the recovery
|
|
2976
|
+
// (keeping the source active) instead of completing with the
|
|
2977
|
+
// key omitted from migratedKeys, which would deactivate the
|
|
2978
|
+
// edited source and leave the stale target effective forever.
|
|
2979
|
+
if (stalePendingOverride && marker?.migratedKeys.includes(key) && targetValue.present && !backupExists) {
|
|
2980
|
+
this.#warnLegacyFallbackMigration(
|
|
2981
|
+
`Settings: config-root workflow migration aborted: ${key} reapplied by the source but its target value cannot be verified as the migration write (no backup); keeping the source active`,
|
|
2982
|
+
);
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
migratedKeys.push(key);
|
|
2986
|
+
// Persist the COERCED value (quoted numeric string -> number), not
|
|
2987
|
+
// the raw string: a schema-backed config.yml must hold values the
|
|
2988
|
+
// generated JSON schema accepts and Settings does not need to
|
|
2989
|
+
// re-coerce on every load.
|
|
2990
|
+
patches.push({ path: key, op: "set", value: this.#coerceWorkflowScalar(key, extracted.value) });
|
|
2991
|
+
// Flat keys are checked before nested ones by
|
|
2992
|
+
// extractWorkflowSetting, so an invalid flat key (e.g.
|
|
2993
|
+
// `"gjc.ralplan.maxIterations": bad`) would keep masking the
|
|
2994
|
+
// migrated nested value after the legacy source is moved to .bak.
|
|
2995
|
+
// Remove the flat form verbatim (the patch grammar cannot address
|
|
2996
|
+
// dotted top-level key names).
|
|
2997
|
+
if (Object.hasOwn(targetDoc, key)) flatKeysToRemove.push(key);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// ALL unresolved strict keys were collected above; write ONE evidence
|
|
3001
|
+
// file recording every one of them, then abort so the legacy source
|
|
3002
|
+
// stays active and strict ralplan keeps failing loudly for each key.
|
|
3003
|
+
if (invalidStrictKeys.length > 0) {
|
|
3004
|
+
// Revalidate the source before retaining evidence: an editor may
|
|
3005
|
+
// repair or replace settings.json while the migration runs, and
|
|
3006
|
+
// stale evidence would make the direct command exit 2 for a
|
|
3007
|
+
// now-valid source.
|
|
3008
|
+
if (!(await this.#configRootSourceMatches(source, sourceSha256))) {
|
|
3009
|
+
this.#warnLegacyFallbackMigration(
|
|
3010
|
+
`Settings: config-root workflow migration aborted without evidence: ${source} changed while retaining strict errors`,
|
|
3011
|
+
);
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
const evidenceOk = await this.#writeStrictInvalidEvidence(
|
|
3015
|
+
`${source}.strict-invalid`,
|
|
3016
|
+
source,
|
|
3017
|
+
invalidStrictKeys,
|
|
3018
|
+
);
|
|
3019
|
+
if (!evidenceOk) {
|
|
3020
|
+
// Read-only fallback: persist the invalid values into the target
|
|
3021
|
+
// config.yml (the only surface the strict resolver reads) so the
|
|
3022
|
+
// exit-2 error stays observable when the evidence cannot be
|
|
3023
|
+
// written. Only ABSENT target keys get fallback values: a
|
|
3024
|
+
// present-but-invalid user-authored value already keeps exit-2
|
|
3025
|
+
// observable on its own, and overwriting it would destroy the
|
|
3026
|
+
// user's configuration when the fallback is later cleaned up or
|
|
3027
|
+
// rolled back.
|
|
3028
|
+
const fallbackEntries = invalidStrictKeys.filter(retained => {
|
|
3029
|
+
const targetValue = extractWorkflowSetting(tx.root, retained.key, { flat: false });
|
|
3030
|
+
return !targetValue.malformedParent && !targetValue.present;
|
|
3031
|
+
});
|
|
3032
|
+
if (fallbackEntries.length > 0) {
|
|
3033
|
+
// Publish the ownership marker BEFORE the values: a crash between
|
|
3034
|
+
// the two leaves the marker (and no values), never values without
|
|
3035
|
+
// a marker the next load could not identify or clean up. Without
|
|
3036
|
+
// the marker, skip the values - an untracked fallback would exit
|
|
3037
|
+
// 2 forever with no way to clear it.
|
|
3038
|
+
const fallbackMarkerOk = await this.#writeFallbackInvalidKeys(
|
|
3039
|
+
source,
|
|
3040
|
+
await this.#mergeFallbackInvalidKeys(
|
|
3041
|
+
source,
|
|
3042
|
+
fallbackEntries,
|
|
3043
|
+
this.#configRootFallbackInvalidMarkerPath(source),
|
|
3044
|
+
),
|
|
3045
|
+
this.#configRootFallbackInvalidMarkerPath(source),
|
|
3046
|
+
);
|
|
3047
|
+
if (fallbackMarkerOk) {
|
|
3048
|
+
this.#warnLegacyFallbackMigration(
|
|
3049
|
+
`Settings: config-root workflow migration persisted strict-invalid values into ${target} as fallback evidence`,
|
|
3050
|
+
);
|
|
3051
|
+
await tx.applyPatches(
|
|
3052
|
+
fallbackEntries.map(retained => ({
|
|
3053
|
+
path: retained.key,
|
|
3054
|
+
op: "set" as const,
|
|
3055
|
+
value: retained.value,
|
|
3056
|
+
})),
|
|
3057
|
+
);
|
|
3058
|
+
} else {
|
|
3059
|
+
this.#warnLegacyFallbackMigration(
|
|
3060
|
+
`Settings: config-root workflow migration could not track strict-invalid fallback values; skipping the fallback`,
|
|
3061
|
+
);
|
|
3062
|
+
}
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
// An invalid/untrusted marker must never suppress migration. Preserve
|
|
3069
|
+
// its bytes by a no-clobber quarantine; abort if quarantine is
|
|
3070
|
+
// impossible. (A malformed marker parses to null, so the file's
|
|
3071
|
+
// existence is the signal, not a non-null marker object.)
|
|
3072
|
+
if (markerFileExists && marker === null) {
|
|
3073
|
+
const corruptPath = `${markerPath}.corrupt`;
|
|
3074
|
+
if (!(await this.#moveLegacySourceNoReplace(markerPath, corruptPath))) {
|
|
3075
|
+
this.#warnLegacyFallbackMigration(
|
|
3076
|
+
`Settings: config-root workflow migration could not quarantine invalid marker ${markerPath}; leaving unchanged`,
|
|
3077
|
+
);
|
|
3078
|
+
return;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
const startedAt =
|
|
3083
|
+
marker?.status === "pending" && typeof marker.startedAt === "string"
|
|
3084
|
+
? marker.startedAt
|
|
3085
|
+
: new Date().toISOString();
|
|
3086
|
+
// The pending marker is the ownership record if this run crashes
|
|
3087
|
+
// after the target patch; bind it to the directory that will
|
|
3088
|
+
// receive the write so a later replacement profile can never
|
|
3089
|
+
// inherit its ownership claims.
|
|
3090
|
+
const pendingTargetIdentity = await this.#workflowMigrationTargetIdentity(target);
|
|
3091
|
+
if (pendingTargetIdentity === null) {
|
|
3092
|
+
this.#warnLegacyFallbackMigration(
|
|
3093
|
+
`Settings: config-root workflow migration cannot verify target directory ${path.dirname(target)} before the pending write; leaving source and marker untouched`,
|
|
3094
|
+
);
|
|
3095
|
+
return;
|
|
3096
|
+
}
|
|
3097
|
+
await this.#writeWorkflowMigrationMarkerAtomic(markerPath, {
|
|
3098
|
+
version: WORKFLOW_MIGRATION_MARKER_VERSION,
|
|
3099
|
+
status: "pending",
|
|
3100
|
+
sourcePath: source,
|
|
3101
|
+
backupPath: backup,
|
|
3102
|
+
targetPath: target,
|
|
3103
|
+
...pendingTargetIdentity,
|
|
3104
|
+
sourceSha256,
|
|
3105
|
+
migratedKeys,
|
|
3106
|
+
startedAt,
|
|
3107
|
+
});
|
|
3108
|
+
pendingMarkerWritten = true;
|
|
3109
|
+
|
|
3110
|
+
// The legacy source may have been edited since `sourceSha256` was
|
|
3111
|
+
// computed and the patches built. Re-hash BEFORE writing anything so
|
|
3112
|
+
// a stale patch never lands in the higher-precedence config.yml,
|
|
3113
|
+
// and snapshot the target so a late mismatch can revert it.
|
|
3114
|
+
if ((await this.#sha256File(source)) !== sourceSha256) {
|
|
3115
|
+
// Nothing was patched by THIS run: the pending marker's
|
|
3116
|
+
// migratedKeys would falsely claim ownership of these patches
|
|
3117
|
+
// on the next changed-pending recovery (staleMarkerKey),
|
|
3118
|
+
// letting it overwrite a valid user target override. Remove it
|
|
3119
|
+
// so the next load starts fresh - but ONLY when no marker
|
|
3120
|
+
// existed before this run: a PRIOR run that already patched
|
|
3121
|
+
// config.yml left that marker as the only evidence its target
|
|
3122
|
+
// values are migration-written, so it must be retained.
|
|
3123
|
+
if (!marker) {
|
|
3124
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
3125
|
+
}
|
|
3126
|
+
this.#warnLegacyFallbackMigration(
|
|
3127
|
+
`Settings: config-root workflow migration aborted: ${source} changed during migration; marker ${marker ? "retained" : "cleared"}, source left active for the next load`,
|
|
3128
|
+
);
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
const targetIdentityBeforePatch = await this.#workflowMigrationTargetIdentity(target);
|
|
3132
|
+
if (targetIdentityBeforePatch === null) {
|
|
3133
|
+
this.#warnLegacyFallbackMigration(
|
|
3134
|
+
`Settings: config-root workflow migration cannot verify target directory ${path.dirname(target)} before patch; leaving source and marker pending`,
|
|
3135
|
+
);
|
|
3136
|
+
return;
|
|
3137
|
+
}
|
|
3138
|
+
const prePatchTargetSnapshot = structuredClone(tx.current);
|
|
3139
|
+
// Apply the nested patches AND the flat-form cleanup in a single
|
|
3140
|
+
// atomic write: two separate writes would let an external editor's
|
|
3141
|
+
// config.yml change (which does not participate in the file lock)
|
|
3142
|
+
// land between them and be overwritten.
|
|
3143
|
+
await tx.applyPatchesAndRemoveTopLevelKeys(patches, flatKeysToRemove);
|
|
3144
|
+
targetPatchCommitted = true;
|
|
3145
|
+
// Capture the identity of the config.yml FILE this write produced.
|
|
3146
|
+
// An external editor atomically replacing the file after this point
|
|
3147
|
+
// yields a NEW inode; completion must reject that replacement instead
|
|
3148
|
+
// of publishing ownership for a value the migration never wrote.
|
|
3149
|
+
const targetFileIdentityAfterPatch = await this.#targetFileIdentity(target);
|
|
3150
|
+
if (targetFileIdentityAfterPatch === null) {
|
|
3151
|
+
this.#warnLegacyFallbackMigration(
|
|
3152
|
+
`Settings: config-root workflow migration could not verify the patched target file ${target}; leaving source, backup, and marker pending`,
|
|
3153
|
+
);
|
|
3154
|
+
return;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// Re-hash immediately before the no-replace move; on mismatch, revert
|
|
3158
|
+
// the target to its pre-patch state so the next load re-runs against
|
|
3159
|
+
// the current file instead of resolving a stale agent-config value.
|
|
3160
|
+
let preMoveSourceHash: string | null = null;
|
|
3161
|
+
try {
|
|
3162
|
+
preMoveSourceHash = await this.#sha256File(source);
|
|
3163
|
+
} catch {
|
|
3164
|
+
// The source was deleted after the patch: revert the target and
|
|
3165
|
+
// clear the now-obsolete marker so the deletion is honored (the
|
|
3166
|
+
// later post-copy deletion path does the same).
|
|
3167
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3168
|
+
if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
|
|
3169
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
3170
|
+
this.#warnLegacyFallbackMigration(
|
|
3171
|
+
`Settings: config-root workflow migration aborted: ${source} was deleted during migration; target reverted, ${backupCreatedByThisRun ? "backup removed" : "external backup preserved"}, marker cleared`,
|
|
3172
|
+
);
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
if (preMoveSourceHash !== sourceSha256) {
|
|
3176
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3177
|
+
if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
|
|
3178
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
3179
|
+
this.#warnLegacyFallbackMigration(
|
|
3180
|
+
`Settings: config-root workflow migration aborted: ${source} changed during migration; target reverted, ${backupCreatedByThisRun ? "backup removed" : "external backup preserved"}, marker cleared`,
|
|
3181
|
+
);
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
if (!(await this.#moveLegacySourceNoReplace(source, backup, sourceSha256))) {
|
|
3185
|
+
// The target was already patched; revert it so the higher
|
|
3186
|
+
// precedence config.yml does not shadow the still-active source
|
|
3187
|
+
// (a `.bak` that appeared in the window would otherwise leave
|
|
3188
|
+
// the pending yes/yes recovery row warning on mismatch forever).
|
|
3189
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3190
|
+
this.#warnLegacyFallbackMigration(
|
|
3191
|
+
`Settings: config-root workflow migration could not move ${source} to ${backup} without overwrite; target reverted, pending marker retained for retry`,
|
|
3192
|
+
);
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
backupCreatedByThisRun = true;
|
|
3196
|
+
// Capture the backup's inode for the guarded removals below. The
|
|
3197
|
+
// copy is byte-identical to the verified source at this point
|
|
3198
|
+
// (the move re-hashed the source before completing), so the
|
|
3199
|
+
// expected sha is sourceSha256 and the inode identifies the file
|
|
3200
|
+
// this run created; a backup replaced or edited afterwards fails
|
|
3201
|
+
// the guard and is preserved.
|
|
3202
|
+
try {
|
|
3203
|
+
backupIno = (await fs.promises.stat(backup)).ino;
|
|
3204
|
+
} catch {
|
|
3205
|
+
// The backup vanished right after the move; nothing of ours
|
|
3206
|
+
// to remove on the abort paths.
|
|
3207
|
+
backupIno = null;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// The source may have been edited in the narrow window after the
|
|
3211
|
+
// pre-move check but before/during the move; verify the bytes we
|
|
3212
|
+
// actually moved. The source is kept ACTIVE on every path, so on
|
|
3213
|
+
// mismatch the edit is already live: revert the target and remove
|
|
3214
|
+
// the now-superseded backup so the next load sees pending + source
|
|
3215
|
+
// + no backup and re-runs the fresh transaction against the edited
|
|
3216
|
+
// file (never completing behind a stale hash).
|
|
3217
|
+
if ((await this.#sha256File(backup)) !== sourceSha256) {
|
|
3218
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3219
|
+
if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
|
|
3220
|
+
this.#warnLegacyFallbackMigration(
|
|
3221
|
+
`Settings: config-root workflow migration aborted: ${source} changed during migration; target reverted, backup removed, source left active for the next load`,
|
|
3222
|
+
);
|
|
3223
|
+
return;
|
|
3224
|
+
}
|
|
3225
|
+
// On the copy fallback (filesystems without hard links) the source
|
|
3226
|
+
// is deliberately kept ACTIVE, so a same-key edit after the copy is
|
|
3227
|
+
// shadowed by the higher-precedence patched config.yml; verify the
|
|
3228
|
+
// source (absent = externally deleted) and revert on
|
|
3229
|
+
// mismatch, removing the now-superseded backup so the next load
|
|
3230
|
+
// sees pending + source + no backup and re-runs the fresh
|
|
3231
|
+
// transaction against the edited file.
|
|
3232
|
+
let sourceHashAfterMove: string | null = null;
|
|
3233
|
+
try {
|
|
3234
|
+
sourceHashAfterMove = await this.#sha256File(source);
|
|
3235
|
+
} catch (error) {
|
|
3236
|
+
// The source is kept ACTIVE on every path, so ENOENT can only be
|
|
3237
|
+
// a concurrent DELETION of the legacy file: honor it by undoing
|
|
3238
|
+
// the patch and backup and clearing the pending marker (there is
|
|
3239
|
+
// nothing left to migrate), instead of completing behind the old
|
|
3240
|
+
// values and silently undoing the deletion.
|
|
3241
|
+
if (isEnoent(error)) {
|
|
3242
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3243
|
+
if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
|
|
3244
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
3245
|
+
this.#warnLegacyFallbackMigration(
|
|
3246
|
+
`Settings: config-root workflow migration aborted: ${source} was deleted during migration; target reverted, backup removed, marker cleared`,
|
|
3247
|
+
);
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
// Non-ENOENT read failure: fail closed (revert + retain
|
|
3251
|
+
// pending) rather than completing behind a possibly-edited
|
|
3252
|
+
// source.
|
|
3253
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3254
|
+
this.#warnLegacyFallbackMigration(
|
|
3255
|
+
`Settings: config-root workflow migration aborted: could not re-read ${source} after the move; target reverted, pending marker retained`,
|
|
3256
|
+
);
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
if (sourceHashAfterMove !== null && sourceHashAfterMove !== sourceSha256) {
|
|
3260
|
+
await tx.replaceCurrent(prePatchTargetSnapshot);
|
|
3261
|
+
if (backupIno !== null) await this.#removeIfStillOurs(backup, backupIno, sourceSha256);
|
|
3262
|
+
this.#warnLegacyFallbackMigration(
|
|
3263
|
+
`Settings: config-root workflow migration aborted: ${source} edited after the copy; target reverted, backup removed, source left active for the next load`,
|
|
3264
|
+
);
|
|
3265
|
+
return;
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
const targetIdentity = await this.#workflowMigrationTargetIdentity(target, targetIdentityBeforePatch);
|
|
3269
|
+
if (targetIdentity === null) {
|
|
3270
|
+
this.#warnLegacyFallbackMigration(
|
|
3271
|
+
`Settings: config-root workflow migration target directory changed after patch; leaving source, backup, and marker pending`,
|
|
3272
|
+
);
|
|
3273
|
+
return;
|
|
3274
|
+
}
|
|
3275
|
+
if ((await this.#targetFileIdentity(target)) !== targetFileIdentityAfterPatch) {
|
|
3276
|
+
this.#warnLegacyFallbackMigration(
|
|
3277
|
+
`Settings: config-root workflow migration target file ${target} was replaced after the patch; leaving source, backup, and marker pending`,
|
|
3278
|
+
);
|
|
3279
|
+
return;
|
|
3280
|
+
}
|
|
3281
|
+
await this.#writeWorkflowMigrationMarkerAtomic(markerPath, {
|
|
3282
|
+
version: WORKFLOW_MIGRATION_MARKER_VERSION,
|
|
3283
|
+
status: "complete",
|
|
3284
|
+
sourcePath: source,
|
|
3285
|
+
backupPath: backup,
|
|
3286
|
+
targetPath: target,
|
|
3287
|
+
...targetIdentity,
|
|
3288
|
+
targetFileIdentity: targetFileIdentityAfterPatch,
|
|
3289
|
+
sourceSha256,
|
|
3290
|
+
migratedKeys,
|
|
3291
|
+
startedAt,
|
|
3292
|
+
completedAt: new Date().toISOString(),
|
|
3293
|
+
});
|
|
3294
|
+
// The legacy source is RETIRED once the migration completes: the
|
|
3295
|
+
// resolver no longer reads settings.json (config.yml is the only
|
|
3296
|
+
// settings surface), so a surviving copy - the copy-fallback keeps
|
|
3297
|
+
// the source active - is removed here. The migration's own .bak
|
|
3298
|
+
// remains as the user-data backup. Strict-invalid evidence is cleared
|
|
3299
|
+
// too: the retained invalid state no longer exists, so strict
|
|
3300
|
+
// resolution must stop throwing.
|
|
3301
|
+
await this.#retireConfigRootSource(source, sourceSha256);
|
|
3302
|
+
await fs.promises.rm(`${source}.strict-invalid`, { force: true }).catch(() => undefined);
|
|
3303
|
+
logger.debug("Settings: migrated config-root workflow settings to config.yml", {
|
|
3304
|
+
source,
|
|
3305
|
+
target,
|
|
3306
|
+
migratedKeys,
|
|
3307
|
+
});
|
|
3308
|
+
});
|
|
3309
|
+
} catch (error) {
|
|
3310
|
+
// A CAS rejection means an external editor changed config.yml before
|
|
3311
|
+
// any patch of this run applied: a pending marker's migratedKeys
|
|
3312
|
+
// would falsely claim ownership of never-applied patches, so clear it
|
|
3313
|
+
// A CAS rejection means an external editor changed config.yml before a
|
|
3314
|
+
// write of THIS run applied. The pending marker is RETAINED: in a
|
|
3315
|
+
// changed-pending recovery the prior run already patched config.yml
|
|
3316
|
+
// and the marker is the only evidence that the existing target value
|
|
3317
|
+
// is migration-written - clearing it would let the stale value pass
|
|
3318
|
+
// the valid-target guard and complete with the key omitted. (A
|
|
3319
|
+
// retained marker whose claims were never applied is handled safely
|
|
3320
|
+
// by the unverifiable-ownership abort, which keeps the source active.)
|
|
3321
|
+
if (error instanceof AtomicYamlConflictError) {
|
|
3322
|
+
// A fresh migration wrote its pending marker but an external editor
|
|
3323
|
+
// changed config.yml before the target patch: nothing was applied,
|
|
3324
|
+
// so the marker's migratedKeys must not claim ownership of
|
|
3325
|
+
// never-applied writes (a retry would otherwise record the editor's
|
|
3326
|
+
// matching value as migration-owned). Clear it; a PRIOR run's marker
|
|
3327
|
+
// (preMarkerExists) or a committed target write stays as ownership
|
|
3328
|
+
// evidence for the changed-pending recovery.
|
|
3329
|
+
if (pendingMarkerWritten && !targetPatchCommitted && !preMarkerExists) {
|
|
3330
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
3331
|
+
this.#warnLegacyFallbackMigration(
|
|
3332
|
+
`Settings: config-root workflow migration aborted: ${target} changed externally before the target write; unapplied pending marker cleared`,
|
|
3333
|
+
);
|
|
3334
|
+
} else {
|
|
3335
|
+
this.#warnLegacyFallbackMigration(
|
|
3336
|
+
`Settings: config-root workflow migration aborted: ${target} changed externally during migration; pending marker retained`,
|
|
3337
|
+
);
|
|
3338
|
+
}
|
|
3339
|
+
return;
|
|
3340
|
+
}
|
|
3341
|
+
// A malformed target config.yml must not abort settings load: warn and
|
|
3342
|
+
// leave source/backup/marker untouched so #loadYaml's recoverable
|
|
3343
|
+
// malformed-config diagnostics still run after the migration returns.
|
|
3344
|
+
this.#warnLegacyFallbackMigration(
|
|
3345
|
+
`Settings: config-root workflow migration could not run against ${target}: ${error instanceof Error ? error.message : String(error)}; leaving source/backup/marker untouched`,
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
/**
|
|
3351
|
+
* One-time copy of the PROJECT `.gjc/settings.json` workflow keys into the
|
|
3352
|
+
* project `.gjc/config.yml` (absent-only). The workflow resolver reads
|
|
3353
|
+
* config.yml only, so a project that still stores its ralplan/
|
|
3354
|
+
* deep-interview/ultragoal overrides in the legacy file would silently fall
|
|
3355
|
+
* back to defaults after the config.yml-only switch without this copy.
|
|
3356
|
+
*
|
|
3357
|
+
* Unlike the config-root migration the source file is NOT retired here: the
|
|
3358
|
+
* project settings.json remains a live discovery surface for its non-workflow
|
|
3359
|
+
* settings, so the copy is idempotent (absent-only patches) rather than
|
|
3360
|
+
* consume-once. A later edit of a workflow key in the legacy file is ignored
|
|
3361
|
+
* once config.yml holds the migrated value, matching the "migrate once, then
|
|
3362
|
+
* config.yml is the only workflow surface" contract.
|
|
3363
|
+
*
|
|
3364
|
+
* Runs inside Settings load so both the session path and the direct workflow
|
|
3365
|
+
* commands (which trigger Settings via {@link ensureWorkflowSettingsMigrated})
|
|
3366
|
+
* migrate before any resolver reads config.yml.
|
|
3367
|
+
*/
|
|
3368
|
+
async #migrateProjectWorkflowSettings(): Promise<void> {
|
|
3369
|
+
const projectDir = path.resolve(this.#cwd, ".gjc");
|
|
3370
|
+
const source = path.resolve(projectDir, "settings.json");
|
|
3371
|
+
const target = path.resolve(projectDir, "config.yml");
|
|
3372
|
+
|
|
3373
|
+
// Strict-invalid retention evidence is valid only while the source still
|
|
3374
|
+
// holds the exact invalid value that produced it; a repair or a removal
|
|
3375
|
+
// must clear it so strict resolution stops throwing. Runs before the
|
|
3376
|
+
// source-existence short-circuit so a deleted source also clears it.
|
|
3377
|
+
const evidencePath = this.#projectStrictInvalidEvidencePath(source);
|
|
3378
|
+
const retainedEvidence = await this.#readStrictInvalidEvidence(evidencePath);
|
|
3379
|
+
if (retainedEvidence) {
|
|
3380
|
+
try {
|
|
3381
|
+
// Prune to the still-invalid keys: repairing ONE recorded key must
|
|
3382
|
+
// not drop the evidence for the remaining unresolved keys. Runs
|
|
3383
|
+
// under the target lock so a concurrent process's evidence
|
|
3384
|
+
// publication serializes with this prune.
|
|
3385
|
+
await withAtomicYamlConfigTransaction(target, async () => {
|
|
3386
|
+
await this.#pruneStrictInvalidEvidence(source, evidencePath, retainedEvidence);
|
|
3387
|
+
});
|
|
3388
|
+
} catch {
|
|
3389
|
+
// Transient source read failure: retain the evidence sidecar.
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
// Fallback-invalid cleanup: config.yml values persisted as fallback
|
|
3394
|
+
// evidence are removed when the source no longer holds the invalid value
|
|
3395
|
+
// (key deleted or settings.json removed), so exit 2 does not persist.
|
|
3396
|
+
await this.#clearStaleFallbackInvalidKeys(source, target);
|
|
3397
|
+
|
|
3398
|
+
if (!(await this.#pathExists(source))) return;
|
|
3399
|
+
|
|
3400
|
+
let raw: string;
|
|
3401
|
+
try {
|
|
3402
|
+
raw = await Bun.file(source).text();
|
|
3403
|
+
} catch (error) {
|
|
3404
|
+
if (isEnoent(error)) return;
|
|
3405
|
+
// An EXISTING but unreadable source (EACCES, EIO) must keep the strict
|
|
3406
|
+
// error observable: record malformed evidence so the direct command
|
|
3407
|
+
// fails loudly instead of silently using config.yml/defaults.
|
|
3408
|
+
await this.#recordProjectMalformedEvidence(source, target);
|
|
3409
|
+
this.#warnLegacyFallbackMigration(
|
|
3410
|
+
`Settings: project workflow migration could not read ${source}; recorded malformed evidence`,
|
|
3411
|
+
);
|
|
3412
|
+
return;
|
|
3413
|
+
}
|
|
3414
|
+
let document: unknown;
|
|
3415
|
+
try {
|
|
3416
|
+
document = JSON.parse(raw);
|
|
3417
|
+
} catch {
|
|
3418
|
+
await this.#recordProjectMalformedEvidence(source, target);
|
|
3419
|
+
this.#warnLegacyFallbackMigration(
|
|
3420
|
+
`Settings: project workflow migration found malformed JSON in ${source}; leaving untouched`,
|
|
3421
|
+
);
|
|
3422
|
+
return;
|
|
3423
|
+
}
|
|
3424
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
3425
|
+
await this.#recordProjectMalformedEvidence(source, target);
|
|
3426
|
+
this.#warnLegacyFallbackMigration(
|
|
3427
|
+
`Settings: project workflow migration found a malformed root in ${source}; leaving untouched`,
|
|
3428
|
+
);
|
|
3429
|
+
return;
|
|
3430
|
+
}
|
|
3431
|
+
|
|
3432
|
+
const migratedKeys = await this.#readProjectMigratedKeys(source);
|
|
3433
|
+
const patches: AtomicYamlPatch[] = [];
|
|
3434
|
+
const invalidStrictKeys: { key: WorkflowSettingKey; value: unknown }[] = [];
|
|
3435
|
+
for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
|
|
3436
|
+
// A key already recorded as migrated is owned by config.yml: never
|
|
3437
|
+
// re-import it, even when the target key is absent (e.g. after
|
|
3438
|
+
// `gjc config unset`), so removing a migrated override sticks instead
|
|
3439
|
+
// of the stale legacy value being copied back on the next load.
|
|
3440
|
+
if (migratedKeys.has(key)) continue;
|
|
3441
|
+
const extracted = extractWorkflowSetting(document as Record<string, unknown>, key);
|
|
3442
|
+
if (extracted.malformedParent) {
|
|
3443
|
+
if (!key.startsWith("gjc.ralplan.")) continue;
|
|
3444
|
+
// A non-mapping workflow parent (e.g. `gjc.ralplan: "broken"`) is
|
|
3445
|
+
// malformed legacy JSON that strict ralplan must fail on (exit 2);
|
|
3446
|
+
// record malformed evidence so the strict resolver surfaces it
|
|
3447
|
+
// instead of silently defaulting, and abort the migration. A
|
|
3448
|
+
// malformed parent for a TOLERANT key is skipped like any other
|
|
3449
|
+
// invalid tolerant value - never a global strict marker.
|
|
3450
|
+
await this.#recordProjectMalformedEvidence(source, target);
|
|
3451
|
+
this.#warnLegacyFallbackMigration(
|
|
3452
|
+
`Settings: project workflow migration retained a malformed parent in ${source} for ${key}`,
|
|
3453
|
+
);
|
|
3454
|
+
return;
|
|
3455
|
+
}
|
|
3456
|
+
if (!extracted.present) continue;
|
|
3457
|
+
const value = this.#coerceWorkflowScalar(key, extracted.value);
|
|
3458
|
+
if (!this.#workflowKeyValueIsValid(key, value)) {
|
|
3459
|
+
if (key.startsWith("gjc.ralplan.")) {
|
|
3460
|
+
// Strict ralplan keys must fail loudly while the retained legacy
|
|
3461
|
+
// value is invalid (the resolver reads config.yml only): record
|
|
3462
|
+
// project-layer evidence instead of silently skipping, mirroring
|
|
3463
|
+
// the config-root strict path.
|
|
3464
|
+
invalidStrictKeys.push({ key, value: extracted.value });
|
|
3465
|
+
this.#warnLegacyFallbackMigration(
|
|
3466
|
+
`Settings: project workflow migration retained invalid strict value for ${key} in ${source}`,
|
|
3467
|
+
);
|
|
3468
|
+
continue;
|
|
3469
|
+
}
|
|
3470
|
+
this.#warnLegacyFallbackMigration(
|
|
3471
|
+
`Settings: project workflow migration skipped invalid value for ${key} in ${source}`,
|
|
3472
|
+
);
|
|
3473
|
+
continue;
|
|
3474
|
+
}
|
|
3475
|
+
patches.push({ path: key, op: "set", value });
|
|
3476
|
+
}
|
|
3477
|
+
if (patches.length === 0 && invalidStrictKeys.length === 0) return;
|
|
3478
|
+
|
|
3479
|
+
// Keys this run records as migrated: present in the target (either
|
|
3480
|
+
// already there from an earlier run whose marker was lost, or copied by
|
|
3481
|
+
// this run's commit). Written to the marker only after a successful
|
|
3482
|
+
// publication - never on an abort or a rollback.
|
|
3483
|
+
let newlyOwned: WorkflowSettingKey[] = [];
|
|
3484
|
+
let rollbackRequired = false;
|
|
3485
|
+
let absent: AtomicYamlPatch[] = [];
|
|
3486
|
+
// Pre-publication target states, captured inside the transaction right
|
|
3487
|
+
// before the write and reused by the post-transaction recovery rollback.
|
|
3488
|
+
let beforeStates: Map<string, ProjectTargetBeforeState> = new Map();
|
|
3489
|
+
try {
|
|
3490
|
+
await withAtomicYamlConfigTransaction(target, async tx => {
|
|
3491
|
+
// A target that did not exist when this transaction read it was
|
|
3492
|
+
// CREATED by this run's publication. If the run must roll back, the
|
|
3493
|
+
// rollback leaves an empty config.yml behind, and the resolver treats
|
|
3494
|
+
// any existing non-future target as authoritative - which would
|
|
3495
|
+
// disable the retained settings.json fallback while the marker stays
|
|
3496
|
+
// unwritable. Track it so the rollback can remove the created file.
|
|
3497
|
+
const targetWasAbsent = tx.root === undefined;
|
|
3498
|
+
// A config.yml written by a NEWER schema version is intentionally
|
|
3499
|
+
// read-only across Settings; never patch it (mirrors the config-root
|
|
3500
|
+
// migration guard) so a future schema cannot inherit stale keys.
|
|
3501
|
+
const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
|
|
3502
|
+
if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
|
|
3503
|
+
this.#warnLegacyFallbackMigration(
|
|
3504
|
+
`Settings: project workflow migration skipped: ${target} is a future config schema (configSchemaVersion ${targetSchemaVersion} > ${CONFIG_SCHEMA_VERSION})`,
|
|
3505
|
+
);
|
|
3506
|
+
// A future-schema target is read-only, but the retained legacy
|
|
3507
|
+
// source's strict errors must stay observable: the resolver reads
|
|
3508
|
+
// config.yml only, so record strict-invalid evidence when the
|
|
3509
|
+
// source holds invalid ralplan values or is malformed.
|
|
3510
|
+
await this.#retainStrictErrorsFromSource(source, this.#projectStrictInvalidEvidencePath(source));
|
|
3511
|
+
return;
|
|
3512
|
+
}
|
|
3513
|
+
// A project config.yml whose root is a scalar/array/null is malformed
|
|
3514
|
+
// user data: patching would replace the whole document with the
|
|
3515
|
+
// migrated mapping. Abort, mirroring the config-root migration's
|
|
3516
|
+
// root-shape guard, instead of destroying the user's configuration.
|
|
3517
|
+
if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
|
|
3518
|
+
this.#warnLegacyFallbackMigration(
|
|
3519
|
+
`Settings: project workflow migration target ${target} has a non-object or null YAML root; not migrating`,
|
|
3520
|
+
);
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
// A non-mapping parent in the project config.yml (e.g.
|
|
3524
|
+
// `gjc: "repair-me"` or `gjc: { ralplan: [] }`) is malformed user
|
|
3525
|
+
// data; patching would silently replace it. Abort the migration,
|
|
3526
|
+
// mirroring the config-root guard, instead of treating the malformed
|
|
3527
|
+
// parent as an absent key.
|
|
3528
|
+
for (const patch of patches) {
|
|
3529
|
+
if (extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false }).malformedParent) {
|
|
3530
|
+
this.#warnLegacyFallbackMigration(
|
|
3531
|
+
`Settings: project workflow migration aborted: ${target} has a non-mapping parent for ${patch.path}; leaving untouched`,
|
|
3532
|
+
);
|
|
3533
|
+
return;
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
// Revalidate the source BEFORE any side effect (evidence write, target
|
|
3537
|
+
// patch): evidence must never be written from bytes the source no
|
|
3538
|
+
// longer holds - an invalid-only migration returns right after the
|
|
3539
|
+
// evidence write, so a stale evidence file would make the direct
|
|
3540
|
+
// command exit 2 even after the source was repaired.
|
|
3541
|
+
const sourceChanged = async (): Promise<boolean> => {
|
|
3542
|
+
const currentRaw = await Bun.file(source)
|
|
3543
|
+
.text()
|
|
3544
|
+
.catch((error: unknown) => {
|
|
3545
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "";
|
|
3546
|
+
throw error;
|
|
3547
|
+
});
|
|
3548
|
+
return currentRaw !== raw;
|
|
3549
|
+
};
|
|
3550
|
+
if (await sourceChanged()) {
|
|
3551
|
+
this.#warnLegacyFallbackMigration(
|
|
3552
|
+
`Settings: project workflow migration aborted: ${source} changed while migrating; leaving untouched`,
|
|
3553
|
+
);
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
// A retained invalid strict ralplan key fails loudly only when the
|
|
3557
|
+
// project config.yml has no valid override for it (a valid target
|
|
3558
|
+
// value wins at the project layer). Decide the COMPLETE unresolved
|
|
3559
|
+
// set first, then write ONE evidence file carrying every unresolved
|
|
3560
|
+
// key - a per-key write-then-delete would let a later key's cleanup
|
|
3561
|
+
// drop an earlier key's evidence.
|
|
3562
|
+
const shadowedInvalidKeys: WorkflowSettingKey[] = [];
|
|
3563
|
+
const unresolved = invalidStrictKeys.filter(retained => {
|
|
3564
|
+
const targetValue = extractWorkflowSetting(tx.root, retained.key, { flat: false });
|
|
3565
|
+
const shadowed =
|
|
3566
|
+
!targetValue.malformedParent &&
|
|
3567
|
+
targetValue.present &&
|
|
3568
|
+
this.#workflowKeyValueIsValid(retained.key, targetValue.value);
|
|
3569
|
+
// A strict key shadowed by a valid project target is TOLERATED
|
|
3570
|
+
// (no exit 2 while the target wins), but it must still be
|
|
3571
|
+
// recorded as owned: the retained legacy source keeps the file
|
|
3572
|
+
// for non-workflow settings, and without ownership a later
|
|
3573
|
+
// `gjc config unset` of the target value would resurrect the
|
|
3574
|
+
// invalid legacy value and exit 2 (mirroring the config-root
|
|
3575
|
+
// path, where the source is retired so the value is gone).
|
|
3576
|
+
if (shadowed) shadowedInvalidKeys.push(retained.key);
|
|
3577
|
+
return !shadowed;
|
|
3578
|
+
});
|
|
3579
|
+
if (unresolved.length === 0) {
|
|
3580
|
+
await fs.promises
|
|
3581
|
+
.rm(this.#projectStrictInvalidEvidencePath(source), { force: true })
|
|
3582
|
+
.catch(() => undefined);
|
|
3583
|
+
} else {
|
|
3584
|
+
const evidenceOk = await this.#writeStrictInvalidEvidence(
|
|
3585
|
+
this.#projectStrictInvalidEvidencePath(source),
|
|
3586
|
+
source,
|
|
3587
|
+
unresolved,
|
|
3588
|
+
);
|
|
3589
|
+
if (!evidenceOk) {
|
|
3590
|
+
// Read-only fallback: persist the invalid values into the target
|
|
3591
|
+
// config.yml (the only surface the strict resolver reads) so the
|
|
3592
|
+
// exit-2 error stays observable when the evidence cannot be
|
|
3593
|
+
// written. A later valid legacy value repairs them (the absent
|
|
3594
|
+
// filter below treats a present-but-invalid target as absent).
|
|
3595
|
+
// Only ABSENT target keys get fallback values: a
|
|
3596
|
+
// present-but-invalid user-authored value already keeps exit-2
|
|
3597
|
+
// observable on its own, and overwriting it would destroy the
|
|
3598
|
+
// user's configuration when the fallback is later cleaned up or
|
|
3599
|
+
// rolled back.
|
|
3600
|
+
const fallbackEntries = unresolved.filter(retained => {
|
|
3601
|
+
const targetValue = extractWorkflowSetting(tx.root, retained.key, { flat: false });
|
|
3602
|
+
return !targetValue.malformedParent && !targetValue.present;
|
|
3603
|
+
});
|
|
3604
|
+
if (fallbackEntries.length > 0) {
|
|
3605
|
+
// Publish the ownership marker BEFORE the values: a crash between
|
|
3606
|
+
// the two leaves the marker (and no values), never values without
|
|
3607
|
+
// a marker the next load could not identify or clean up. Without
|
|
3608
|
+
// the marker, skip the values - an untracked fallback would exit
|
|
3609
|
+
// 2 forever with no way to clear it.
|
|
3610
|
+
const fallbackMarkerOk = await this.#writeFallbackInvalidKeys(
|
|
3611
|
+
source,
|
|
3612
|
+
await this.#mergeFallbackInvalidKeys(source, fallbackEntries),
|
|
3613
|
+
);
|
|
3614
|
+
if (fallbackMarkerOk) {
|
|
3615
|
+
this.#warnLegacyFallbackMigration(
|
|
3616
|
+
`Settings: project workflow migration persisted strict-invalid values into ${target} as fallback evidence`,
|
|
3617
|
+
);
|
|
3618
|
+
await tx.applyPatches(
|
|
3619
|
+
fallbackEntries.map(retained => ({
|
|
3620
|
+
path: retained.key,
|
|
3621
|
+
op: "set" as const,
|
|
3622
|
+
value: retained.value,
|
|
3623
|
+
})),
|
|
3624
|
+
);
|
|
3625
|
+
} else {
|
|
3626
|
+
this.#warnLegacyFallbackMigration(
|
|
3627
|
+
`Settings: project workflow migration could not track strict-invalid fallback values; skipping the fallback`,
|
|
3628
|
+
);
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
absent = patches.filter(patch => {
|
|
3634
|
+
const targetValue = extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false });
|
|
3635
|
+
if (targetValue.malformedParent) return false;
|
|
3636
|
+
if (!targetValue.present) return true;
|
|
3637
|
+
// A present but INVALID target value (e.g. a fallback-written strict
|
|
3638
|
+
// value or an editor's mistake) is repairable: a valid legacy value
|
|
3639
|
+
// must win, mirroring the config-root migration's target repair.
|
|
3640
|
+
return !this.#workflowKeyValueIsValid(patch.path as WorkflowSettingKey, targetValue.value);
|
|
3641
|
+
});
|
|
3642
|
+
if (absent.length === 0) {
|
|
3643
|
+
// Every valid source key is already present in config.yml (e.g.
|
|
3644
|
+
// from an earlier run whose marker was lost): they are owned by
|
|
3645
|
+
// config.yml, so record them AND publish the merged marker under
|
|
3646
|
+
// the lock before returning - an all-present first load must still
|
|
3647
|
+
// create ownership, or a later `gjc config unset` would re-import
|
|
3648
|
+
// the stale legacy value.
|
|
3649
|
+
newlyOwned = [...patches.map(patch => patch.path as WorkflowSettingKey), ...shadowedInvalidKeys];
|
|
3650
|
+
const allPresentMarkerOk = await this.#writeProjectMigratedKeys(
|
|
3651
|
+
source,
|
|
3652
|
+
await this.#mergeProjectMigratedKeys(source, newlyOwned),
|
|
3653
|
+
);
|
|
3654
|
+
if (!allPresentMarkerOk) {
|
|
3655
|
+
// Ownership could not be durably recorded: without the marker, a
|
|
3656
|
+
// later `gjc config unset` would be undone by the retained
|
|
3657
|
+
// source. Nothing was published this run (every key was already
|
|
3658
|
+
// present), so there is nothing to roll back; surface the
|
|
3659
|
+
// failure instead of reporting silent completion - the next
|
|
3660
|
+
// load retries the marker publication.
|
|
3661
|
+
this.#warnLegacyFallbackMigration(
|
|
3662
|
+
`Settings: project workflow migration could not record ownership for ${source}; the migrated-keys marker was not written - a later config removal may be re-imported`,
|
|
3663
|
+
);
|
|
3664
|
+
}
|
|
3665
|
+
return;
|
|
3666
|
+
}
|
|
3667
|
+
// Re-verify immediately before the publication (the primary gate):
|
|
3668
|
+
// the resolver reads config.yml only, so a value edited between the
|
|
3669
|
+
// initial read and this write would be committed from stale bytes
|
|
3670
|
+
// and then ignored forever (later loads skip keys already present).
|
|
3671
|
+
if (await sourceChanged()) {
|
|
3672
|
+
this.#warnLegacyFallbackMigration(
|
|
3673
|
+
`Settings: project workflow migration aborted: ${source} changed while migrating; leaving untouched`,
|
|
3674
|
+
);
|
|
3675
|
+
return;
|
|
3676
|
+
}
|
|
3677
|
+
// Capture each key's pre-publication target state so a rollback can
|
|
3678
|
+
// RESTORE repaired keys (present-but-invalid values the migration
|
|
3679
|
+
// overwrites with the valid legacy value) instead of unsetting them -
|
|
3680
|
+
// an unset would silently delete the user's pre-existing
|
|
3681
|
+
// configuration. Keys that were ABSENT before the write are still
|
|
3682
|
+
// rolled back by unsetting.
|
|
3683
|
+
beforeStates = new Map<string, ProjectTargetBeforeState>();
|
|
3684
|
+
for (const patch of absent) {
|
|
3685
|
+
const current = extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false });
|
|
3686
|
+
beforeStates.set(patch.path, {
|
|
3687
|
+
present: !current.malformedParent && current.present,
|
|
3688
|
+
value: current.value,
|
|
3689
|
+
});
|
|
3690
|
+
}
|
|
3691
|
+
const restorePublished = (patches: readonly AtomicYamlPatch[]): AtomicYamlPatch[] =>
|
|
3692
|
+
patches.map(patch => {
|
|
3693
|
+
const before = beforeStates.get(patch.path);
|
|
3694
|
+
return before?.present
|
|
3695
|
+
? { path: patch.path, op: "set" as const, value: before.value }
|
|
3696
|
+
: { path: patch.path, op: "unset" as const };
|
|
3697
|
+
});
|
|
3698
|
+
await tx.applyPatches(absent);
|
|
3699
|
+
// Close the residual publication window: if an editor replaced the
|
|
3700
|
+
// source DURING applyPatches (target CAS, temp write, native
|
|
3701
|
+
// publication), roll back the just-published keys so stale values
|
|
3702
|
+
// never survive in config.yml; the next load retries with the fresh
|
|
3703
|
+
// source. The evidence is cleared too (it was derived from the same
|
|
3704
|
+
// stale bytes).
|
|
3705
|
+
const rollbackMarkerlessPublication = async (
|
|
3706
|
+
reason: string,
|
|
3707
|
+
clearStrictEvidence: boolean,
|
|
3708
|
+
): Promise<void> => {
|
|
3709
|
+
this.#warnLegacyFallbackMigration(
|
|
3710
|
+
`Settings: project workflow migration rolled back ${absent.length} written key(s): ${reason}`,
|
|
3711
|
+
);
|
|
3712
|
+
try {
|
|
3713
|
+
await tx.applyPatches(restorePublished(absent));
|
|
3714
|
+
} catch {
|
|
3715
|
+
// The rollback's whole-file CAS conflicted with an external
|
|
3716
|
+
// config.yml edit; recover AFTER the transaction by reopening
|
|
3717
|
+
// the target and restoring only values that still match the
|
|
3718
|
+
// migration write. An external target edit means the file is no
|
|
3719
|
+
// longer ours to remove.
|
|
3720
|
+
rollbackRequired = true;
|
|
3721
|
+
}
|
|
3722
|
+
if (targetWasAbsent && !rollbackRequired) {
|
|
3723
|
+
await this.#removeProjectMigrationCreatedTarget(tx.configPath, source);
|
|
3724
|
+
}
|
|
3725
|
+
// The strict-invalid evidence is cleared ONLY when the SOURCE
|
|
3726
|
+
// changed (it was derived from the stale bytes): an ownership-
|
|
3727
|
+
// marker failure leaves the source unchanged, so its evidence
|
|
3728
|
+
// stays valid and must keep making the exit-2 observable.
|
|
3729
|
+
if (clearStrictEvidence) {
|
|
3730
|
+
await fs.promises
|
|
3731
|
+
.rm(this.#projectStrictInvalidEvidencePath(source), { force: true })
|
|
3732
|
+
.catch(() => undefined);
|
|
3733
|
+
}
|
|
3734
|
+
};
|
|
3735
|
+
if (await sourceChanged()) {
|
|
3736
|
+
await rollbackMarkerlessPublication(`${source} changed during publication`, true);
|
|
3737
|
+
// The source changed while publishing: the rolled-back keys must
|
|
3738
|
+
// NOT be recorded as owned, or the newer legacy values would be
|
|
3739
|
+
// skipped forever. Stop the transaction callback here; the
|
|
3740
|
+
// post-transaction CAS recovery still runs for a conflicted
|
|
3741
|
+
// in-transaction rollback.
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
// Record EVERY config-owned key: the valid source keys copied by
|
|
3745
|
+
// this run and the pre-existing present-valid keys whose marker was
|
|
3746
|
+
// lost, plus strict keys shadowed by a valid project target (their
|
|
3747
|
+
// slot is owned by config.yml), so a later unset of any of them is
|
|
3748
|
+
// not re-imported from the retained legacy source.
|
|
3749
|
+
newlyOwned = [...patches.map(patch => patch.path as WorkflowSettingKey), ...shadowedInvalidKeys];
|
|
3750
|
+
// Merge the per-key ownership marker under the config lock: a
|
|
3751
|
+
// concurrent migration of the same project may have published its own
|
|
3752
|
+
// marker between our initial read and this transaction, so re-read the
|
|
3753
|
+
// CURRENT marker and merge instead of last-writer-wins replacement.
|
|
3754
|
+
let mergedMarker: readonly WorkflowSettingKey[];
|
|
3755
|
+
try {
|
|
3756
|
+
await SettingsMigrationTestHooks.beforeProjectMarkerMerge?.();
|
|
3757
|
+
mergedMarker = await this.#mergeProjectMigratedKeys(source, newlyOwned);
|
|
3758
|
+
} catch (error) {
|
|
3759
|
+
// The marker became unreadable between the initial read and this
|
|
3760
|
+
// re-read (AFTER the values already committed): ownership cannot
|
|
3761
|
+
// be durably recorded, so roll back the just-published keys
|
|
3762
|
+
// exactly like a failed marker write - a removed key would
|
|
3763
|
+
// otherwise be re-imported once the marker becomes readable
|
|
3764
|
+
// again.
|
|
3765
|
+
await rollbackMarkerlessPublication(
|
|
3766
|
+
`the migrated-keys marker could not be re-read: ${error instanceof Error ? error.message : String(error)}`,
|
|
3767
|
+
false,
|
|
3768
|
+
);
|
|
3769
|
+
return;
|
|
3770
|
+
}
|
|
3771
|
+
const markerOk = await this.#writeProjectMigratedKeys(source, mergedMarker);
|
|
3772
|
+
if (!markerOk) {
|
|
3773
|
+
// Ownership could not be durably recorded: without the marker, a
|
|
3774
|
+
// later config.yml removal would be undone by the retained source.
|
|
3775
|
+
// Undo only the keys THIS RUN wrote - restored repaired keys keep
|
|
3776
|
+
// their pre-existing value, newly copied keys are unset (a
|
|
3777
|
+
// markerless publication is never complete).
|
|
3778
|
+
await rollbackMarkerlessPublication("the migrated-keys marker could not be written", false);
|
|
3779
|
+
return;
|
|
3780
|
+
}
|
|
3781
|
+
});
|
|
3782
|
+
// A CAS-conflicted in-transaction rollback left the published stale
|
|
3783
|
+
// values in place; recover by reopening the target and unsetting only
|
|
3784
|
+
// the migration-published values that still match.
|
|
3785
|
+
if (rollbackRequired) {
|
|
3786
|
+
await this.#rollbackProjectPublishedKeys(target, absent, source, beforeStates);
|
|
3787
|
+
}
|
|
3788
|
+
logger.debug("Settings: migrated project workflow settings into config.yml", { path: target });
|
|
3789
|
+
} catch (error) {
|
|
3790
|
+
// A malformed project config.yml must not abort settings load: the
|
|
3791
|
+
// resolver's own diagnostics still run afterwards, and the legacy
|
|
3792
|
+
// source stays untouched for the next load to retry.
|
|
3793
|
+
this.#warnLegacyFallbackMigration(
|
|
3794
|
+
`Settings: project workflow migration could not write ${target}: ${error instanceof Error ? error.message : String(error)}; leaving untouched`,
|
|
3795
|
+
);
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
/**
|
|
3800
|
+
* Remove a project config.yml that THIS migration run created and then
|
|
3801
|
+
* rolled back. The rollback unsets the published keys but leaves an empty
|
|
3802
|
+
* file behind; because the resolver treats any existing non-future target as
|
|
3803
|
+
* authoritative, that empty file would disable the retained settings.json
|
|
3804
|
+
* fallback. Removing it restores the incomplete-migration state so the next
|
|
3805
|
+
* load (and every retry while the marker stays unwritable) keeps resolving
|
|
3806
|
+
* through the fallback. Only ever called under the target's own file lock
|
|
3807
|
+
* right after the rollback write, and the removal itself is identity-guarded
|
|
3808
|
+
* (an external editor that does not participate in the lock can atomically
|
|
3809
|
+
* replace the file, and that replacement must never be deleted).
|
|
3810
|
+
*/
|
|
3811
|
+
async #removeProjectMigrationCreatedTarget(canonicalTarget: string, source: string): Promise<void> {
|
|
3812
|
+
if (await this.#removeFileOnlyIfUnchanged(canonicalTarget)) {
|
|
3813
|
+
this.#warnLegacyFallbackMigration(
|
|
3814
|
+
`Settings: project workflow migration removed the config.yml it created during rollback so the retained ${source} fallback stays active`,
|
|
3815
|
+
);
|
|
3816
|
+
return;
|
|
3817
|
+
}
|
|
3818
|
+
this.#warnLegacyFallbackMigration(
|
|
3819
|
+
`Settings: project workflow migration could not remove the config.yml it created during rollback; a replaced or retained file is left in place`,
|
|
3820
|
+
);
|
|
3821
|
+
}
|
|
3822
|
+
|
|
3823
|
+
/**
|
|
3824
|
+
* Remove a file only when it is still the exact file observed here. An
|
|
3825
|
+
* external process (an editor that ignores the config lock, or a concurrent
|
|
3826
|
+
* loader publishing evidence) can atomically replace a path between this
|
|
3827
|
+
* run's decision and a pathname removal, so the file is first renamed to a
|
|
3828
|
+
* unique tombstone in the same directory (atomic), the moved file's inode
|
|
3829
|
+
* identity is verified against the observation, and only a matching file is
|
|
3830
|
+
* unlinked. A replaced file is renamed back to its path, never deleted.
|
|
3831
|
+
* Returns true when the observed file is gone (removed or already absent),
|
|
3832
|
+
* false when a replaced or immovable file was left in place.
|
|
3833
|
+
*/
|
|
3834
|
+
async #removeFileOnlyIfUnchanged(filePath: string): Promise<boolean> {
|
|
3835
|
+
let expected: { dev: bigint; ino: bigint };
|
|
3836
|
+
try {
|
|
3837
|
+
const stat = await fs.promises.stat(filePath, { bigint: true });
|
|
3838
|
+
expected = { dev: stat.dev, ino: stat.ino };
|
|
3839
|
+
} catch {
|
|
3840
|
+
return true; // already gone: nothing to remove
|
|
3841
|
+
}
|
|
3842
|
+
const tombstone = `${filePath}.gjc-remove-${nodeCrypto.randomUUID()}`;
|
|
3843
|
+
try {
|
|
3844
|
+
await fs.promises.rename(filePath, tombstone);
|
|
3845
|
+
} catch (error) {
|
|
3846
|
+
if (isEnoent(error)) return true;
|
|
3847
|
+
return false; // could not move: leave in place
|
|
3848
|
+
}
|
|
3849
|
+
let moved: { dev: bigint; ino: bigint } | null = null;
|
|
3850
|
+
try {
|
|
3851
|
+
const stat = await fs.promises.lstat(tombstone, { bigint: true });
|
|
3852
|
+
moved = { dev: stat.dev, ino: stat.ino };
|
|
3853
|
+
} catch {
|
|
3854
|
+
return true; // the moved file already disappeared
|
|
3855
|
+
}
|
|
3856
|
+
if (moved.dev !== expected.dev || moved.ino !== expected.ino) {
|
|
3857
|
+
// The rename moved a file an external process replaced after the
|
|
3858
|
+
// identity capture: restore it WITHOUT replacing a file published
|
|
3859
|
+
// while the path was absent (hard-link no-replace; EEXIST keeps the
|
|
3860
|
+
// newest file and retains the tombstone for recovery).
|
|
3861
|
+
try {
|
|
3862
|
+
await fs.promises.link(tombstone, filePath);
|
|
3863
|
+
await fs.promises.rm(tombstone, { force: true }).catch(() => undefined);
|
|
3864
|
+
} catch {
|
|
3865
|
+
// The path is occupied (EEXIST) or linking failed: keep the newest
|
|
3866
|
+
// file and retain the tombstone (recoverable).
|
|
3867
|
+
}
|
|
3868
|
+
return false;
|
|
3869
|
+
}
|
|
3870
|
+
await fs.promises.rm(tombstone, { force: true }).catch(() => undefined);
|
|
3871
|
+
return true;
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
/**
|
|
3875
|
+
* Project strict-invalid evidence lives under the ignored runtime dir
|
|
3876
|
+
* `.gjc/state/` so running GJC never dirties the user's git worktree; the
|
|
3877
|
+
* config-root evidence stays next to its machine-global source (outside any
|
|
3878
|
+
* repository).
|
|
3879
|
+
*/
|
|
3880
|
+
#projectStrictInvalidEvidencePath(source: string): string {
|
|
3881
|
+
return path.join(path.dirname(source), "state", "settings.json.strict-invalid");
|
|
3882
|
+
}
|
|
3883
|
+
|
|
3884
|
+
/**
|
|
3885
|
+
* Record malformed-source strict evidence for the project migration; when the
|
|
3886
|
+
* evidence sidecar (`.gjc/state/`) cannot be written, fall back to persisting
|
|
3887
|
+
* invalid placeholder values into the project config.yml so the ralplan exit-2
|
|
3888
|
+
* error stays observable either way.
|
|
3889
|
+
*/
|
|
3890
|
+
async #recordProjectMalformedEvidence(source: string, target: string): Promise<void> {
|
|
3891
|
+
// Revalidate the source revision right before publishing: a repair or
|
|
3892
|
+
// replacement after the read/parse failure must not leave a stale global
|
|
3893
|
+
// malformed marker - the config-only resolver would exit 2 for an
|
|
3894
|
+
// already-valid source.
|
|
3895
|
+
if (!(await this.#sourceStillMalformed(source, null))) return;
|
|
3896
|
+
// When EVERY strict ralplan key is already owned by config.yml (recorded
|
|
3897
|
+
// in the migrated-keys marker), the retained source's corruption is
|
|
3898
|
+
// irrelevant: a deliberate `gjc config unset` of an owned key must keep
|
|
3899
|
+
// falling through to the lower layer/default instead of exiting 2 on a
|
|
3900
|
+
// stale global malformed marker. Partial ownership still publishes the
|
|
3901
|
+
// marker (the unowned strict keys' errors must stay observable).
|
|
3902
|
+
if (await this.#allProjectStrictKeysMigrated(source)) return;
|
|
3903
|
+
if (
|
|
3904
|
+
await this.#writeStrictInvalidEvidence(this.#projectStrictInvalidEvidencePath(source), source, [], {
|
|
3905
|
+
malformed: true,
|
|
3906
|
+
})
|
|
3907
|
+
) {
|
|
3908
|
+
return;
|
|
3909
|
+
}
|
|
3910
|
+
await this.#persistProjectMalformedStrictFallback(source, target);
|
|
3911
|
+
}
|
|
3912
|
+
|
|
3913
|
+
/**
|
|
3914
|
+
* True when every strict ralplan key the project source could contribute is
|
|
3915
|
+
* already recorded in the project migrated-keys ownership marker.
|
|
3916
|
+
*/
|
|
3917
|
+
async #allProjectStrictKeysMigrated(source: string): Promise<boolean> {
|
|
3918
|
+
const owned = await this.#readProjectMigratedKeys(source);
|
|
3919
|
+
return CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS.filter(key => key.startsWith("gjc.ralplan.")).every(key =>
|
|
3920
|
+
owned.has(key),
|
|
3921
|
+
);
|
|
3922
|
+
}
|
|
3923
|
+
|
|
3924
|
+
/**
|
|
3925
|
+
* Revalidate that the retained project source is STILL malformed in the same
|
|
3926
|
+
* sense the caller observed (unreadable, unparseable, non-object root, or a
|
|
3927
|
+
* non-mapping strict workflow parent) before recording malformed evidence.
|
|
3928
|
+
*/
|
|
3929
|
+
/**
|
|
3930
|
+
* True when the retained legacy source is still malformed in the strict
|
|
3931
|
+
* sense: unreadable (non-ENOENT), unparseable, a non-object root, or a
|
|
3932
|
+
* non-mapping strict workflow parent. Used to revalidate a source before
|
|
3933
|
+
* publishing malformed evidence - a repair must not leave a stale marker,
|
|
3934
|
+
* but a structurally malformed revision (e.g. `null`, `[]`, or a broken
|
|
3935
|
+
* `gjc.ralplan` parent) parses as JSON and is NOT a repair.
|
|
3936
|
+
*
|
|
3937
|
+
* Pass `knownRaw` when the bytes were already read to skip the re-read.
|
|
3938
|
+
*/
|
|
3939
|
+
async #sourceStillMalformed(source: string, knownRaw: string | null): Promise<boolean> {
|
|
3940
|
+
let raw: string;
|
|
3941
|
+
if (knownRaw !== null) {
|
|
3942
|
+
raw = knownRaw;
|
|
3943
|
+
} else {
|
|
3944
|
+
try {
|
|
3945
|
+
raw = await Bun.file(source).text();
|
|
3946
|
+
} catch (error) {
|
|
3947
|
+
return !isEnoent(error); // removed: nothing to record
|
|
3948
|
+
}
|
|
3949
|
+
}
|
|
3950
|
+
let document: unknown;
|
|
3951
|
+
try {
|
|
3952
|
+
document = JSON.parse(raw);
|
|
3953
|
+
} catch {
|
|
3954
|
+
return true; // still unparseable
|
|
3955
|
+
}
|
|
3956
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) return true;
|
|
3957
|
+
for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
|
|
3958
|
+
if (!key.startsWith("gjc.ralplan.")) continue;
|
|
3959
|
+
if (extractWorkflowSetting(document as Record<string, unknown>, key).malformedParent) return true;
|
|
3960
|
+
}
|
|
3961
|
+
return false; // parses to a valid object with well-formed parents
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
/**
|
|
3965
|
+
* Fallback for a malformed or unreadable legacy project source whose strict
|
|
3966
|
+
* evidence sidecar could not be written: persist guaranteed-invalid placeholder
|
|
3967
|
+
* values for the strict ralplan keys into the project config.yml (the only
|
|
3968
|
+
* surface the strict resolver reads) so `gjc ralplan` keeps exiting 2 while
|
|
3969
|
+
* the source is malformed. The fallback is tracked by the fallback-invalid
|
|
3970
|
+
* marker (writable next to the source even when `.gjc/state/` is not) and is
|
|
3971
|
+
* removed once the source is repaired.
|
|
3972
|
+
*/
|
|
3973
|
+
async #persistProjectMalformedStrictFallback(source: string, target: string): Promise<void> {
|
|
3974
|
+
try {
|
|
3975
|
+
await withAtomicYamlConfigTransaction(target, async tx => {
|
|
3976
|
+
await this.#persistMalformedStrictFallbackInTx(tx, source);
|
|
3977
|
+
});
|
|
3978
|
+
} catch (error) {
|
|
3979
|
+
this.#warnLegacyFallbackMigration(
|
|
3980
|
+
`Settings: project workflow migration could not persist malformed-source strict fallback into ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
|
3981
|
+
);
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
/**
|
|
3986
|
+
* Record malformed-source strict evidence for the config-root migration and,
|
|
3987
|
+
* when the evidence sidecar (`${source}.strict-invalid`) cannot be written,
|
|
3988
|
+
* fall back to persisting invalid placeholders into the target agent config.yml
|
|
3989
|
+
* inside the CURRENT transaction (the config-root malformed branches run under
|
|
3990
|
+
* the target lock, so a nested transaction would deadlock the queue).
|
|
3991
|
+
*/
|
|
3992
|
+
async #recordConfigRootMalformedEvidence(tx: AtomicYamlConfigTransaction, source: string): Promise<void> {
|
|
3993
|
+
// Revalidate the source revision right before publishing: a repair or
|
|
3994
|
+
// replacement after the caller's read/hash check must not leave a stale
|
|
3995
|
+
// global malformed marker (or invalid fallback placeholders) - the
|
|
3996
|
+
// config-only resolver would exit 2 for an already-valid source.
|
|
3997
|
+
if (!(await this.#sourceStillMalformed(source, null))) return;
|
|
3998
|
+
if (await this.#writeStrictInvalidEvidence(`${source}.strict-invalid`, source, [], { malformed: true })) {
|
|
3999
|
+
return;
|
|
4000
|
+
}
|
|
4001
|
+
await this.#persistMalformedStrictFallbackInTx(tx, source, this.#configRootFallbackInvalidMarkerPath(source));
|
|
4002
|
+
}
|
|
4003
|
+
|
|
4004
|
+
/**
|
|
4005
|
+
* Persist guaranteed-invalid placeholder values for the strict ralplan keys
|
|
4006
|
+
* into the target config.yml inside an already-held transaction. Mirrors the
|
|
4007
|
+
* main migrations' target guards (never patch a future schema or a malformed
|
|
4008
|
+
* root) and never overwrites a PRESENT target value: a valid value wins
|
|
4009
|
+
* outright, and an invalid value already keeps the exit-2 error observable on
|
|
4010
|
+
* its own - replacing it with a placeholder (and later unsetting it on marker
|
|
4011
|
+
* failure or repair) would destroy the user's pre-existing configuration.
|
|
4012
|
+
*/
|
|
4013
|
+
async #persistMalformedStrictFallbackInTx(
|
|
4014
|
+
tx: AtomicYamlConfigTransaction,
|
|
4015
|
+
source: string,
|
|
4016
|
+
markerPath = `${source}.fallback-invalid`,
|
|
4017
|
+
): Promise<void> {
|
|
4018
|
+
const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
|
|
4019
|
+
if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) return;
|
|
4020
|
+
if (tx.root !== undefined && (tx.root === null || typeof tx.root !== "object" || Array.isArray(tx.root))) {
|
|
4021
|
+
return;
|
|
4022
|
+
}
|
|
4023
|
+
const placeholders: StrictInvalidEvidenceEntry[] = [];
|
|
4024
|
+
for (const { key, value } of MALFORMED_SOURCE_STRICT_FALLBACK) {
|
|
4025
|
+
const current = extractWorkflowSetting(tx.root, key, { flat: false });
|
|
4026
|
+
if (current.malformedParent) continue;
|
|
4027
|
+
if (current.present) continue;
|
|
4028
|
+
placeholders.push({ key, value });
|
|
4029
|
+
}
|
|
4030
|
+
if (placeholders.length === 0) return;
|
|
4031
|
+
// Publish the ownership marker BEFORE the values: a crash between the two
|
|
4032
|
+
// leaves the marker (and no values), never values without a marker the next
|
|
4033
|
+
// load could not identify or clean up. Without the marker, skip the values -
|
|
4034
|
+
// an untracked fallback would exit 2 forever with no way to clear it.
|
|
4035
|
+
const markerOk = await this.#writeFallbackInvalidKeys(
|
|
4036
|
+
source,
|
|
4037
|
+
await this.#mergeFallbackInvalidKeys(source, placeholders, markerPath),
|
|
4038
|
+
markerPath,
|
|
4039
|
+
);
|
|
4040
|
+
if (!markerOk) {
|
|
4041
|
+
this.#warnLegacyFallbackMigration(
|
|
4042
|
+
`Settings: workflow migration could not track malformed-source strict fallback values; skipping the fallback`,
|
|
4043
|
+
);
|
|
4044
|
+
return;
|
|
4045
|
+
}
|
|
4046
|
+
await tx.applyPatches(placeholders.map(entry => ({ path: entry.key, op: "set" as const, value: entry.value })));
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
/**
|
|
4050
|
+
* Persist strict-invalid retention evidence: when a migration aborts on an
|
|
4051
|
+
* invalid STRICT ralplan legacy value (or a malformed source) it keeps the
|
|
4052
|
+
* source active so `gjc ralplan` can still fail loudly, but the resolver
|
|
4053
|
+
* never reads the retained source. This evidence file is the durable record
|
|
4054
|
+
* the resolver's strict path throws on, so the retained value is surfaced
|
|
4055
|
+
* instead of silently falling back to defaults. The file carries the complete
|
|
4056
|
+
* set of unresolved strict keys (or a malformed-source marker) so one file
|
|
4057
|
+
* cannot shadow another key's evidence. Returns false when the evidence could
|
|
4058
|
+
* not be written (callers must keep the strict error observable some other
|
|
4059
|
+
* way, e.g. by persisting the invalid values into config.yml).
|
|
4060
|
+
*/
|
|
4061
|
+
async #writeStrictInvalidEvidence(
|
|
4062
|
+
evidencePath: string,
|
|
4063
|
+
source: string,
|
|
4064
|
+
entries: readonly StrictInvalidEvidenceEntry[],
|
|
4065
|
+
options: { malformed?: boolean } = {},
|
|
4066
|
+
): Promise<boolean> {
|
|
4067
|
+
const payload = options.malformed
|
|
4068
|
+
? JSON.stringify({ version: 2, malformed: true, source: path.resolve(source) }, null, 2)
|
|
4069
|
+
: JSON.stringify(
|
|
4070
|
+
{
|
|
4071
|
+
version: 2,
|
|
4072
|
+
keys: entries.map(entry => ({ key: entry.key, value: entry.value })),
|
|
4073
|
+
source: path.resolve(source),
|
|
4074
|
+
},
|
|
4075
|
+
null,
|
|
4076
|
+
2,
|
|
4077
|
+
);
|
|
4078
|
+
// Publish atomically (stage + sync + rename): a direct in-place write
|
|
4079
|
+
// could truncate the live evidence file before the replacement payload
|
|
4080
|
+
// is durable, and readRetainedStrictEvidence would then read malformed
|
|
4081
|
+
// JSON as no evidence - the still-invalid strict key would silently fall
|
|
4082
|
+
// through to defaults instead of preserving exit 2.
|
|
4083
|
+
const tempPath = `${evidencePath}.${nodeCrypto.randomUUID()}.tmp`;
|
|
4084
|
+
try {
|
|
4085
|
+
await fs.promises.mkdir(path.dirname(evidencePath), { recursive: true });
|
|
4086
|
+
const tempHandle = await fs.promises.open(tempPath, "wx", 0o600);
|
|
4087
|
+
try {
|
|
4088
|
+
await tempHandle.writeFile(payload, "utf8");
|
|
4089
|
+
await tempHandle.sync();
|
|
4090
|
+
} finally {
|
|
4091
|
+
await tempHandle.close();
|
|
4092
|
+
}
|
|
4093
|
+
await fs.promises.rename(tempPath, evidencePath);
|
|
4094
|
+
// Durable publication: sync the parent directory so the rename survives
|
|
4095
|
+
// a host crash. Best-effort: platforms/filesystems that reject directory
|
|
4096
|
+
// sync are ignored.
|
|
4097
|
+
try {
|
|
4098
|
+
const dirHandle = await fs.promises.open(path.dirname(evidencePath), "r");
|
|
4099
|
+
try {
|
|
4100
|
+
await dirHandle.sync();
|
|
4101
|
+
} finally {
|
|
4102
|
+
await dirHandle.close();
|
|
4103
|
+
}
|
|
4104
|
+
} catch {
|
|
4105
|
+
// Unsupported directory sync: ignore.
|
|
4106
|
+
}
|
|
4107
|
+
return true;
|
|
4108
|
+
} catch (error) {
|
|
4109
|
+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
|
|
4110
|
+
this.#warnLegacyFallbackMigration(
|
|
4111
|
+
`Settings: workflow migration could not write strict-invalid evidence at ${evidencePath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4112
|
+
);
|
|
4113
|
+
return false;
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
/**
|
|
4118
|
+
* Read strict-invalid retention evidence. Returns null when absent or
|
|
4119
|
+
* structurally invalid; an evidence file with no recorded key for the
|
|
4120
|
+
* relevant workflow key is treated as null so it never affects other keys.
|
|
4121
|
+
*/
|
|
4122
|
+
async #readStrictInvalidEvidence(evidencePath: string): Promise<StrictInvalidEvidence | null> {
|
|
4123
|
+
let raw: string;
|
|
4124
|
+
try {
|
|
4125
|
+
raw = await Bun.file(evidencePath).text();
|
|
4126
|
+
} catch {
|
|
4127
|
+
return null;
|
|
4128
|
+
}
|
|
4129
|
+
try {
|
|
4130
|
+
const parsed = JSON.parse(raw) as {
|
|
4131
|
+
version?: unknown;
|
|
4132
|
+
malformed?: unknown;
|
|
4133
|
+
key?: unknown;
|
|
4134
|
+
value?: unknown;
|
|
4135
|
+
keys?: unknown;
|
|
4136
|
+
source?: unknown;
|
|
4137
|
+
};
|
|
4138
|
+
const source = typeof parsed.source === "string" ? parsed.source : evidencePath;
|
|
4139
|
+
if (parsed.malformed === true) {
|
|
4140
|
+
return { version: 2, malformed: true, source };
|
|
4141
|
+
}
|
|
4142
|
+
if (parsed.version === 2 && Array.isArray(parsed.keys)) {
|
|
4143
|
+
const keys: StrictInvalidEvidenceEntry[] = [];
|
|
4144
|
+
for (const rawEntry of parsed.keys) {
|
|
4145
|
+
if (!rawEntry || typeof rawEntry !== "object") continue;
|
|
4146
|
+
const entry = rawEntry as { key?: unknown; value?: unknown };
|
|
4147
|
+
if (
|
|
4148
|
+
typeof entry.key === "string" &&
|
|
4149
|
+
(CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(entry.key)
|
|
4150
|
+
) {
|
|
4151
|
+
keys.push({ key: entry.key as WorkflowSettingKey, value: entry.value });
|
|
4152
|
+
}
|
|
4153
|
+
}
|
|
4154
|
+
if (keys.length === 0) return null;
|
|
4155
|
+
return { version: 2, keys, source };
|
|
4156
|
+
}
|
|
4157
|
+
// v1 single-key compatibility.
|
|
4158
|
+
if (
|
|
4159
|
+
typeof parsed.key === "string" &&
|
|
4160
|
+
(CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(parsed.key)
|
|
4161
|
+
) {
|
|
4162
|
+
return {
|
|
4163
|
+
version: 2,
|
|
4164
|
+
keys: [{ key: parsed.key as WorkflowSettingKey, value: parsed.value }],
|
|
4165
|
+
source,
|
|
4166
|
+
};
|
|
4167
|
+
}
|
|
4168
|
+
return null;
|
|
4169
|
+
} catch {
|
|
4170
|
+
return null;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
|
|
4174
|
+
/**
|
|
4175
|
+
* Prune strict-invalid retention evidence to the keys that STILL hold in the
|
|
4176
|
+
* source. A v2 file may record several unresolved ralplan keys; repairing ONE
|
|
4177
|
+
* of them must not drop the evidence for the others (their exit-2 would
|
|
4178
|
+
* silently vanish), so the file is rewritten with only the still-invalid
|
|
4179
|
+
* entries and removed entirely only when none remain. Malformed-source
|
|
4180
|
+
* evidence is all-or-nothing: any parseable source (a repair) clears it. A
|
|
4181
|
+
* transient source read failure throws so callers retain the evidence.
|
|
4182
|
+
*/
|
|
4183
|
+
async #pruneStrictInvalidEvidence(
|
|
4184
|
+
source: string,
|
|
4185
|
+
evidencePath: string,
|
|
4186
|
+
evidence: StrictInvalidEvidence,
|
|
4187
|
+
): Promise<void> {
|
|
4188
|
+
let raw: string;
|
|
4189
|
+
try {
|
|
4190
|
+
raw = await Bun.file(source).text();
|
|
4191
|
+
} catch (error) {
|
|
4192
|
+
// Only a genuine removal (ENOENT) clears the evidence; a transient
|
|
4193
|
+
// read failure (EACCES, EIO) must NOT clear it, or the strict error
|
|
4194
|
+
// would silently vanish with neither source nor evidence to surface
|
|
4195
|
+
// it. Throw so callers retain the evidence.
|
|
4196
|
+
if (isEnoent(error)) {
|
|
4197
|
+
await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
throw error;
|
|
4201
|
+
}
|
|
4202
|
+
if (evidence.malformed) {
|
|
4203
|
+
// The malformed marker holds while the source is still malformed; a
|
|
4204
|
+
// parseable source (a repair) clears it entirely.
|
|
4205
|
+
try {
|
|
4206
|
+
JSON.parse(raw);
|
|
4207
|
+
} catch {
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
|
|
4211
|
+
return;
|
|
4212
|
+
}
|
|
4213
|
+
let document: unknown;
|
|
4214
|
+
try {
|
|
4215
|
+
document = JSON.parse(raw);
|
|
4216
|
+
} catch {
|
|
4217
|
+
// The source became malformed: keep the recorded key evidence; the
|
|
4218
|
+
// same run's malformed branch re-records it as a malformed marker.
|
|
4219
|
+
return;
|
|
4220
|
+
}
|
|
4221
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
4222
|
+
// Structurally invalid root: the recorded values cannot be verified;
|
|
4223
|
+
// keep the evidence (the source is still invalid per the resolver).
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
const stillHolding = evidence.keys.filter(entry => {
|
|
4227
|
+
const extracted = extractWorkflowSetting(document as Record<string, unknown>, entry.key);
|
|
4228
|
+
if (!extracted.present) return false;
|
|
4229
|
+
if (JSON.stringify(extracted.value) !== JSON.stringify(entry.value)) return false;
|
|
4230
|
+
return !this.#workflowKeyValueIsValid(entry.key, extracted.value);
|
|
4231
|
+
});
|
|
4232
|
+
const clearedKeys = new Set(evidence.keys.filter(entry => !stillHolding.includes(entry)).map(entry => entry.key));
|
|
4233
|
+
// Re-read the CURRENT sidecar right before writing and reconcile with a
|
|
4234
|
+
// concurrent publication: remove only the entries this run cleared, while
|
|
4235
|
+
// keeping every still-invalid key AND every concurrently-added key (a
|
|
4236
|
+
// stale A-only removal must never drop a newer sidecar that added B). The
|
|
4237
|
+
// callers additionally hold the target lock, so publications of the same
|
|
4238
|
+
// source serialize with this prune.
|
|
4239
|
+
const final = new Map<WorkflowSettingKey, unknown>();
|
|
4240
|
+
for (const entry of stillHolding) final.set(entry.key, entry.value);
|
|
4241
|
+
const current = await this.#readStrictInvalidEvidence(evidencePath);
|
|
4242
|
+
if (current?.malformed) {
|
|
4243
|
+
// A concurrent loader published a malformed-source marker (the source is
|
|
4244
|
+
// now malformed): retain it - it is the stronger evidence, and deleting
|
|
4245
|
+
// it here would let the resolver silently fall through to defaults. The
|
|
4246
|
+
// next load's prune clears it once the source is parseable again.
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
if (current) {
|
|
4250
|
+
for (const entry of current.keys) {
|
|
4251
|
+
if (!clearedKeys.has(entry.key)) final.set(entry.key, entry.value);
|
|
4252
|
+
}
|
|
4253
|
+
}
|
|
4254
|
+
if (final.size === 0) {
|
|
4255
|
+
// Delete only the exact evidence file this run reconciled: a concurrent
|
|
4256
|
+
// loader may publish a NEW malformed marker after the re-read above, and
|
|
4257
|
+
// a pathname rm would delete that marker (letting a strict resolver with
|
|
4258
|
+
// an existing keyless project config.yml fall through instead of exiting
|
|
4259
|
+
// 2). The rename + inode-verify removal deletes only the file observed
|
|
4260
|
+
// here; a concurrently-published marker is renamed back and survives.
|
|
4261
|
+
await this.#removeFileOnlyIfUnchanged(evidencePath);
|
|
4262
|
+
return;
|
|
4263
|
+
}
|
|
4264
|
+
const finalEntries = [...final].map(([key, value]) => ({ key, value }));
|
|
4265
|
+
if (
|
|
4266
|
+
finalEntries.length === evidence.keys.length &&
|
|
4267
|
+
finalEntries.every((entry, index) => entry.key === evidence.keys[index].key)
|
|
4268
|
+
) {
|
|
4269
|
+
return; // unchanged (a concurrent run already reconciled)
|
|
4270
|
+
}
|
|
4271
|
+
await this.#writeStrictInvalidEvidence(evidencePath, source, finalEntries);
|
|
4272
|
+
}
|
|
4273
|
+
|
|
4274
|
+
/**
|
|
4275
|
+
* Retain strict errors from a retained legacy source when the migration must
|
|
4276
|
+
* not touch the target (future-schema guard): the resolver reads config.yml
|
|
4277
|
+
* only, so an invalid strict ralplan value or a malformed source must be
|
|
4278
|
+
* recorded as evidence to keep `gjc ralplan` exiting 2 instead of silently
|
|
4279
|
+
* defaulting. Clears stale evidence when the source holds no strict failure.
|
|
4280
|
+
*/
|
|
4281
|
+
async #retainStrictErrorsFromSource(source: string, evidencePath: string): Promise<boolean> {
|
|
4282
|
+
const recordMalformed = async (): Promise<boolean> => {
|
|
4283
|
+
// Revalidate the source revision right before publishing: a repair or
|
|
4284
|
+
// replacement between the initial read/parse failure and this write
|
|
4285
|
+
// must not leave a stale malformed marker - the workflow command's
|
|
4286
|
+
// config-only resolver would otherwise exit 2 for an already-repaired
|
|
4287
|
+
// source, and a concurrent key-specific publication could be
|
|
4288
|
+
// overwritten by this global marker.
|
|
4289
|
+
let latest: string;
|
|
4290
|
+
try {
|
|
4291
|
+
latest = await Bun.file(source).text();
|
|
4292
|
+
} catch (error) {
|
|
4293
|
+
// Removed (ENOENT): nothing to record. Still unreadable: keep the
|
|
4294
|
+
// strict error observable via the malformed marker.
|
|
4295
|
+
if (isEnoent(error)) return true;
|
|
4296
|
+
const ok = await this.#writeStrictInvalidEvidence(evidencePath, source, [], { malformed: true });
|
|
4297
|
+
if (!ok) this.#warnEvidenceNotPreserved(source);
|
|
4298
|
+
return ok;
|
|
4299
|
+
}
|
|
4300
|
+
// Shape-check, not just parseability: a structurally malformed revision
|
|
4301
|
+
// (null/array root, non-mapping strict parent) parses as JSON but is
|
|
4302
|
+
// NOT a repair - only a valid object with well-formed parents is.
|
|
4303
|
+
if (!(await this.#sourceStillMalformed(source, latest))) {
|
|
4304
|
+
return true; // repaired since the initial failure: no malformed marker
|
|
4305
|
+
}
|
|
4306
|
+
// Still malformed: record it.
|
|
4307
|
+
const ok = await this.#writeStrictInvalidEvidence(evidencePath, source, [], { malformed: true });
|
|
4308
|
+
if (!ok) this.#warnEvidenceNotPreserved(source);
|
|
4309
|
+
return ok;
|
|
4310
|
+
};
|
|
4311
|
+
// Re-read and recompute up to twice: a repair or replacement during the
|
|
4312
|
+
// scan must not publish evidence computed from stale bytes.
|
|
4313
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4314
|
+
let raw: string;
|
|
4315
|
+
try {
|
|
4316
|
+
raw = await Bun.file(source).text();
|
|
4317
|
+
} catch (error) {
|
|
4318
|
+
if (isEnoent(error)) return true;
|
|
4319
|
+
// An EXISTING but unreadable source (EACCES, EIO) is a
|
|
4320
|
+
// malformed-source strict failure.
|
|
4321
|
+
return await recordMalformed();
|
|
4322
|
+
}
|
|
4323
|
+
let document: unknown;
|
|
4324
|
+
try {
|
|
4325
|
+
document = JSON.parse(raw);
|
|
4326
|
+
} catch {
|
|
4327
|
+
return await recordMalformed();
|
|
4328
|
+
}
|
|
4329
|
+
if (!document || typeof document !== "object" || Array.isArray(document)) {
|
|
4330
|
+
return await recordMalformed();
|
|
4331
|
+
}
|
|
4332
|
+
const invalidStrict: StrictInvalidEvidenceEntry[] = [];
|
|
4333
|
+
for (const key of CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS) {
|
|
4334
|
+
if (!key.startsWith("gjc.ralplan.")) continue;
|
|
4335
|
+
const extracted = extractWorkflowSetting(document as Record<string, unknown>, key);
|
|
4336
|
+
if (extracted.malformedParent) {
|
|
4337
|
+
// A non-mapping parent for a strict key is a malformed-source
|
|
4338
|
+
// strict failure (exit 2).
|
|
4339
|
+
return await recordMalformed();
|
|
4340
|
+
}
|
|
4341
|
+
if (!extracted.present) continue;
|
|
4342
|
+
if (!this.#workflowKeyValueIsValid(key, this.#coerceWorkflowScalar(key, extracted.value))) {
|
|
4343
|
+
invalidStrict.push({ key, value: extracted.value });
|
|
4344
|
+
}
|
|
4345
|
+
}
|
|
4346
|
+
// Revalidate the source revision right before publishing key-specific
|
|
4347
|
+
// evidence: a repair or replacement during the scan must not leave
|
|
4348
|
+
// evidence that makes the resolver exit 2 for a now-valid source.
|
|
4349
|
+
const latestRaw = await Bun.file(source)
|
|
4350
|
+
.text()
|
|
4351
|
+
.catch(() => null);
|
|
4352
|
+
if (latestRaw === null) {
|
|
4353
|
+
// Source removed: clear any stale evidence.
|
|
4354
|
+
await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
|
|
4355
|
+
return true;
|
|
4356
|
+
}
|
|
4357
|
+
if (latestRaw !== raw) continue; // changed while scanning: recompute
|
|
4358
|
+
if (invalidStrict.length === 0) {
|
|
4359
|
+
// The source holds no strict failure; clear any stale evidence.
|
|
4360
|
+
await fs.promises.rm(evidencePath, { force: true }).catch(() => undefined);
|
|
4361
|
+
return true;
|
|
4362
|
+
}
|
|
4363
|
+
const ok = await this.#writeStrictInvalidEvidence(evidencePath, source, invalidStrict);
|
|
4364
|
+
if (!ok) this.#warnEvidenceNotPreserved(source);
|
|
4365
|
+
return ok;
|
|
4366
|
+
}
|
|
4367
|
+
// The source kept changing across both reads; publish nothing rather than
|
|
4368
|
+
// stale evidence.
|
|
4369
|
+
return true;
|
|
4370
|
+
}
|
|
4371
|
+
|
|
4372
|
+
/**
|
|
4373
|
+
* The read-only future-schema target cannot receive fallback placeholders and
|
|
4374
|
+
* the evidence sidecar is unwritable, so the strict error cannot be preserved
|
|
4375
|
+
* on any durable surface; surface the failure instead of silently letting
|
|
4376
|
+
* `gjc ralplan` fall through to defaults.
|
|
4377
|
+
*/
|
|
4378
|
+
#warnEvidenceNotPreserved(source: string): void {
|
|
4379
|
+
this.#warnLegacyFallbackMigration(
|
|
4380
|
+
`Settings: strict error could not be preserved for ${source} (evidence unwritable, target read-only); gjc ralplan will use defaults until the source is repaired or the evidence path is writable`,
|
|
4381
|
+
);
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
/**
|
|
4385
|
+
* Read the project workflow migration's per-key completion marker. A key in
|
|
4386
|
+
* this set is owned by config.yml and is never re-imported from the retained
|
|
4387
|
+
* `.gjc/settings.json`, so removing a migrated override from config.yml
|
|
4388
|
+
* sticks. The marker lives under the ignored runtime dir `.gjc/state/` so a
|
|
4389
|
+
* successful migration never dirties the user's git worktree. A missing or
|
|
4390
|
+
* malformed marker reads as empty (the migration simply re-copies absent-only
|
|
4391
|
+
* values and rewrites the marker).
|
|
4392
|
+
*/
|
|
4393
|
+
async #readProjectMigratedKeys(source: string): Promise<Set<WorkflowSettingKey>> {
|
|
4394
|
+
const markerPath = path.join(path.dirname(source), "state", "settings.json.migrated-keys");
|
|
4395
|
+
let raw: string;
|
|
4396
|
+
try {
|
|
4397
|
+
raw = await Bun.file(markerPath).text();
|
|
4398
|
+
} catch (error) {
|
|
4399
|
+
// Only a MISSING marker reads as empty. A non-ENOENT read failure
|
|
4400
|
+
// (EACCES, transient I/O) must ABORT the migration: treating it as
|
|
4401
|
+
// empty would reimport a stale retained value and overwrite the
|
|
4402
|
+
// marker once the failure clears.
|
|
4403
|
+
if (isEnoent(error)) return new Set();
|
|
4404
|
+
throw error;
|
|
4405
|
+
}
|
|
4406
|
+
try {
|
|
4407
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
4408
|
+
if (!Array.isArray(parsed)) return new Set();
|
|
4409
|
+
const keys = new Set<WorkflowSettingKey>();
|
|
4410
|
+
for (const entry of parsed) {
|
|
4411
|
+
if (
|
|
4412
|
+
typeof entry === "string" &&
|
|
4413
|
+
(CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(entry)
|
|
4414
|
+
) {
|
|
4415
|
+
keys.add(entry as WorkflowSettingKey);
|
|
4416
|
+
}
|
|
4417
|
+
}
|
|
4418
|
+
return keys;
|
|
4419
|
+
} catch {
|
|
4420
|
+
return new Set();
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
4423
|
+
|
|
4424
|
+
/**
|
|
4425
|
+
* Atomically persist the project workflow migration's per-key completion
|
|
4426
|
+
* marker under `.gjc/state/` (temp file + rename; the directory is created on
|
|
4427
|
+
* demand).
|
|
4428
|
+
*/
|
|
4429
|
+
async #writeProjectMigratedKeys(source: string, keys: readonly WorkflowSettingKey[]): Promise<boolean> {
|
|
4430
|
+
const markerPath = path.join(path.dirname(source), "state", "settings.json.migrated-keys");
|
|
4431
|
+
// A UNIQUE staging path: two GJC processes may migrate the same project
|
|
4432
|
+
// concurrently, and a fixed .tmp name could be consumed by one writer's
|
|
4433
|
+
// rename, making the other fail its marker write and roll back a copy
|
|
4434
|
+
// whose ownership the winner recorded.
|
|
4435
|
+
const tempPath = `${markerPath}.${nodeCrypto.randomUUID()}.tmp`;
|
|
4436
|
+
try {
|
|
4437
|
+
await fs.promises.mkdir(path.dirname(markerPath), { recursive: true });
|
|
4438
|
+
// Fsync the staged payload BEFORE the rename: Bun.write alone would let a
|
|
4439
|
+
// host/power failure after publication leave an empty or malformed marker
|
|
4440
|
+
// whose directory entry survives the parent sync but whose content does
|
|
4441
|
+
// not, and #readProjectMigratedKeys would read no ownership - a later
|
|
4442
|
+
// unset of a migrated key would then re-import the stale legacy value.
|
|
4443
|
+
const tempHandle = await fs.promises.open(tempPath, "wx", 0o600);
|
|
4444
|
+
try {
|
|
4445
|
+
await tempHandle.writeFile(JSON.stringify(keys, null, 2), "utf8");
|
|
4446
|
+
await tempHandle.sync();
|
|
4447
|
+
} finally {
|
|
4448
|
+
await tempHandle.close();
|
|
4449
|
+
}
|
|
4450
|
+
await fs.promises.rename(tempPath, markerPath);
|
|
4451
|
+
// Durable publication: sync the marker's parent directory so the rename
|
|
4452
|
+
// (the ownership entry) survives a host crash even when the temp
|
|
4453
|
+
// content was already durable. Best-effort: platforms/filesystems that
|
|
4454
|
+
// reject directory sync are ignored.
|
|
4455
|
+
try {
|
|
4456
|
+
const dirHandle = await fs.promises.open(path.dirname(markerPath), "r");
|
|
4457
|
+
try {
|
|
4458
|
+
await dirHandle.sync();
|
|
4459
|
+
} finally {
|
|
4460
|
+
await dirHandle.close();
|
|
4461
|
+
}
|
|
4462
|
+
} catch {
|
|
4463
|
+
// Unsupported directory sync: ignore.
|
|
4464
|
+
}
|
|
4465
|
+
return true;
|
|
4466
|
+
} catch (error) {
|
|
4467
|
+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
|
|
4468
|
+
this.#warnLegacyFallbackMigration(
|
|
4469
|
+
`Settings: project workflow migration could not write the migrated-keys marker at ${markerPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4470
|
+
);
|
|
4471
|
+
return false;
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
|
|
4475
|
+
/**
|
|
4476
|
+
* Second-chance rollback for the project migration: when the in-transaction
|
|
4477
|
+
* rollback lost its whole-file CAS (an editor changed an unrelated config.yml
|
|
4478
|
+
* field between the publication and the rollback), reopen the target and
|
|
4479
|
+
* restore only the migration-published values that still match what the
|
|
4480
|
+
* migration wrote - never an editor's newer value. Repaired keys get their
|
|
4481
|
+
* pre-publication value back; keys that were absent before are unset. Also
|
|
4482
|
+
* used when the migrated-key marker cannot be written, so a markerless
|
|
4483
|
+
* publication is never considered complete.
|
|
4484
|
+
*/
|
|
4485
|
+
async #rollbackProjectPublishedKeys(
|
|
4486
|
+
target: string,
|
|
4487
|
+
published: readonly AtomicYamlPatch[],
|
|
4488
|
+
source: string,
|
|
4489
|
+
beforeStates: ReadonlyMap<string, ProjectTargetBeforeState>,
|
|
4490
|
+
): Promise<void> {
|
|
4491
|
+
try {
|
|
4492
|
+
await withAtomicYamlConfigTransaction(target, async tx => {
|
|
4493
|
+
const matching = published.filter(patch => {
|
|
4494
|
+
if (patch.op !== "set") return false;
|
|
4495
|
+
const current = extractWorkflowSetting(tx.root, patch.path as WorkflowSettingKey, { flat: false });
|
|
4496
|
+
return (
|
|
4497
|
+
!current.malformedParent &&
|
|
4498
|
+
current.present &&
|
|
4499
|
+
JSON.stringify(current.value) === JSON.stringify(patch.value)
|
|
4500
|
+
);
|
|
4501
|
+
});
|
|
4502
|
+
if (matching.length === 0) return;
|
|
4503
|
+
// Restore each key's pre-publication state instead of blanket-unsetting:
|
|
4504
|
+
// a repaired key (present-but-invalid before the migration) must keep
|
|
4505
|
+
// its original value rather than being deleted.
|
|
4506
|
+
await tx.applyPatches(
|
|
4507
|
+
matching.map(patch => {
|
|
4508
|
+
const before = beforeStates.get(patch.path);
|
|
4509
|
+
return before?.present
|
|
4510
|
+
? { path: patch.path, op: "set" as const, value: before.value }
|
|
4511
|
+
: { path: patch.path, op: "unset" as const };
|
|
4512
|
+
}),
|
|
4513
|
+
);
|
|
4514
|
+
});
|
|
4515
|
+
// Clear the strict-invalid evidence ONLY when it no longer reflects the
|
|
4516
|
+
// retained source: independently valid evidence (the source still holds
|
|
4517
|
+
// the recorded invalid values) must survive the rollback so exit 2 stays
|
|
4518
|
+
// observable - a marker-failure rollback does not change the source.
|
|
4519
|
+
const evidencePath = this.#projectStrictInvalidEvidencePath(source);
|
|
4520
|
+
const retainedEvidence = await this.#readStrictInvalidEvidence(evidencePath);
|
|
4521
|
+
if (retainedEvidence) {
|
|
4522
|
+
try {
|
|
4523
|
+
// Prune to the still-invalid keys: repairing ONE recorded key must
|
|
4524
|
+
// not drop the evidence for the remaining unresolved keys. Runs
|
|
4525
|
+
// under the target lock so a concurrent process's evidence
|
|
4526
|
+
// publication serializes with this prune.
|
|
4527
|
+
await withAtomicYamlConfigTransaction(target, async () => {
|
|
4528
|
+
await this.#pruneStrictInvalidEvidence(source, evidencePath, retainedEvidence);
|
|
4529
|
+
});
|
|
4530
|
+
} catch {
|
|
4531
|
+
// Transient source read failure: retain the evidence sidecar.
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
this.#warnLegacyFallbackMigration(
|
|
4535
|
+
`Settings: project workflow migration recovered the rollback after a CAS conflict: restored migration-published keys whose values still matched`,
|
|
4536
|
+
);
|
|
4537
|
+
} catch (error) {
|
|
4538
|
+
this.#warnLegacyFallbackMigration(
|
|
4539
|
+
`Settings: project workflow migration could not recover the rollback for ${target}: ${error instanceof Error ? error.message : String(error)}; stale migration-published values may remain until the next repair`,
|
|
4540
|
+
);
|
|
4541
|
+
}
|
|
4542
|
+
}
|
|
4543
|
+
|
|
4544
|
+
/**
|
|
4545
|
+
* True when the config-root source still matches the bytes this migration
|
|
4546
|
+
* read (the hash is computed from the initial read). Used to gate evidence
|
|
4547
|
+
* retention: stale evidence written after an external repair/replacement
|
|
4548
|
+
* would make the direct command exit 2 for a now-valid source.
|
|
4549
|
+
*/
|
|
4550
|
+
async #configRootSourceMatches(source: string, sourceSha256: string): Promise<boolean> {
|
|
4551
|
+
const current = await this.#sha256File(source).catch(() => null);
|
|
4552
|
+
return current === sourceSha256;
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4555
|
+
/**
|
|
4556
|
+
* Config-root fallback-invalid marker path. The PROJECT marker stays next to
|
|
4557
|
+
* the legacy source (`${source}.fallback-invalid`, covered by .gitignore);
|
|
4558
|
+
* when the config-root source collides with a project source both migrations
|
|
4559
|
+
* would otherwise share that marker while writing fallbacks to DIFFERENT
|
|
4560
|
+
* targets, and one target's cleanup could delete the shared marker while the
|
|
4561
|
+
* other target's fallback value is still active. The config-root marker uses
|
|
4562
|
+
* a distinct name so ownership is per-target.
|
|
4563
|
+
*/
|
|
4564
|
+
#configRootFallbackInvalidMarkerPath(source: string): string {
|
|
4565
|
+
return `${source}.config-root.fallback-invalid`;
|
|
4566
|
+
}
|
|
4567
|
+
|
|
4568
|
+
/**
|
|
4569
|
+
* Read the fallback-invalid marker (keys persisted into config.yml as
|
|
4570
|
+
* fallback evidence when the strict-evidence sidecar could not be written).
|
|
4571
|
+
* The marker lives next to the legacy source so it stays writable in the
|
|
4572
|
+
* exact scenario that produced it (.gjc/state/ is read-only or occupied).
|
|
4573
|
+
*/
|
|
4574
|
+
async #readFallbackInvalidKeys(
|
|
4575
|
+
source: string,
|
|
4576
|
+
markerPath = `${source}.fallback-invalid`,
|
|
4577
|
+
): Promise<StrictInvalidEvidenceEntry[]> {
|
|
4578
|
+
let raw: string;
|
|
4579
|
+
try {
|
|
4580
|
+
raw = await Bun.file(markerPath).text();
|
|
4581
|
+
} catch {
|
|
4582
|
+
return [];
|
|
4583
|
+
}
|
|
4584
|
+
try {
|
|
4585
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
4586
|
+
if (!Array.isArray(parsed)) return [];
|
|
4587
|
+
const entries: StrictInvalidEvidenceEntry[] = [];
|
|
4588
|
+
for (const entry of parsed) {
|
|
4589
|
+
if (typeof entry === "string") {
|
|
4590
|
+
// v1 marker: key only (no recorded value) - never match an unset.
|
|
4591
|
+
if ((CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(entry)) {
|
|
4592
|
+
entries.push({ key: entry as WorkflowSettingKey, value: undefined });
|
|
4593
|
+
}
|
|
4594
|
+
} else if (entry && typeof entry === "object") {
|
|
4595
|
+
const { key, value } = entry as { key?: unknown; value?: unknown };
|
|
4596
|
+
if (
|
|
4597
|
+
typeof key === "string" &&
|
|
4598
|
+
(CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(key)
|
|
4599
|
+
) {
|
|
4600
|
+
entries.push({ key: key as WorkflowSettingKey, value });
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
return entries;
|
|
4605
|
+
} catch {
|
|
4606
|
+
return [];
|
|
4607
|
+
}
|
|
4608
|
+
}
|
|
4609
|
+
|
|
4610
|
+
/**
|
|
4611
|
+
* Merge newly written fallback entries into the EXISTING fallback-invalid
|
|
4612
|
+
* marker. An evidence-unwritable source that first falls back key A and a
|
|
4613
|
+
* later load adds invalid key B (while A stays invalid) writes only B here -
|
|
4614
|
+
* A is already present in config.yml and excluded by the absent-target
|
|
4615
|
+
* filter. Replacing the marker with just B would lose A's tracking, so after
|
|
4616
|
+
* the source is repaired the cleanup could never remove A's fallback value
|
|
4617
|
+
* from config.yml (exit 2 persisting forever). New entries win over existing
|
|
4618
|
+
* ones for the same key.
|
|
4619
|
+
*/
|
|
4620
|
+
async #mergeFallbackInvalidKeys(
|
|
4621
|
+
source: string,
|
|
4622
|
+
entries: readonly StrictInvalidEvidenceEntry[],
|
|
4623
|
+
markerPath = `${source}.fallback-invalid`,
|
|
4624
|
+
): Promise<StrictInvalidEvidenceEntry[]> {
|
|
4625
|
+
const merged = new Map<WorkflowSettingKey, unknown>();
|
|
4626
|
+
for (const entry of await this.#readFallbackInvalidKeys(source, markerPath)) {
|
|
4627
|
+
merged.set(entry.key, entry.value);
|
|
4628
|
+
}
|
|
4629
|
+
for (const entry of entries) {
|
|
4630
|
+
merged.set(entry.key, entry.value);
|
|
4631
|
+
}
|
|
4632
|
+
return [...merged].map(([key, value]) => ({ key, value }));
|
|
4633
|
+
}
|
|
4634
|
+
|
|
4635
|
+
/** Atomically persist (or remove) the fallback-invalid marker. Returns false on failure. */
|
|
4636
|
+
async #writeFallbackInvalidKeys(
|
|
4637
|
+
source: string,
|
|
4638
|
+
entries: readonly StrictInvalidEvidenceEntry[],
|
|
4639
|
+
markerPath = `${source}.fallback-invalid`,
|
|
4640
|
+
): Promise<boolean> {
|
|
4641
|
+
const tempPath = `${markerPath}.${nodeCrypto.randomUUID()}.tmp`;
|
|
4642
|
+
try {
|
|
4643
|
+
if (entries.length === 0) {
|
|
4644
|
+
await fs.promises.rm(markerPath, { force: true }).catch(() => undefined);
|
|
4645
|
+
return true;
|
|
4646
|
+
}
|
|
4647
|
+
// Publish atomically (stage + sync + rename): a direct in-place write of
|
|
4648
|
+
// a merged marker could be truncated by a crash, leaving malformed JSON
|
|
4649
|
+
// that #readFallbackInvalidKeys treats as empty while the recorded
|
|
4650
|
+
// config.yml values stay active and become un-cleanable.
|
|
4651
|
+
const tempHandle = await fs.promises.open(tempPath, "wx", 0o600);
|
|
4652
|
+
try {
|
|
4653
|
+
await tempHandle.writeFile(
|
|
4654
|
+
JSON.stringify(
|
|
4655
|
+
entries.map(entry => ({ key: entry.key, value: entry.value })),
|
|
4656
|
+
null,
|
|
4657
|
+
2,
|
|
4658
|
+
),
|
|
4659
|
+
"utf8",
|
|
4660
|
+
);
|
|
4661
|
+
await tempHandle.sync();
|
|
4662
|
+
} finally {
|
|
4663
|
+
await tempHandle.close();
|
|
4664
|
+
}
|
|
4665
|
+
await fs.promises.rename(tempPath, markerPath);
|
|
4666
|
+
// Durable publication: sync the marker's parent directory so the rename
|
|
4667
|
+
// (the marker's directory entry) survives a host crash even when the
|
|
4668
|
+
// temp content was already durable - otherwise a power loss can preserve
|
|
4669
|
+
// the fallback value in config.yml while the ownership marker is lost,
|
|
4670
|
+
// and the value becomes un-cleanable. Best-effort (mirrors the atomic
|
|
4671
|
+
// marker writer): platforms/filesystems that reject directory sync are
|
|
4672
|
+
// ignored.
|
|
4673
|
+
try {
|
|
4674
|
+
const dirHandle = await fs.promises.open(path.dirname(markerPath), "r");
|
|
4675
|
+
try {
|
|
4676
|
+
await dirHandle.sync();
|
|
4677
|
+
} finally {
|
|
4678
|
+
await dirHandle.close();
|
|
4679
|
+
}
|
|
4680
|
+
} catch {
|
|
4681
|
+
// Unsupported directory sync: ignore.
|
|
4682
|
+
}
|
|
4683
|
+
return true;
|
|
4684
|
+
} catch (error) {
|
|
4685
|
+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
|
|
4686
|
+
this.#warnLegacyFallbackMigration(
|
|
4687
|
+
`Settings: workflow migration could not write the fallback-invalid marker at ${markerPath}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4688
|
+
);
|
|
4689
|
+
return false;
|
|
4690
|
+
}
|
|
4691
|
+
}
|
|
4692
|
+
/**
|
|
4693
|
+
* Fallback-invalid cleanup: values persisted into config.yml as fallback
|
|
4694
|
+
* evidence must be removed when the source no longer holds the invalid
|
|
4695
|
+
* value (the key was deleted or settings.json was removed), so exit 2 does
|
|
4696
|
+
* not persist after the user repairs the legacy source. A malformed source
|
|
4697
|
+
* keeps the fallback (the config.yml value stays the error surface).
|
|
4698
|
+
*/
|
|
4699
|
+
async #clearStaleFallbackInvalidKeys(
|
|
4700
|
+
source: string,
|
|
4701
|
+
target: string,
|
|
4702
|
+
markerPath = `${source}.fallback-invalid`,
|
|
4703
|
+
): Promise<void> {
|
|
4704
|
+
const entries = await this.#readFallbackInvalidKeys(source, markerPath);
|
|
4705
|
+
if (entries.length === 0) return;
|
|
4706
|
+
|
|
4707
|
+
const stillInvalid = new Set<WorkflowSettingKey>();
|
|
4708
|
+
let sourceText: string | null;
|
|
4709
|
+
try {
|
|
4710
|
+
sourceText = await Bun.file(source).text();
|
|
4711
|
+
} catch (error) {
|
|
4712
|
+
if (!isEnoent(error)) {
|
|
4713
|
+
// A transient read failure (permissions, I/O) must NOT be treated as
|
|
4714
|
+
// deletion: retain the fallback so the strict error stays observable.
|
|
4715
|
+
return;
|
|
4716
|
+
}
|
|
4717
|
+
// settings.json removed: nothing to keep the fallback alive.
|
|
4718
|
+
sourceText = null;
|
|
4719
|
+
}
|
|
4720
|
+
if (sourceText !== null) {
|
|
4721
|
+
let parsed: unknown;
|
|
4722
|
+
try {
|
|
4723
|
+
parsed = JSON.parse(sourceText);
|
|
4724
|
+
} catch {
|
|
4725
|
+
// Still malformed: keep the fallback as the observable error.
|
|
4726
|
+
return;
|
|
4727
|
+
}
|
|
4728
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
|
|
4729
|
+
for (const entry of entries) {
|
|
4730
|
+
const extracted = extractWorkflowSetting(parsed as Record<string, unknown>, entry.key);
|
|
4731
|
+
if (extracted.present && !this.#workflowKeyValueIsValid(entry.key, extracted.value)) {
|
|
4732
|
+
stillInvalid.add(entry.key);
|
|
4733
|
+
}
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
|
|
4737
|
+
const toClear = entries.filter(entry => !stillInvalid.has(entry.key));
|
|
4738
|
+
if (toClear.length === 0) return;
|
|
4739
|
+
const toClearKeys = new Set(toClear.map(entry => entry.key));
|
|
4740
|
+
try {
|
|
4741
|
+
await withAtomicYamlConfigTransaction(target, async tx => {
|
|
4742
|
+
// A config.yml written by a NEWER schema version is read-only across
|
|
4743
|
+
// Settings; leave the target AND the fallback marker untouched, or
|
|
4744
|
+
// this cleanup (which runs before either migration's future-schema
|
|
4745
|
+
// guard) would rewrite configuration an older binary treats as
|
|
4746
|
+
// read-only.
|
|
4747
|
+
const targetSchemaVersion = (tx.root as Record<string, unknown> | null | undefined)?.configSchemaVersion;
|
|
4748
|
+
if (typeof targetSchemaVersion === "number" && targetSchemaVersion > CONFIG_SCHEMA_VERSION) {
|
|
4749
|
+
return;
|
|
4750
|
+
}
|
|
4751
|
+
// Unset only while the target still matches the migration's fallback
|
|
4752
|
+
// write: a user's newer hand-edited override (e.g. maxIterations: 9)
|
|
4753
|
+
// survives the cleanup.
|
|
4754
|
+
const unsets = toClear.filter(entry => {
|
|
4755
|
+
const current = extractWorkflowSetting(tx.root, entry.key, { flat: false });
|
|
4756
|
+
return (
|
|
4757
|
+
!current.malformedParent &&
|
|
4758
|
+
current.present &&
|
|
4759
|
+
JSON.stringify(current.value) === JSON.stringify(entry.value)
|
|
4760
|
+
);
|
|
4761
|
+
});
|
|
4762
|
+
if (unsets.length > 0) {
|
|
4763
|
+
await tx.applyPatches(unsets.map(entry => ({ path: entry.key, op: "unset" as const })));
|
|
4764
|
+
}
|
|
4765
|
+
// Reconcile the marker under the SAME lock the publication sites use:
|
|
4766
|
+
// another process may have merged a newly published fallback key
|
|
4767
|
+
// between this run's initial read and the transaction. Writing a
|
|
4768
|
+
// stale filtered snapshot here would drop that entry and leave its
|
|
4769
|
+
// config.yml value without ownership forever, so re-read the CURRENT
|
|
4770
|
+
// marker and remove only the keys THIS run cleared.
|
|
4771
|
+
await this.#writeFallbackInvalidKeys(
|
|
4772
|
+
source,
|
|
4773
|
+
(await this.#readFallbackInvalidKeys(source, markerPath)).filter(entry => !toClearKeys.has(entry.key)),
|
|
4774
|
+
markerPath,
|
|
4775
|
+
);
|
|
4776
|
+
});
|
|
4777
|
+
} catch (error) {
|
|
4778
|
+
this.#warnLegacyFallbackMigration(
|
|
4779
|
+
`Settings: workflow migration could not clear stale fallback-invalid values in ${target}: ${error instanceof Error ? error.message : String(error)}`,
|
|
4780
|
+
);
|
|
4781
|
+
return;
|
|
4782
|
+
}
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4785
|
+
/**
|
|
4786
|
+
* Merge the per-key completion marker with the CURRENT on-disk marker (a
|
|
4787
|
+
* concurrent migration may have published its own between our initial read
|
|
4788
|
+
* and this transaction) and return the merged key list.
|
|
4789
|
+
*/
|
|
4790
|
+
async #mergeProjectMigratedKeys(
|
|
4791
|
+
source: string,
|
|
4792
|
+
newlyOwned: readonly WorkflowSettingKey[],
|
|
4793
|
+
): Promise<readonly WorkflowSettingKey[]> {
|
|
4794
|
+
if (newlyOwned.length === 0) return [];
|
|
4795
|
+
const currentMarker = await this.#readProjectMigratedKeys(source);
|
|
4796
|
+
return [...currentMarker, ...newlyOwned.filter(key => !currentMarker.has(key))];
|
|
4797
|
+
}
|
|
4798
|
+
|
|
4799
|
+
async #readBackupBytes(backup: string): Promise<{ bytes: ArrayBuffer; text: string }> {
|
|
4800
|
+
const bytes = await Bun.file(backup).arrayBuffer();
|
|
4801
|
+
return { bytes, text: Buffer.from(bytes).toString("utf8") };
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
async #statIdentity(filePath: string): Promise<string | undefined> {
|
|
4805
|
+
const st = await fs.promises.stat(filePath).catch(() => null);
|
|
4806
|
+
return st ? `${st.dev}:${st.ino}` : undefined;
|
|
4807
|
+
}
|
|
4808
|
+
|
|
4809
|
+
async #targetFileIdentity(target: string): Promise<string | null> {
|
|
4810
|
+
const identity = await this.#statIdentity(target);
|
|
4811
|
+
return identity ?? null;
|
|
4812
|
+
}
|
|
4813
|
+
|
|
4814
|
+
async #workflowMigrationTargetIdentity(
|
|
4815
|
+
target: string,
|
|
4816
|
+
expected?: { canonicalTargetDir: string; canonicalTargetIdentity: string },
|
|
4817
|
+
): Promise<{ canonicalTargetDir: string; canonicalTargetIdentity: string } | null> {
|
|
4818
|
+
const targetDir = path.dirname(target);
|
|
4819
|
+
const canonicalTargetDir = await fs.promises.realpath(targetDir).catch(() => null);
|
|
4820
|
+
if (canonicalTargetDir === null) return null;
|
|
4821
|
+
const canonicalTargetIdentity = await this.#statIdentity(targetDir);
|
|
4822
|
+
if (canonicalTargetIdentity === undefined) return null;
|
|
4823
|
+
if (
|
|
4824
|
+
expected &&
|
|
4825
|
+
(canonicalTargetDir !== expected.canonicalTargetDir ||
|
|
4826
|
+
canonicalTargetIdentity !== expected.canonicalTargetIdentity)
|
|
4827
|
+
) {
|
|
4828
|
+
return null;
|
|
4829
|
+
}
|
|
4830
|
+
return { canonicalTargetDir, canonicalTargetIdentity };
|
|
4831
|
+
}
|
|
4832
|
+
|
|
4833
|
+
#isGlobalAgentScope(): boolean {
|
|
4834
|
+
// The Settings instance is bound to the environment-selected global agent
|
|
4835
|
+
// profile (GJC_CODING_AGENT_DIR / PI_CODING_AGENT_DIR or the default
|
|
4836
|
+
// <configRoot>/agent). An EXPLICITLY supplied temporary agentDir (SDK
|
|
4837
|
+
// loadForScope, tests) differs from getAgentDir() and must never consume
|
|
4838
|
+
// the machine-global config-root source; an environment-selected
|
|
4839
|
+
// non-default profile is a legitimate global profile and must migrate
|
|
4840
|
+
// like the default one.
|
|
4841
|
+
return path.resolve(this.#agentDir) === path.resolve(getAgentDir());
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
#workflowMigrationTargetSatisfies(root: unknown, marker: WorkflowMigrationMarker): boolean {
|
|
4845
|
+
if (root === undefined || root === null) return marker.migratedKeys.length === 0;
|
|
4846
|
+
if (typeof root !== "object" || Array.isArray(root)) return false;
|
|
4847
|
+
const doc = root as Record<string, unknown>;
|
|
4848
|
+
return marker.migratedKeys.every(key => extractWorkflowSetting(doc, key, { flat: false }).present);
|
|
4849
|
+
}
|
|
4850
|
+
#workflowKeyValueIsValid(key: WorkflowSettingKey, value: unknown): boolean {
|
|
4851
|
+
const def = SETTINGS_SCHEMA[key] as
|
|
4852
|
+
| { type?: string; validate?: (value: number) => boolean; values?: readonly unknown[] }
|
|
4853
|
+
| undefined;
|
|
4854
|
+
if (!def) return true;
|
|
4855
|
+
let candidate: unknown = value;
|
|
4856
|
+
candidate = this.#coerceWorkflowScalar(key, candidate);
|
|
4857
|
+
switch (def.type) {
|
|
4858
|
+
case "enum":
|
|
4859
|
+
return def.values?.includes(candidate) ?? false;
|
|
4860
|
+
case "number":
|
|
4861
|
+
return def.validate !== undefined
|
|
4862
|
+
? def.validate(candidate as number)
|
|
4863
|
+
: typeof candidate === "number" && Number.isFinite(candidate);
|
|
4864
|
+
case "boolean":
|
|
4865
|
+
return typeof candidate === "boolean";
|
|
4866
|
+
case "string":
|
|
4867
|
+
return typeof candidate === "string";
|
|
4868
|
+
default:
|
|
4869
|
+
return true;
|
|
4870
|
+
}
|
|
4871
|
+
}
|
|
4872
|
+
#workflowMigrationMarkerPathsMatch(
|
|
4873
|
+
marker: WorkflowMigrationMarker,
|
|
4874
|
+
source: string,
|
|
4875
|
+
backup: string,
|
|
4876
|
+
target: string,
|
|
4877
|
+
): boolean {
|
|
4878
|
+
return (
|
|
4879
|
+
path.resolve(marker.sourcePath) === path.resolve(source) &&
|
|
4880
|
+
path.resolve(marker.backupPath) === path.resolve(backup) &&
|
|
4881
|
+
path.resolve(marker.targetPath) === path.resolve(target)
|
|
4882
|
+
);
|
|
4883
|
+
}
|
|
4884
|
+
/**
|
|
4885
|
+
* Mirror the resolver/Settings scalar coercion for a workflow key: a quoted
|
|
4886
|
+
* numeric string for a number setting (e.g. `maxIterations: "9"`) becomes
|
|
4887
|
+
* the number 9. Used both for validity checks and for what the migration
|
|
4888
|
+
* persists into config.yml.
|
|
4889
|
+
*/
|
|
4890
|
+
#coerceWorkflowScalar(key: WorkflowSettingKey, value: unknown): unknown {
|
|
4891
|
+
const def = SETTINGS_SCHEMA[key] as { type?: string } | undefined;
|
|
4892
|
+
if (
|
|
4893
|
+
def?.type === "number" &&
|
|
4894
|
+
typeof value === "string" &&
|
|
4895
|
+
value.trim() !== "" &&
|
|
4896
|
+
Number.isFinite(Number(value))
|
|
4897
|
+
) {
|
|
4898
|
+
return Number(value);
|
|
4899
|
+
}
|
|
4900
|
+
return value;
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
async #readWorkflowMigrationMarker(markerPath: string): Promise<WorkflowMigrationMarker | null> {
|
|
4904
|
+
let raw: string;
|
|
4905
|
+
try {
|
|
4906
|
+
raw = await Bun.file(markerPath).text();
|
|
4907
|
+
} catch (error) {
|
|
4908
|
+
// Only ENOENT means no marker; a transient EACCES/EIO read failure
|
|
4909
|
+
// must propagate so a valid pending marker is never quarantined as
|
|
4910
|
+
// corrupt and its ownership evidence lost.
|
|
4911
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
4912
|
+
throw error;
|
|
4913
|
+
}
|
|
4914
|
+
try {
|
|
4915
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
4916
|
+
if (parsed.version !== WORKFLOW_MIGRATION_MARKER_VERSION) return null;
|
|
4917
|
+
if (parsed.status !== "pending" && parsed.status !== "complete") return null;
|
|
4918
|
+
if (
|
|
4919
|
+
typeof parsed.sourcePath !== "string" ||
|
|
4920
|
+
typeof parsed.backupPath !== "string" ||
|
|
4921
|
+
typeof parsed.targetPath !== "string" ||
|
|
4922
|
+
typeof parsed.sourceSha256 !== "string" ||
|
|
4923
|
+
!/^[0-9a-f]{64}$/.test(parsed.sourceSha256) ||
|
|
4924
|
+
typeof parsed.startedAt !== "string" ||
|
|
4925
|
+
Number.isNaN(Date.parse(parsed.startedAt)) ||
|
|
4926
|
+
!Array.isArray(parsed.migratedKeys) ||
|
|
4927
|
+
!parsed.migratedKeys.every(
|
|
4928
|
+
key =>
|
|
4929
|
+
typeof key === "string" && (CONFIG_ROOT_WORKFLOW_MIGRATION_KEYS as readonly string[]).includes(key),
|
|
4930
|
+
)
|
|
4931
|
+
) {
|
|
4932
|
+
return null;
|
|
4933
|
+
}
|
|
4934
|
+
if (
|
|
4935
|
+
parsed.status === "complete" &&
|
|
4936
|
+
(typeof parsed.completedAt !== "string" || Number.isNaN(Date.parse(parsed.completedAt)))
|
|
4937
|
+
) {
|
|
4938
|
+
return null;
|
|
4939
|
+
}
|
|
4940
|
+
return parsed as WorkflowMigrationMarker;
|
|
4941
|
+
} catch {
|
|
4942
|
+
return null;
|
|
4943
|
+
}
|
|
4944
|
+
}
|
|
4945
|
+
|
|
4946
|
+
async #writeWorkflowMigrationMarkerAtomic(markerPath: string, marker: WorkflowMigrationMarker): Promise<void> {
|
|
4947
|
+
if (marker.status === "complete") {
|
|
4948
|
+
if (typeof marker.canonicalTargetIdentity !== "string" || marker.canonicalTargetIdentity.length === 0) {
|
|
4949
|
+
throw new Error("Cannot publish a complete workflow migration marker without target directory identity.");
|
|
4950
|
+
}
|
|
4951
|
+
const currentTargetIdentity = await this.#statIdentity(path.dirname(marker.targetPath));
|
|
4952
|
+
if (currentTargetIdentity !== marker.canonicalTargetIdentity) {
|
|
4953
|
+
throw new Error("Cannot publish a complete workflow migration marker without a matching target directory.");
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4956
|
+
const serialized = JSON.stringify(marker, null, 2);
|
|
4957
|
+
const directory = path.dirname(markerPath);
|
|
4958
|
+
const tempPath = path.join(
|
|
4959
|
+
directory,
|
|
4960
|
+
`.${path.basename(markerPath)}.${process.pid}.${nodeCrypto.randomUUID()}.tmp`,
|
|
4961
|
+
);
|
|
4962
|
+
try {
|
|
4963
|
+
await Bun.write(tempPath, serialized);
|
|
4964
|
+
// Durable before publication: fsync the marker file so a crash
|
|
4965
|
+
// cannot leave a rename that survives while the marker content is
|
|
4966
|
+
// lost.
|
|
4967
|
+
const tempHandle = await fs.promises.open(tempPath, "r");
|
|
4968
|
+
try {
|
|
4969
|
+
await tempHandle.sync();
|
|
4970
|
+
} finally {
|
|
4971
|
+
await tempHandle.close();
|
|
4972
|
+
}
|
|
4973
|
+
await fs.promises.rename(tempPath, markerPath);
|
|
4974
|
+
// Durable publication: sync the parent directory so the rename (the
|
|
4975
|
+
// marker's directory entry) survives a host crash even when the
|
|
4976
|
+
// temp file content was already durable.
|
|
4977
|
+
// Best-effort (like the atomic-YAML writer): some platforms (Windows,
|
|
4978
|
+
// filesystems that reject opening/syncing directories) throw here -
|
|
4979
|
+
// the marker rename is already durable; a directory-sync failure
|
|
4980
|
+
// must not abort the migration.
|
|
4981
|
+
try {
|
|
4982
|
+
const dirHandle = await fs.promises.open(directory, "r");
|
|
4983
|
+
try {
|
|
4984
|
+
await dirHandle.sync();
|
|
4985
|
+
} finally {
|
|
4986
|
+
await dirHandle.close();
|
|
4987
|
+
}
|
|
4988
|
+
} catch {
|
|
4989
|
+
// Unsupported directory sync: ignore.
|
|
4990
|
+
}
|
|
4991
|
+
} finally {
|
|
4992
|
+
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4995
|
+
|
|
4996
|
+
/**
|
|
4997
|
+
* True when the config-root source is the SAME physical file as the current
|
|
4998
|
+
* project's `.gjc/settings.json` (GJC run from the config root itself, e.g.
|
|
4999
|
+
* the user's home with the default `.gjc`): path identity first, then inode
|
|
5000
|
+
* identity for symlink/hardlink aliases.
|
|
5001
|
+
*/
|
|
5002
|
+
async #configRootCollidesWithProjectSource(source: string): Promise<boolean> {
|
|
5003
|
+
const projectSource = path.resolve(this.#cwd, ".gjc", "settings.json");
|
|
5004
|
+
if (path.resolve(source) === projectSource) return true;
|
|
5005
|
+
const [sourceStat, projectStat] = await Promise.all([
|
|
5006
|
+
fs.promises.stat(source).catch(() => null),
|
|
5007
|
+
fs.promises.stat(projectSource).catch(() => null),
|
|
5008
|
+
]);
|
|
5009
|
+
return !!sourceStat && !!projectStat && sourceStat.dev === projectStat.dev && sourceStat.ino === projectStat.ino;
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
/**
|
|
5013
|
+
* Reconcile the config-root sidecars (strict evidence prune + fallback-invalid
|
|
5014
|
+
* cleanup) against the AGENT target. Shared by the normal migration start and
|
|
5015
|
+
* the project-source collision deferral: in the collision, the config-root
|
|
5016
|
+
* fallback values live in the agent config.yml and the strict evidence sits
|
|
5017
|
+
* at the agent layer slot, so they must be reconciled here - the project
|
|
5018
|
+
* migration only cleans against the project config.yml and project evidence,
|
|
5019
|
+
* and would otherwise delete the shared fallback marker without removing the
|
|
5020
|
+
* agent config.yml value, orphaning it (strict ralplan failing forever).
|
|
5021
|
+
*/
|
|
5022
|
+
async #reconcileConfigRootSidecars(source: string, target: string): Promise<void> {
|
|
5023
|
+
const evidencePath = `${source}.strict-invalid`;
|
|
5024
|
+
const retainedEvidence = await this.#readStrictInvalidEvidence(evidencePath);
|
|
5025
|
+
if (retainedEvidence) {
|
|
5026
|
+
try {
|
|
5027
|
+
// Prune to the still-invalid keys: repairing ONE recorded key must
|
|
5028
|
+
// not drop the evidence for the remaining unresolved keys. Runs
|
|
5029
|
+
// under the target lock so a concurrent process's evidence
|
|
5030
|
+
// publication serializes with this prune.
|
|
5031
|
+
await withAtomicYamlConfigTransaction(target, async () => {
|
|
5032
|
+
await this.#pruneStrictInvalidEvidence(source, evidencePath, retainedEvidence);
|
|
5033
|
+
});
|
|
5034
|
+
} catch {
|
|
5035
|
+
// Transient source read failure: retain the evidence sidecar.
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
|
|
5039
|
+
// Fallback-invalid cleanup: config.yml values persisted as fallback
|
|
5040
|
+
// evidence are removed when the source no longer holds the invalid value
|
|
5041
|
+
// (key deleted or settings.json removed), so exit 2 does not persist. The
|
|
5042
|
+
// config-root marker namespace is distinct from the project's so a
|
|
5043
|
+
// colliding project source never shares ownership state across targets.
|
|
5044
|
+
await this.#clearStaleFallbackInvalidKeys(source, target, this.#configRootFallbackInvalidMarkerPath(source));
|
|
5045
|
+
}
|
|
5046
|
+
|
|
5047
|
+
/**
|
|
5048
|
+
* Retire the config-root legacy source once migration is complete (shared by
|
|
5049
|
+
* the completion path and the completed-marker crash recovery). Only the
|
|
5050
|
+
* EXACT revision whose bytes match `expectedSourceSha256` is removed: the
|
|
5051
|
+
* source is first RENAMED to a unique quarantine path (an atomic capture of
|
|
5052
|
+
* whatever the path currently holds); the quarantine is unlinked only when
|
|
5053
|
+
* its bytes still match. A quarantined concurrent replacement is restored to
|
|
5054
|
+
* `source` (or left in quarantine if a new file already occupies the path) -
|
|
5055
|
+
* never deleted by pathname.
|
|
5056
|
+
*/
|
|
5057
|
+
async #retireConfigRootSource(source: string, expectedSourceSha256: string): Promise<void> {
|
|
5058
|
+
const quarantinePath = `${source}.migrated-src-${nodeCrypto.randomUUID()}`;
|
|
5059
|
+
let quarantined = false;
|
|
5060
|
+
try {
|
|
5061
|
+
await fs.promises.rename(source, quarantinePath);
|
|
5062
|
+
quarantined = true;
|
|
5063
|
+
} catch {
|
|
5064
|
+
// Source already gone (or locked); nothing to retire.
|
|
5065
|
+
}
|
|
5066
|
+
if (!quarantined) return;
|
|
5067
|
+
const quarantinedHash = await this.#sha256File(quarantinePath).catch(() => null);
|
|
5068
|
+
if (quarantinedHash === expectedSourceSha256) {
|
|
5069
|
+
await fs.promises.rm(quarantinePath, { force: true }).catch(() => undefined);
|
|
5070
|
+
return;
|
|
5071
|
+
}
|
|
5072
|
+
// A concurrent replacement was quarantined; restore it WITHOUT replacement
|
|
5073
|
+
// semantics: hard-link back only when the original path is free (link
|
|
5074
|
+
// fails with EEXIST when an editor has since published a new file there),
|
|
5075
|
+
// and leave the quarantined object intact in that case - never overwrite
|
|
5076
|
+
// or delete the editor's latest revision.
|
|
5077
|
+
try {
|
|
5078
|
+
await fs.promises.link(quarantinePath, source);
|
|
5079
|
+
await fs.promises.rm(quarantinePath, { force: true }).catch(() => undefined);
|
|
5080
|
+
} catch {
|
|
5081
|
+
// `source` is occupied (EEXIST) or linking failed; keep the
|
|
5082
|
+
// quarantined object under its unique name.
|
|
5083
|
+
}
|
|
5084
|
+
}
|
|
5085
|
+
|
|
5086
|
+
async #moveLegacySourceNoReplace(
|
|
5087
|
+
source: string,
|
|
5088
|
+
destination: string,
|
|
5089
|
+
expectedSourceSha256?: string,
|
|
5090
|
+
): Promise<boolean> {
|
|
5091
|
+
try {
|
|
5092
|
+
await fs.promises.lstat(destination);
|
|
5093
|
+
return false; // Never overwrite an existing destination.
|
|
5094
|
+
} catch (error) {
|
|
5095
|
+
if (!isEnoent(error)) return false;
|
|
5096
|
+
}
|
|
5097
|
+
if (expectedSourceSha256 !== undefined) {
|
|
5098
|
+
// USER-DATA move: use an INDEPENDENT copy (never a hard link - a kept
|
|
5099
|
+
// source and a hard-linked backup share an inode, so a later in-place
|
|
5100
|
+
// edit or truncation of the still-active legacy file would mutate the
|
|
5101
|
+
// backup and the marker hash would no longer preserve the migrated
|
|
5102
|
+
// bytes) and keep the source ACTIVE (never unlink; a path-based unlink
|
|
5103
|
+
// after a non-atomic identity check could delete a rename-replaced
|
|
5104
|
+
// file). The caller re-verifies the source before the complete marker.
|
|
5105
|
+
try {
|
|
5106
|
+
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
5107
|
+
} catch {
|
|
5108
|
+
return false;
|
|
5109
|
+
}
|
|
5110
|
+
// Durable before the complete marker: sync the copied backup (a power
|
|
5111
|
+
// loss must not leave a surviving marker + target with a missing or
|
|
5112
|
+
// empty backup).
|
|
5113
|
+
const userCopyHandle = await fs.promises.open(destination, "r");
|
|
5114
|
+
try {
|
|
5115
|
+
await userCopyHandle.sync();
|
|
5116
|
+
} finally {
|
|
5117
|
+
await userCopyHandle.close();
|
|
5118
|
+
}
|
|
5119
|
+
// Capture the identity of the copy we just made BEFORE any further
|
|
5120
|
+
// verification: an external replacement or in-place edit after this
|
|
5121
|
+
// point must never be removed by an abort path.
|
|
5122
|
+
let copiedDestIno: number | null = null;
|
|
5123
|
+
let copiedDestSha: string | null = null;
|
|
5124
|
+
try {
|
|
5125
|
+
copiedDestIno = (await fs.promises.stat(destination)).ino;
|
|
5126
|
+
copiedDestSha = await this.#sha256File(destination);
|
|
5127
|
+
} catch {
|
|
5128
|
+
// The copy vanished right after creation; nothing of ours remains.
|
|
5129
|
+
return false;
|
|
5130
|
+
}
|
|
5131
|
+
await SettingsMigrationTestHooks.afterBackupIdentityCaptured?.(destination);
|
|
5132
|
+
let copiedSourceHash: string | null = null;
|
|
5133
|
+
try {
|
|
5134
|
+
copiedSourceHash = await this.#sha256File(source);
|
|
5135
|
+
} catch {
|
|
5136
|
+
// The source was deleted right after the copy: remove the copy
|
|
5137
|
+
// ONLY while it is still the file we created (an external
|
|
5138
|
+
// replacement or edit at the backup pathname must be preserved)
|
|
5139
|
+
// and report failure so the caller reverts the target.
|
|
5140
|
+
await this.#removeIfStillOurs(destination, copiedDestIno, copiedDestSha);
|
|
5141
|
+
return false;
|
|
5142
|
+
}
|
|
5143
|
+
if (copiedSourceHash !== expectedSourceSha256) {
|
|
5144
|
+
await this.#removeIfStillOurs(destination, copiedDestIno, copiedDestSha);
|
|
5145
|
+
return false;
|
|
5146
|
+
}
|
|
5147
|
+
return true;
|
|
5148
|
+
}
|
|
5149
|
+
// Internal artifacts (marker quarantine): capture the inode, hard-link
|
|
5150
|
+
// (copy fallback), and remove the source name only while it is still the
|
|
5151
|
+
// inode we verified.
|
|
5152
|
+
let sourceIno: number | undefined;
|
|
5153
|
+
try {
|
|
5154
|
+
sourceIno = (await fs.promises.stat(source)).ino;
|
|
5155
|
+
} catch {
|
|
5156
|
+
return false;
|
|
5157
|
+
}
|
|
5158
|
+
try {
|
|
5159
|
+
// Atomic same-directory no-clobber move via hard link + unlink.
|
|
5160
|
+
await fs.promises.link(source, destination);
|
|
5161
|
+
} catch (error) {
|
|
5162
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
5163
|
+
// Filesystems without hard links: a no-clobber copy. COPYFILE_EXCL
|
|
5164
|
+
// fails with EEXIST if the destination appears, so it can never
|
|
5165
|
+
// replace an existing `.bak`/quarantine - unlike a raw rename, which
|
|
5166
|
+
// would overwrite a destination created after the lstat above.
|
|
5167
|
+
try {
|
|
5168
|
+
await fs.promises.copyFile(source, destination, fs.constants.COPYFILE_EXCL);
|
|
5169
|
+
} catch {
|
|
5170
|
+
return false;
|
|
5171
|
+
}
|
|
5172
|
+
// Durable before the complete marker: sync the copied backup file (a
|
|
5173
|
+
// power loss must not leave a surviving marker + target with a
|
|
5174
|
+
// missing or empty backup).
|
|
5175
|
+
const copyHandle = await fs.promises.open(destination, "r");
|
|
5176
|
+
try {
|
|
5177
|
+
await copyHandle.sync();
|
|
5178
|
+
} finally {
|
|
5179
|
+
await copyHandle.close();
|
|
5180
|
+
}
|
|
5181
|
+
}
|
|
5182
|
+
let destinationIno: number | null = null;
|
|
5183
|
+
try {
|
|
5184
|
+
destinationIno = (await fs.promises.stat(destination)).ino;
|
|
5185
|
+
} catch {
|
|
5186
|
+
return false;
|
|
5187
|
+
}
|
|
5188
|
+
if (!(await this.#legacySourceStillVerified(source, sourceIno))) {
|
|
5189
|
+
// Remove the destination ONLY while it is still the entry created
|
|
5190
|
+
// above; an external replacement at the quarantine pathname must be
|
|
5191
|
+
// preserved.
|
|
5192
|
+
await this.#removeIfStillOurs(destination, destinationIno);
|
|
5193
|
+
return false;
|
|
5194
|
+
}
|
|
5195
|
+
try {
|
|
5196
|
+
await fs.promises.rm(source, { force: true });
|
|
5197
|
+
} catch {
|
|
5198
|
+
return false;
|
|
5199
|
+
}
|
|
5200
|
+
return true;
|
|
5201
|
+
}
|
|
5202
|
+
|
|
5203
|
+
/**
|
|
5204
|
+
* Remove `path` only while it is still the file this migration created:
|
|
5205
|
+
* the observed inode must match and (when a hash is given) the bytes must
|
|
5206
|
+
* too. A concurrent external process that replaced or edited the file at
|
|
5207
|
+
* this pathname must never be deleted - the migration's no-clobber promise
|
|
5208
|
+
* preserves externally created `.bak`/quarantine data, mirroring the
|
|
5209
|
+
* identity guards already applied to config targets and strict-evidence
|
|
5210
|
+
* files.
|
|
5211
|
+
*/
|
|
5212
|
+
async #removeIfStillOurs(path: string, expectedIno: number, expectedSha256?: string): Promise<boolean> {
|
|
5213
|
+
// Atomically QUARANTINE the entry (same-directory rename) before any
|
|
5214
|
+
// verification: the identity check and the unlink then operate on the
|
|
5215
|
+
// private quarantine name, so an external process that replaces the
|
|
5216
|
+
// file at the PUBLIC pathname after the check but before the removal
|
|
5217
|
+
// can no longer have its file deleted. Whatever the rename moved is
|
|
5218
|
+
// what gets verified; a mismatch is restored to the public pathname
|
|
5219
|
+
// (no-clobber) or retained under the quarantine name, never unlinked.
|
|
5220
|
+
const quarantine = `${path}.quarantine-${process.pid}-${nodeCrypto.randomUUID()}`;
|
|
5221
|
+
try {
|
|
5222
|
+
await fs.promises.rename(path, quarantine);
|
|
5223
|
+
} catch {
|
|
5224
|
+
// Nothing to remove (ENOENT) or the entry cannot be moved: removal
|
|
5225
|
+
// by pathname is never attempted - the quarantine is the only
|
|
5226
|
+
// removal basis.
|
|
5227
|
+
return false;
|
|
5228
|
+
}
|
|
5229
|
+
const stat = await fs.promises.stat(quarantine).catch(() => null);
|
|
5230
|
+
if (!stat || stat.ino !== expectedIno) {
|
|
5231
|
+
await this.#restoreQuarantined(quarantine, path);
|
|
5232
|
+
return false;
|
|
5233
|
+
}
|
|
5234
|
+
if (expectedSha256 !== undefined && (await this.#sha256File(quarantine)) !== expectedSha256) {
|
|
5235
|
+
await this.#restoreQuarantined(quarantine, path);
|
|
5236
|
+
return false;
|
|
5237
|
+
}
|
|
5238
|
+
await SettingsMigrationTestHooks.beforeQuarantineRemoval?.(path);
|
|
5239
|
+
await fs.promises.rm(quarantine, { force: true }).catch(() => undefined);
|
|
5240
|
+
return true;
|
|
5241
|
+
}
|
|
5242
|
+
|
|
5243
|
+
/**
|
|
5244
|
+
* Restore a quarantined entry to its original pathname without ever
|
|
5245
|
+
* replacing a file another process published there meanwhile: the restore
|
|
5246
|
+
* is no-clobber (hard link, then a copy fallback), and on any failure the
|
|
5247
|
+
* quarantined entry is RETAINED (never deleted) so the data stays
|
|
5248
|
+
* recoverable.
|
|
5249
|
+
*/
|
|
5250
|
+
async #restoreQuarantined(quarantine: string, original: string): Promise<void> {
|
|
5251
|
+
try {
|
|
5252
|
+
await fs.promises.link(quarantine, original);
|
|
5253
|
+
await fs.promises.rm(quarantine, { force: true }).catch(() => undefined);
|
|
5254
|
+
return;
|
|
5255
|
+
} catch (error) {
|
|
5256
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") {
|
|
5257
|
+
// The pathname was re-occupied while quarantined: keep the
|
|
5258
|
+
// quarantined entry (displaced, never deleted).
|
|
5259
|
+
return;
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
try {
|
|
5263
|
+
// Filesystems without hard links: a no-clobber copy fallback.
|
|
5264
|
+
await fs.promises.copyFile(quarantine, original, fs.constants.COPYFILE_EXCL);
|
|
5265
|
+
await fs.promises.rm(quarantine, { force: true }).catch(() => undefined);
|
|
5266
|
+
} catch {
|
|
5267
|
+
// Occupied or uncopyable: retain the quarantined file so the data
|
|
5268
|
+
// stays recoverable.
|
|
5269
|
+
}
|
|
5270
|
+
}
|
|
5271
|
+
/**
|
|
5272
|
+
* True only if `path` still refers to the same inode that was verified
|
|
5273
|
+
* earlier and (when an expected hash is given) still holds the verified
|
|
5274
|
+
* bytes. Used immediately before any unlink of a legacy source so a
|
|
5275
|
+
* concurrent rename-style save or in-place edit is never consumed.
|
|
5276
|
+
*/
|
|
5277
|
+
async #legacySourceStillVerified(
|
|
5278
|
+
path: string,
|
|
5279
|
+
expectedIno: number,
|
|
5280
|
+
expectedSourceSha256?: string,
|
|
5281
|
+
): Promise<boolean> {
|
|
5282
|
+
const stat = await fs.promises.stat(path).catch(() => null);
|
|
5283
|
+
if (!stat || stat.ino !== expectedIno) return false;
|
|
5284
|
+
if (expectedSourceSha256 !== undefined && (await this.#sha256File(path)) !== expectedSourceSha256) return false;
|
|
5285
|
+
return true;
|
|
5286
|
+
}
|
|
5287
|
+
|
|
5288
|
+
async #pathExists(target: string): Promise<boolean> {
|
|
5289
|
+
try {
|
|
5290
|
+
await fs.promises.lstat(target);
|
|
5291
|
+
return true;
|
|
5292
|
+
} catch (error) {
|
|
5293
|
+
// Only ENOENT means absence; a transient EACCES/EIO failure must
|
|
5294
|
+
// propagate so recovery never mistakes it for a deletion/removal.
|
|
5295
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
5296
|
+
throw error;
|
|
5297
|
+
}
|
|
5298
|
+
}
|
|
5299
|
+
|
|
5300
|
+
async #sha256File(target: string): Promise<string> {
|
|
5301
|
+
const raw = await Bun.file(target).arrayBuffer();
|
|
5302
|
+
return nodeCrypto.createHash("sha256").update(Buffer.from(raw)).digest("hex");
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
#hasCustomThemeFile(name: string): boolean {
|
|
5306
|
+
try {
|
|
5307
|
+
return fs.existsSync(path.join(getCustomThemesDir(this.#agentDir), `${name}.json`));
|
|
5308
|
+
} catch {
|
|
5309
|
+
return false;
|
|
5310
|
+
}
|
|
5311
|
+
}
|
|
5312
|
+
|
|
5313
|
+
#migrateLegacyBuiltInThemeName(name: string): string {
|
|
5314
|
+
if (isLegacyThemeName(name) && !this.#hasCustomThemeFile(name)) {
|
|
5315
|
+
return LEGACY_THEME_NAME_REPLACEMENTS[name];
|
|
5316
|
+
}
|
|
5317
|
+
return name;
|
|
5318
|
+
}
|
|
5319
|
+
|
|
5320
|
+
#getThemeSlotForName(name: string): "dark" | "light" {
|
|
5321
|
+
return isLightTheme(name, this.#agentDir) ? "light" : "dark";
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
/** Apply registered schema migrations once, using configSchemaVersion as the durable marker. */
|
|
5325
|
+
#migrateRawSettings(raw: RawSettings): RawSettings {
|
|
5326
|
+
const configuredVersion = raw.configSchemaVersion;
|
|
5327
|
+
if (configuredVersion === CONFIG_SCHEMA_VERSION) return raw;
|
|
5328
|
+
if (typeof configuredVersion === "number" && configuredVersion > CONFIG_SCHEMA_VERSION) return raw;
|
|
5329
|
+
|
|
5330
|
+
// Migration registry v0 -> v1.
|
|
5331
|
+
// queueMode -> steeringMode
|
|
5332
|
+
normalizeSessionDirectoryMigration(raw);
|
|
5333
|
+
if ("queueMode" in raw && !("steeringMode" in raw)) {
|
|
5334
|
+
raw.steeringMode = raw.queueMode;
|
|
5335
|
+
delete raw.queueMode;
|
|
5336
|
+
}
|
|
5337
|
+
// ask.timeout: v0 stored milliseconds; v1 stores seconds.
|
|
5338
|
+
if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
|
|
5339
|
+
const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
|
|
5340
|
+
if (oldValue > 1000) (raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
|
|
5341
|
+
}
|
|
5342
|
+
|
|
5343
|
+
// Migrate old flat "theme" string to nested theme.dark/theme.light
|
|
5344
|
+
if (typeof raw.theme === "string") {
|
|
5345
|
+
const oldTheme = raw.theme;
|
|
5346
|
+
const migratedTheme = this.#migrateLegacyBuiltInThemeName(oldTheme);
|
|
5347
|
+
if (oldTheme === "dark" && migratedTheme === "red-claw") {
|
|
5348
|
+
raw.theme = { dark: migratedTheme };
|
|
5349
|
+
} else if (oldTheme === "light" && migratedTheme === "blue-crab") {
|
|
5350
|
+
raw.theme = { light: migratedTheme };
|
|
5351
|
+
} else {
|
|
5352
|
+
const slot = this.#getThemeSlotForName(migratedTheme);
|
|
5353
|
+
raw.theme = { [slot]: migratedTheme };
|
|
5354
|
+
}
|
|
5355
|
+
} else if (raw.theme && typeof raw.theme === "object" && !Array.isArray(raw.theme)) {
|
|
5356
|
+
const themeObj = raw.theme as Record<string, unknown>;
|
|
5357
|
+
if (typeof themeObj.dark === "string") {
|
|
5358
|
+
themeObj.dark = this.#migrateLegacyBuiltInThemeName(themeObj.dark);
|
|
5359
|
+
}
|
|
5360
|
+
if (typeof themeObj.light === "string") {
|
|
5361
|
+
themeObj.light = this.#migrateLegacyBuiltInThemeName(themeObj.light);
|
|
5362
|
+
}
|
|
5363
|
+
}
|
|
5364
|
+
|
|
5365
|
+
// task.isolation.enabled (boolean) -> task.isolation.mode (enum)
|
|
5366
|
+
const taskObj = raw.task as Record<string, unknown> | undefined;
|
|
5367
|
+
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
|
|
5368
|
+
if (isolationObj && "enabled" in isolationObj) {
|
|
5369
|
+
if (typeof isolationObj.enabled === "boolean") {
|
|
5370
|
+
isolationObj.mode = isolationObj.enabled ? "auto" : "none";
|
|
5371
|
+
}
|
|
5372
|
+
delete isolationObj.enabled;
|
|
5373
|
+
}
|
|
5374
|
+
|
|
5375
|
+
// task.isolation.mode: legacy values from before the pi-iso PAL refactor.
|
|
5376
|
+
// `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay`
|
|
5377
|
+
// and `fuse-projfs` are now the platform-named `overlayfs` / `projfs`
|
|
5378
|
+
// kinds; the PAL falls back internally when the chosen one isn't
|
|
5379
|
+
// available, so we don't need the old TS-side platform guards.
|
|
5380
|
+
if (isolationObj && typeof isolationObj.mode === "string") {
|
|
5381
|
+
const legacy: Record<string, string> = {
|
|
5382
|
+
worktree: "rcopy",
|
|
5383
|
+
"fuse-overlay": "overlayfs",
|
|
5384
|
+
"fuse-projfs": "projfs",
|
|
5385
|
+
};
|
|
5386
|
+
const mapped = legacy[isolationObj.mode as string];
|
|
5387
|
+
if (mapped !== undefined) {
|
|
5388
|
+
isolationObj.mode = mapped;
|
|
5389
|
+
}
|
|
5390
|
+
}
|
|
5391
|
+
|
|
5392
|
+
// edit.mode: removed "atom" variant is now "hashline"
|
|
5393
|
+
const editObj = raw.edit as Record<string, unknown> | undefined;
|
|
5394
|
+
if (editObj) {
|
|
5395
|
+
if (editObj.mode === "atom") {
|
|
5396
|
+
editObj.mode = "hashline";
|
|
5397
|
+
}
|
|
5398
|
+
const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
|
|
5399
|
+
if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
|
|
5400
|
+
for (const [pattern, variant] of Object.entries(modelVariants)) {
|
|
5401
|
+
if (variant === "atom") {
|
|
5402
|
+
modelVariants[pattern] = "hashline";
|
|
5403
|
+
}
|
|
5404
|
+
}
|
|
5405
|
+
}
|
|
5406
|
+
}
|
|
5407
|
+
if (raw["edit.mode"] === "atom") {
|
|
5408
|
+
raw["edit.mode"] = "hashline";
|
|
5409
|
+
}
|
|
5410
|
+
|
|
5411
|
+
// statusLine: rename "plan_mode" segment to "mode"
|
|
5412
|
+
const statusLineObj = raw.statusLine as Record<string, unknown> | undefined;
|
|
5413
|
+
if (statusLineObj) {
|
|
5414
|
+
for (const key of ["leftSegments", "rightSegments"] as const) {
|
|
5415
|
+
const segments = statusLineObj[key];
|
|
5416
|
+
if (Array.isArray(segments)) {
|
|
5417
|
+
statusLineObj[key] = segments.map(seg => (seg === "plan_mode" ? "mode" : seg));
|
|
5418
|
+
}
|
|
5419
|
+
}
|
|
5420
|
+
const segmentOptions = statusLineObj.segmentOptions as Record<string, unknown> | undefined;
|
|
5421
|
+
if (segmentOptions && "plan_mode" in segmentOptions && !("mode" in segmentOptions)) {
|
|
5422
|
+
segmentOptions.mode = segmentOptions.plan_mode;
|
|
5423
|
+
delete segmentOptions.plan_mode;
|
|
5424
|
+
}
|
|
5425
|
+
}
|
|
5426
|
+
|
|
5427
|
+
// Map legacy `memories.enabled` boolean to the explicit `memory.backend`
|
|
5428
|
+
// enum if the latter hasn't been set yet. Idempotent: subsequent
|
|
5429
|
+
// migrations are no-ops once memory.backend is materialised.
|
|
5430
|
+
const memoryBackendObj = raw.memory as Record<string, unknown> | undefined;
|
|
5431
|
+
const memoryBackendSet = memoryBackendObj && typeof memoryBackendObj.backend === "string";
|
|
5432
|
+
const memoriesObj = raw.memories as Record<string, unknown> | undefined;
|
|
5433
|
+
if (!memoryBackendSet && memoriesObj && typeof memoriesObj.enabled === "boolean") {
|
|
5434
|
+
const next = memoriesObj.enabled ? "local" : "off";
|
|
5435
|
+
const memoryRoot = (memoryBackendObj ?? {}) as Record<string, unknown>;
|
|
5436
|
+
memoryRoot.backend = next;
|
|
5437
|
+
raw.memory = memoryRoot;
|
|
5438
|
+
}
|
|
5439
|
+
|
|
1635
5440
|
// hindsight: dynamicBankId/agentName -> scoping enum + bankId
|
|
1636
5441
|
// - dynamicBankId=true → scoping="per-project" (closest semantic match;
|
|
1637
5442
|
// the legacy `agent::project::channel::user` tuple was per-project in
|
|
@@ -1664,35 +5469,28 @@ export class Settings implements NotificationSettingsReader {
|
|
|
1664
5469
|
// Migrate legacy providers.image* settings into modelRoles.image.
|
|
1665
5470
|
// The image-generation model is now selected via the model-role system
|
|
1666
5471
|
// (/model image <selector>) instead of a separate provider config dialog.
|
|
1667
|
-
const providersObj = raw.providers
|
|
5472
|
+
const providersObj = rawSettingsRecord(raw.providers);
|
|
1668
5473
|
if (providersObj && typeof providersObj.image === "string") {
|
|
1669
|
-
const imageProvider = providersObj.image
|
|
5474
|
+
const imageProvider = providersObj.image;
|
|
1670
5475
|
const imageModel = typeof providersObj.imageModel === "string" ? providersObj.imageModel : undefined;
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
if (imageProvider !== "
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
if (
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
const customProviderId = "image-custom";
|
|
1681
|
-
roles.image = `${customProviderId}/${imageModel ?? "gpt-image-2"}`;
|
|
1682
|
-
} else if (imageModel) {
|
|
1683
|
-
roles.image = `${imageProvider}/${imageModel}`;
|
|
1684
|
-
} else {
|
|
1685
|
-
roles.image = imageProvider;
|
|
5476
|
+
|
|
5477
|
+
// A custom endpoint belongs in models.yml. Retain the complete legacy
|
|
5478
|
+
// configuration rather than creating an unresolvable image-custom selector.
|
|
5479
|
+
if (imageProvider !== "custom") {
|
|
5480
|
+
if (imageProvider !== "auto") {
|
|
5481
|
+
const roles = rawSettingsRecord(raw.modelRoles) ?? {};
|
|
5482
|
+
if (!roles.image) {
|
|
5483
|
+
roles.image = imageModel ? `${imageProvider}/${imageModel}` : imageProvider;
|
|
5484
|
+
raw.modelRoles = roles;
|
|
1686
5485
|
}
|
|
1687
|
-
raw.modelRoles = roles;
|
|
1688
5486
|
}
|
|
1689
|
-
}
|
|
1690
5487
|
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
5488
|
+
delete providersObj.image;
|
|
5489
|
+
delete providersObj.imageModel;
|
|
5490
|
+
delete providersObj.imageCustomUrl;
|
|
5491
|
+
delete providersObj.imageCustomKey;
|
|
5492
|
+
delete providersObj.imageCustomKeyEnv;
|
|
5493
|
+
}
|
|
1696
5494
|
}
|
|
1697
5495
|
|
|
1698
5496
|
raw.configSchemaVersion = CONFIG_SCHEMA_VERSION;
|
|
@@ -2076,10 +5874,29 @@ export class Settings implements NotificationSettingsReader {
|
|
|
2076
5874
|
#stripProjectNotificationSettings(settings: RawSettings): {
|
|
2077
5875
|
settings: RawSettings;
|
|
2078
5876
|
rejectedNotifications: boolean;
|
|
5877
|
+
rejectedCredentialPins: boolean;
|
|
2079
5878
|
} {
|
|
2080
5879
|
let rejectedNotifications = false;
|
|
5880
|
+
let rejectedCredentialPins = false;
|
|
2081
5881
|
const sanitized: RawSettings = {};
|
|
2082
5882
|
for (const [key, value] of Object.entries(settings)) {
|
|
5883
|
+
if (key === "auth" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
5884
|
+
const authSettings = { ...(value as Record<string, unknown>) };
|
|
5885
|
+
if (Object.hasOwn(authSettings, "credentialPins")) {
|
|
5886
|
+
delete authSettings.credentialPins;
|
|
5887
|
+
rejectedCredentialPins = true;
|
|
5888
|
+
}
|
|
5889
|
+
if (Object.hasOwn(authSettings, "credentialPinStoreIdentity")) {
|
|
5890
|
+
delete authSettings.credentialPinStoreIdentity;
|
|
5891
|
+
rejectedCredentialPins = true;
|
|
5892
|
+
}
|
|
5893
|
+
if (Object.keys(authSettings).length > 0) sanitized[key] = authSettings;
|
|
5894
|
+
continue;
|
|
5895
|
+
}
|
|
5896
|
+
if (key === "auth.credentialPins" || key === "auth.credentialPinStoreIdentity") {
|
|
5897
|
+
rejectedCredentialPins = true;
|
|
5898
|
+
continue;
|
|
5899
|
+
}
|
|
2083
5900
|
if (key === "notifications" && value && typeof value === "object" && !Array.isArray(value)) {
|
|
2084
5901
|
const localNotifications: Record<string, unknown> = {};
|
|
2085
5902
|
for (const [notificationKey, notificationValue] of Object.entries(value)) {
|
|
@@ -2098,7 +5915,7 @@ export class Settings implements NotificationSettingsReader {
|
|
|
2098
5915
|
}
|
|
2099
5916
|
sanitized[key] = value;
|
|
2100
5917
|
}
|
|
2101
|
-
return { settings: sanitized, rejectedNotifications };
|
|
5918
|
+
return { settings: sanitized, rejectedNotifications, rejectedCredentialPins };
|
|
2102
5919
|
}
|
|
2103
5920
|
|
|
2104
5921
|
#deepMerge(base: RawSettings, overrides: RawSettings): RawSettings {
|
|
@@ -2204,6 +6021,28 @@ export function resetSettingsForTest(): void {
|
|
|
2204
6021
|
globalInitOptions = null;
|
|
2205
6022
|
}
|
|
2206
6023
|
|
|
6024
|
+
/**
|
|
6025
|
+
* Ensure the one-time legacy workflow settings migrations have run for `cwd`
|
|
6026
|
+
* before a workflow runtime resolves settings. The direct CLI commands
|
|
6027
|
+
* (`gjc ralplan`, `gjc deep-interview`, `gjc ultragoal`) never initialize
|
|
6028
|
+
* Settings, so without this the project `.gjc/settings.json` and config-root
|
|
6029
|
+
* `settings.json` workflow keys would silently fall back to defaults after the
|
|
6030
|
+
* resolver stopped reading settings.json. No-op when the global Settings
|
|
6031
|
+
* singleton is already initialized (session path). Best-effort: a settings
|
|
6032
|
+
* load failure must not block the native command, whose resolver falls back to
|
|
6033
|
+
* defaults exactly as before.
|
|
6034
|
+
*/
|
|
6035
|
+
export async function ensureWorkflowSettingsMigrated(cwd: string): Promise<void> {
|
|
6036
|
+
if (isSettingsInitialized()) return;
|
|
6037
|
+
try {
|
|
6038
|
+
await Settings.loadForScope({ cwd });
|
|
6039
|
+
} catch (error) {
|
|
6040
|
+
logger.warn("Settings: workflow migration trigger could not load settings", {
|
|
6041
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6042
|
+
});
|
|
6043
|
+
}
|
|
6044
|
+
}
|
|
6045
|
+
|
|
2207
6046
|
/**
|
|
2208
6047
|
* The global settings singleton.
|
|
2209
6048
|
* Must call `Settings.init()` before using.
|