@gajae-code/coding-agent 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- package/src/web/search/providers/openai-compatible.ts +191 -14
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared target-adjacent v1 activation record authority for `gjc update` and
|
|
3
|
+
* `gjc doctor install.restore-binary`.
|
|
4
|
+
*
|
|
5
|
+
* Every identity capture is a bounded, no-follow, post-read-revalidated
|
|
6
|
+
* snapshot, never a bare fs.readFile/fs.stat pair. Every record write is a
|
|
7
|
+
* no-replace create or an exact CAS against the caller's own previously
|
|
8
|
+
* observed record identity, never a blind fs.rename overwrite. Promoting a
|
|
9
|
+
* staged candidate onto the live target uses the native exactReplaceRetained
|
|
10
|
+
* primitive: it validates both identities before mutating anything and
|
|
11
|
+
* retires the old target to a retained backup name instead of deleting it.
|
|
12
|
+
* There is no unlink/rename-based rollback path in this module.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
16
|
+
import { constants } from "node:fs";
|
|
17
|
+
import * as fs from "node:fs/promises";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import {
|
|
20
|
+
exactReplacePath,
|
|
21
|
+
exactReplaceRetained,
|
|
22
|
+
exactRestore,
|
|
23
|
+
type NativeExactFileIdentity,
|
|
24
|
+
} from "@gajae-code/natives";
|
|
25
|
+
|
|
26
|
+
export type ActivationPhase = "staged" | "published" | "verified" | "pending_activation" | "rolled_back" | "uncertain";
|
|
27
|
+
|
|
28
|
+
/** Serialized exact regular-file identity. BigInt fields are decimal strings so the record stays plain JSON. */
|
|
29
|
+
export interface FileIdentity {
|
|
30
|
+
readonly dev: string;
|
|
31
|
+
readonly ino: string;
|
|
32
|
+
readonly nlink: string;
|
|
33
|
+
readonly parentDev?: string;
|
|
34
|
+
readonly parentIno?: string;
|
|
35
|
+
readonly size: string;
|
|
36
|
+
readonly mtimeNs: string;
|
|
37
|
+
readonly mode: number;
|
|
38
|
+
readonly sha256: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Serialized directory identity. No content hash: a directory has no bytes to bind. */
|
|
42
|
+
export interface DirectoryIdentity {
|
|
43
|
+
readonly dev: string;
|
|
44
|
+
readonly ino: string;
|
|
45
|
+
readonly mode: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ActivationRecordV1 {
|
|
49
|
+
readonly schemaVersion: 1;
|
|
50
|
+
readonly kind: "gjc-install-activation";
|
|
51
|
+
readonly transactionId: string;
|
|
52
|
+
readonly targetPath: string;
|
|
53
|
+
readonly parentPath: string;
|
|
54
|
+
readonly targetIdentity?: FileIdentity;
|
|
55
|
+
readonly backupIdentity?: FileIdentity;
|
|
56
|
+
readonly parentIdentity?: DirectoryIdentity;
|
|
57
|
+
readonly baseline: { readonly exists: boolean; readonly digest?: string; readonly version?: string };
|
|
58
|
+
readonly candidate: {
|
|
59
|
+
readonly digest: string;
|
|
60
|
+
readonly version: string;
|
|
61
|
+
readonly channel?: string;
|
|
62
|
+
readonly ref?: string;
|
|
63
|
+
readonly os?: string;
|
|
64
|
+
readonly arch?: string;
|
|
65
|
+
readonly sidecars: readonly string[];
|
|
66
|
+
};
|
|
67
|
+
readonly stagingPath: string;
|
|
68
|
+
/** Staged candidate identity captured when recorded; re-checked before promotion. */
|
|
69
|
+
readonly stagingIdentity?: FileIdentity;
|
|
70
|
+
/** Single-component name exactReplaceRetained retires the old target to, inside the target's own parent. */
|
|
71
|
+
readonly backupName: string;
|
|
72
|
+
readonly phase: ActivationPhase;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ActivationRead =
|
|
76
|
+
| { readonly status: "missing" }
|
|
77
|
+
| { readonly status: "valid"; readonly record: ActivationRecordV1; readonly identity: FileIdentity }
|
|
78
|
+
| { readonly status: "malformed"; readonly reason: string }
|
|
79
|
+
| { readonly status: "foreign"; readonly reason: string };
|
|
80
|
+
|
|
81
|
+
/** Public snapshot API shared by callers (update-cli/doctor) to capture original target/staging identities. */
|
|
82
|
+
export async function snapshotRegularFile(
|
|
83
|
+
filePath: string,
|
|
84
|
+
): Promise<{ readonly header: Uint8Array; readonly identity: FileIdentity } | undefined> {
|
|
85
|
+
const snapshot = await readExactRegularFile(filePath, 512 * 1024 * 1024, 8);
|
|
86
|
+
return snapshot ? { header: snapshot.bytes, identity: snapshot.identity } : undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function snapshotDirectory(directoryPath: string): Promise<DirectoryIdentity | undefined> {
|
|
90
|
+
return readDirectoryIdentity(directoryPath);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function sameFileIdentity(a: FileIdentity, b: FileIdentity): boolean {
|
|
94
|
+
return (
|
|
95
|
+
a.dev === b.dev &&
|
|
96
|
+
a.ino === b.ino &&
|
|
97
|
+
a.nlink === b.nlink &&
|
|
98
|
+
a.size === b.size &&
|
|
99
|
+
a.mtimeNs === b.mtimeNs &&
|
|
100
|
+
a.mode === b.mode &&
|
|
101
|
+
a.sha256 === b.sha256 &&
|
|
102
|
+
a.parentDev === b.parentDev &&
|
|
103
|
+
a.parentIno === b.parentIno
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function sameDirectoryIdentity(a: DirectoryIdentity | undefined, b: DirectoryIdentity | undefined): boolean {
|
|
108
|
+
return !!a && !!b && a.dev === b.dev && a.ino === b.ino && a.mode === b.mode;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function toNativeIdentity(identity: FileIdentity, quarantineName?: string): NativeExactFileIdentity {
|
|
112
|
+
return {
|
|
113
|
+
dev: BigInt(identity.dev),
|
|
114
|
+
ino: BigInt(identity.ino),
|
|
115
|
+
nlink: BigInt(identity.nlink),
|
|
116
|
+
...(identity.parentDev !== undefined && identity.parentIno !== undefined
|
|
117
|
+
? { parentDev: BigInt(identity.parentDev), parentIno: BigInt(identity.parentIno) }
|
|
118
|
+
: {}),
|
|
119
|
+
size: BigInt(identity.size),
|
|
120
|
+
mtimeNs: BigInt(identity.mtimeNs),
|
|
121
|
+
sha256: identity.sha256,
|
|
122
|
+
...(quarantineName ? { quarantineName } : {}),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function activationRecordPath(targetPath: string): string {
|
|
127
|
+
return `${path.resolve(targetPath)}.activation.v1.json`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Deterministic-enough, collision-checked single-component retained backup name for one transaction. */
|
|
131
|
+
export function activationBackupName(targetPath: string, transactionId: string): string {
|
|
132
|
+
return `${path.basename(path.resolve(targetPath))}.activation-backup.${transactionId}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function createActivationTransactionId(): string {
|
|
136
|
+
return randomUUID();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Pure builder for a fresh `staged` activation record; callers still write it via createActivationRecordFile. */
|
|
140
|
+
export function buildActivationRecord(input: {
|
|
141
|
+
readonly targetPath: string;
|
|
142
|
+
readonly targetIdentity?: FileIdentity;
|
|
143
|
+
readonly originalTargetAbsent?: boolean;
|
|
144
|
+
readonly parentIdentity: DirectoryIdentity;
|
|
145
|
+
readonly baselineDigest?: string;
|
|
146
|
+
readonly baselineVersion?: string;
|
|
147
|
+
readonly stagingPath: string;
|
|
148
|
+
readonly stagingIdentity: FileIdentity;
|
|
149
|
+
readonly candidate: {
|
|
150
|
+
readonly digest: string;
|
|
151
|
+
readonly version: string;
|
|
152
|
+
readonly channel?: string;
|
|
153
|
+
readonly ref?: string;
|
|
154
|
+
readonly os?: string;
|
|
155
|
+
readonly arch?: string;
|
|
156
|
+
readonly sidecars?: readonly string[];
|
|
157
|
+
};
|
|
158
|
+
readonly transactionId?: string;
|
|
159
|
+
}): ActivationRecordV1 {
|
|
160
|
+
if (input.originalTargetAbsent ? input.targetIdentity !== undefined : input.targetIdentity === undefined)
|
|
161
|
+
throw new Error("original_target_observation_missing");
|
|
162
|
+
const transactionId = input.transactionId ?? createActivationTransactionId();
|
|
163
|
+
return {
|
|
164
|
+
schemaVersion: 1,
|
|
165
|
+
kind: "gjc-install-activation",
|
|
166
|
+
transactionId,
|
|
167
|
+
targetPath: path.resolve(input.targetPath),
|
|
168
|
+
parentPath: path.dirname(path.resolve(input.targetPath)),
|
|
169
|
+
targetIdentity: input.targetIdentity,
|
|
170
|
+
parentIdentity: input.parentIdentity,
|
|
171
|
+
baseline: { exists: !input.originalTargetAbsent, digest: input.baselineDigest, version: input.baselineVersion },
|
|
172
|
+
candidate: { ...input.candidate, sidecars: input.candidate.sidecars ?? [] },
|
|
173
|
+
stagingPath: input.stagingPath,
|
|
174
|
+
stagingIdentity: input.stagingIdentity,
|
|
175
|
+
backupName: activationBackupName(input.targetPath, transactionId),
|
|
176
|
+
phase: "staged",
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const READ_LIMIT = 256 * 1024;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Bounded, no-follow, identity-revalidated read of one regular file. Rejects
|
|
184
|
+
* symlinks, non-regular files, a symlinked/changed parent, and any content
|
|
185
|
+
* or identity drift observed between the open and the final lexical check.
|
|
186
|
+
* Returns `undefined` only for a confirmed-absent path (ENOENT); every other
|
|
187
|
+
* failure — including a torn/changed read — throws, since presence is a
|
|
188
|
+
* distinct fact from disallowed observation.
|
|
189
|
+
*/
|
|
190
|
+
async function readExactRegularFile(
|
|
191
|
+
filePath: string,
|
|
192
|
+
byteLimit = READ_LIMIT,
|
|
193
|
+
prefixLimit = byteLimit,
|
|
194
|
+
): Promise<{ readonly bytes: Uint8Array; readonly identity: FileIdentity } | undefined> {
|
|
195
|
+
const resolved = path.resolve(filePath);
|
|
196
|
+
const parentPath = path.dirname(resolved);
|
|
197
|
+
let handle: fs.FileHandle | undefined;
|
|
198
|
+
try {
|
|
199
|
+
const before = await fs.lstat(resolved, { bigint: true });
|
|
200
|
+
if (before.isSymbolicLink() || !before.isFile()) throw new Error("target_not_regular");
|
|
201
|
+
if (before.size > BigInt(byteLimit)) throw new Error("target_size_limit");
|
|
202
|
+
const parentBefore = await fs.lstat(parentPath, { bigint: true });
|
|
203
|
+
if (parentBefore.isSymbolicLink() || !parentBefore.isDirectory()) throw new Error("parent_not_directory");
|
|
204
|
+
handle = await fs.open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
205
|
+
const opened = await handle.stat({ bigint: true });
|
|
206
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino) throw new Error("target_changed");
|
|
207
|
+
const bytes = new Uint8Array(prefixLimit);
|
|
208
|
+
const chunk = new Uint8Array(64 * 1024);
|
|
209
|
+
const digest = createHash("sha256");
|
|
210
|
+
let length = 0;
|
|
211
|
+
while (length <= byteLimit) {
|
|
212
|
+
const result = await handle.read(chunk, 0, Math.min(chunk.length, byteLimit + 1 - length), length);
|
|
213
|
+
if (result.bytesRead === 0) break;
|
|
214
|
+
const content = chunk.subarray(0, result.bytesRead);
|
|
215
|
+
digest.update(content);
|
|
216
|
+
if (length < prefixLimit)
|
|
217
|
+
bytes.set(content.subarray(0, Math.min(content.length, prefixLimit - length)), length);
|
|
218
|
+
length += result.bytesRead;
|
|
219
|
+
}
|
|
220
|
+
if (length > byteLimit) throw new Error("target_size_limit");
|
|
221
|
+
const content = bytes.subarray(0, Math.min(length, prefixLimit));
|
|
222
|
+
const after = await handle.stat({ bigint: true });
|
|
223
|
+
const lexical = await fs.lstat(resolved, { bigint: true });
|
|
224
|
+
const parentAfter = await fs.lstat(parentPath, { bigint: true });
|
|
225
|
+
if (
|
|
226
|
+
lexical.isSymbolicLink() ||
|
|
227
|
+
parentAfter.isSymbolicLink() ||
|
|
228
|
+
!parentAfter.isDirectory() ||
|
|
229
|
+
parentAfter.dev !== parentBefore.dev ||
|
|
230
|
+
parentAfter.ino !== parentBefore.ino ||
|
|
231
|
+
after.dev !== opened.dev ||
|
|
232
|
+
after.ino !== opened.ino ||
|
|
233
|
+
after.mtimeNs !== opened.mtimeNs ||
|
|
234
|
+
after.ctimeNs !== opened.ctimeNs ||
|
|
235
|
+
after.size !== opened.size ||
|
|
236
|
+
after.nlink !== opened.nlink ||
|
|
237
|
+
lexical.dev !== after.dev ||
|
|
238
|
+
lexical.ino !== after.ino ||
|
|
239
|
+
lexical.mtimeNs !== after.mtimeNs ||
|
|
240
|
+
lexical.ctimeNs !== after.ctimeNs
|
|
241
|
+
)
|
|
242
|
+
throw new Error("target_changed");
|
|
243
|
+
const identity: FileIdentity = {
|
|
244
|
+
dev: after.dev.toString(),
|
|
245
|
+
ino: after.ino.toString(),
|
|
246
|
+
nlink: after.nlink.toString(),
|
|
247
|
+
parentDev: parentAfter.dev.toString(),
|
|
248
|
+
parentIno: parentAfter.ino.toString(),
|
|
249
|
+
size: after.size.toString(),
|
|
250
|
+
mtimeNs: after.mtimeNs.toString(),
|
|
251
|
+
mode: Number(after.mode),
|
|
252
|
+
sha256: digest.digest("hex"),
|
|
253
|
+
};
|
|
254
|
+
return { bytes: content, identity };
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
257
|
+
throw error;
|
|
258
|
+
} finally {
|
|
259
|
+
await handle?.close();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Bounded, no-follow directory identity. Rejects a symlinked or non-directory path. */
|
|
264
|
+
async function readDirectoryIdentity(directoryPath: string): Promise<DirectoryIdentity | undefined> {
|
|
265
|
+
try {
|
|
266
|
+
const stat = await fs.lstat(path.resolve(directoryPath), { bigint: true });
|
|
267
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("parent_not_directory");
|
|
268
|
+
return { dev: stat.dev.toString(), ino: stat.ino.toString(), mode: Number(stat.mode) };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function validFileIdentity(value: unknown): value is FileIdentity {
|
|
276
|
+
if (!value || typeof value !== "object") return false;
|
|
277
|
+
const v = value as Partial<FileIdentity>;
|
|
278
|
+
return (
|
|
279
|
+
typeof v.dev === "string" &&
|
|
280
|
+
typeof v.ino === "string" &&
|
|
281
|
+
typeof v.nlink === "string" &&
|
|
282
|
+
typeof v.size === "string" &&
|
|
283
|
+
typeof v.mtimeNs === "string" &&
|
|
284
|
+
typeof v.mode === "number" &&
|
|
285
|
+
typeof v.sha256 === "string" &&
|
|
286
|
+
/^[a-f0-9]{64}$/.test(v.sha256) &&
|
|
287
|
+
typeof v.parentDev === "string" &&
|
|
288
|
+
typeof v.parentIno === "string" &&
|
|
289
|
+
[v.dev, v.ino, v.nlink, v.size, v.parentDev, v.parentIno].every(
|
|
290
|
+
value => /^(?:0|[1-9]\d{0,19})$/.test(value) && BigInt(value) <= 18446744073709551615n,
|
|
291
|
+
) &&
|
|
292
|
+
/^-?(?:0|[1-9]\d{0,18})$/.test(v.mtimeNs) &&
|
|
293
|
+
Number.isInteger(v.mode) &&
|
|
294
|
+
v.mode >= 0 &&
|
|
295
|
+
v.mode <= 0xffff &&
|
|
296
|
+
v.nlink === "1"
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function validDirectoryIdentity(value: unknown): value is DirectoryIdentity {
|
|
301
|
+
if (!value || typeof value !== "object") return false;
|
|
302
|
+
const v = value as Partial<DirectoryIdentity>;
|
|
303
|
+
return (
|
|
304
|
+
typeof v.dev === "string" &&
|
|
305
|
+
typeof v.ino === "string" &&
|
|
306
|
+
[v.dev, v.ino].every(value => /^(?:0|[1-9]\d{0,19})$/.test(value) && BigInt(value) <= 18446744073709551615n) &&
|
|
307
|
+
typeof v.mode === "number" &&
|
|
308
|
+
Number.isInteger(v.mode) &&
|
|
309
|
+
v.mode >= 0 &&
|
|
310
|
+
v.mode <= 0xffff
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const PHASES = new Set<ActivationPhase>([
|
|
315
|
+
"staged",
|
|
316
|
+
"published",
|
|
317
|
+
"verified",
|
|
318
|
+
"pending_activation",
|
|
319
|
+
"rolled_back",
|
|
320
|
+
"uncertain",
|
|
321
|
+
]);
|
|
322
|
+
const BACKUP_NAME = /^[^\\/]{1,255}$/;
|
|
323
|
+
|
|
324
|
+
function validRecord(value: unknown, targetPath: string): value is ActivationRecordV1 {
|
|
325
|
+
if (!value || typeof value !== "object") return false;
|
|
326
|
+
const r = value as Partial<ActivationRecordV1>;
|
|
327
|
+
if (
|
|
328
|
+
r.schemaVersion !== 1 ||
|
|
329
|
+
r.kind !== "gjc-install-activation" ||
|
|
330
|
+
typeof r.transactionId !== "string" ||
|
|
331
|
+
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(r.transactionId) ||
|
|
332
|
+
typeof r.targetPath !== "string" ||
|
|
333
|
+
typeof r.parentPath !== "string" ||
|
|
334
|
+
typeof r.stagingPath !== "string" ||
|
|
335
|
+
typeof r.backupName !== "string" ||
|
|
336
|
+
!BACKUP_NAME.test(r.backupName) ||
|
|
337
|
+
r.backupName.includes("\0") ||
|
|
338
|
+
r.backupName === "." ||
|
|
339
|
+
r.backupName === ".." ||
|
|
340
|
+
typeof r.phase !== "string" ||
|
|
341
|
+
!PHASES.has(r.phase as ActivationPhase)
|
|
342
|
+
)
|
|
343
|
+
return false;
|
|
344
|
+
if (!r.baseline || typeof r.baseline !== "object" || typeof r.baseline.exists !== "boolean") return false;
|
|
345
|
+
if (r.baseline.exists && !r.targetIdentity) return false;
|
|
346
|
+
if (
|
|
347
|
+
!r.candidate ||
|
|
348
|
+
typeof r.candidate !== "object" ||
|
|
349
|
+
typeof r.candidate.digest !== "string" ||
|
|
350
|
+
!/^[a-f0-9]{64}$/.test(r.candidate.digest) ||
|
|
351
|
+
typeof r.candidate.version !== "string" ||
|
|
352
|
+
!Array.isArray(r.candidate.sidecars)
|
|
353
|
+
)
|
|
354
|
+
return false;
|
|
355
|
+
if (r.targetIdentity !== undefined && !validFileIdentity(r.targetIdentity)) return false;
|
|
356
|
+
if (r.backupIdentity !== undefined && !validFileIdentity(r.backupIdentity)) return false;
|
|
357
|
+
if (r.stagingIdentity !== undefined && !validFileIdentity(r.stagingIdentity)) return false;
|
|
358
|
+
if (r.parentIdentity !== undefined && !validDirectoryIdentity(r.parentIdentity)) return false;
|
|
359
|
+
// Target-adjacent authority: the record must literally describe this exact
|
|
360
|
+
// target and its immediate parent, resolved lexically, never inferred from
|
|
361
|
+
// staging/backup basename prefixes.
|
|
362
|
+
const resolvedTarget = path.resolve(targetPath);
|
|
363
|
+
if (path.resolve(r.targetPath) !== resolvedTarget) return false;
|
|
364
|
+
if (path.resolve(r.parentPath) !== path.dirname(resolvedTarget)) return false;
|
|
365
|
+
if (path.dirname(path.resolve(r.stagingPath)) !== path.dirname(resolvedTarget)) return false;
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Read the activation record for `targetPath`, itself opened bounded/
|
|
371
|
+
* no-follow/identity-revalidated. A record that is present but fails schema
|
|
372
|
+
* or path validation is reported distinctly (`malformed`/`foreign`) and is
|
|
373
|
+
* never treated as absent, so a caller cannot silently proceed past a
|
|
374
|
+
* corrupted or substituted record.
|
|
375
|
+
*/
|
|
376
|
+
export async function readActivationRecord(targetPath: string): Promise<ActivationRead> {
|
|
377
|
+
const recordPath = activationRecordPath(targetPath);
|
|
378
|
+
const snapshot = await readExactRegularFile(recordPath);
|
|
379
|
+
if (!snapshot) return { status: "missing" };
|
|
380
|
+
let parsed: unknown;
|
|
381
|
+
try {
|
|
382
|
+
parsed = JSON.parse(Buffer.from(snapshot.bytes).toString("utf8"));
|
|
383
|
+
} catch {
|
|
384
|
+
return { status: "malformed", reason: "invalid_json" };
|
|
385
|
+
}
|
|
386
|
+
if (!validRecord(parsed, targetPath)) return { status: "foreign", reason: "schema_or_target_mismatch" };
|
|
387
|
+
return { status: "valid", record: parsed, identity: snapshot.identity };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Create the activation record with a no-replace write: an existing record
|
|
392
|
+
* (foreign, malformed, or a live transaction) is never overwritten blindly.
|
|
393
|
+
*/
|
|
394
|
+
export async function createActivationRecordFile(record: ActivationRecordV1): Promise<FileIdentity> {
|
|
395
|
+
if (!validRecord(record, record.targetPath) || !record.parentIdentity) throw new Error("invalid_activation_record");
|
|
396
|
+
const recordPath = activationRecordPath(record.targetPath);
|
|
397
|
+
const tempPath = `${recordPath}.${record.transactionId}.tmp`;
|
|
398
|
+
const handle = await fs.open(
|
|
399
|
+
tempPath,
|
|
400
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
401
|
+
0o600,
|
|
402
|
+
);
|
|
403
|
+
try {
|
|
404
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
405
|
+
await handle.sync();
|
|
406
|
+
} finally {
|
|
407
|
+
await handle.close();
|
|
408
|
+
}
|
|
409
|
+
const source = await readExactRegularFile(tempPath);
|
|
410
|
+
if (
|
|
411
|
+
!source ||
|
|
412
|
+
!record.parentIdentity ||
|
|
413
|
+
source.identity.parentDev !== record.parentIdentity.dev ||
|
|
414
|
+
source.identity.parentIno !== record.parentIdentity.ino
|
|
415
|
+
)
|
|
416
|
+
throw new Error("activation_record_parent_changed");
|
|
417
|
+
const published = exactRestore(tempPath, recordPath, toNativeIdentity(source.identity));
|
|
418
|
+
const retainedPlaceholder =
|
|
419
|
+
published.code === "cleanup_pending" &&
|
|
420
|
+
published.retainedPlaceholderPath &&
|
|
421
|
+
!published.retainedUnknownPath &&
|
|
422
|
+
!published.retainedSuccessorPath;
|
|
423
|
+
if (!published.ok && !retainedPlaceholder)
|
|
424
|
+
throw new Error(`activation_record_publication_unverified:${published.code ?? "unknown"}`);
|
|
425
|
+
const observed = await readExactRegularFile(recordPath);
|
|
426
|
+
if (!observed || !sameFileIdentity(source.identity, observed.identity))
|
|
427
|
+
throw new Error("activation_record_identity_changed");
|
|
428
|
+
await syncActivationParent(record.parentPath, record.parentIdentity);
|
|
429
|
+
return source.identity;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function syncActivationParent(parentPath: string, expected: DirectoryIdentity): Promise<void> {
|
|
433
|
+
if (process.platform === "win32") return;
|
|
434
|
+
const parent = await fs.open(
|
|
435
|
+
parentPath,
|
|
436
|
+
constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW | constants.O_NONBLOCK,
|
|
437
|
+
);
|
|
438
|
+
try {
|
|
439
|
+
const stat = await parent.stat({ bigint: true });
|
|
440
|
+
if (stat.dev.toString() !== expected.dev || stat.ino.toString() !== expected.ino)
|
|
441
|
+
throw new Error("activation_parent_changed");
|
|
442
|
+
await parent.sync();
|
|
443
|
+
} finally {
|
|
444
|
+
await parent.close();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Compare-and-swap the activation record: the caller must supply the exact
|
|
450
|
+
* identity of the record it last read. `exactReplacePath` performs the
|
|
451
|
+
* atomic namespace-exchange CAS; a stale or substituted record is refused
|
|
452
|
+
* rather than overwritten.
|
|
453
|
+
*/
|
|
454
|
+
export async function updateActivationRecordFile(
|
|
455
|
+
record: ActivationRecordV1,
|
|
456
|
+
expectedIdentity: FileIdentity,
|
|
457
|
+
): Promise<FileIdentity> {
|
|
458
|
+
if (!validRecord(record, record.targetPath) || !record.parentIdentity) throw new Error("invalid_activation_record");
|
|
459
|
+
const recordPath = activationRecordPath(record.targetPath);
|
|
460
|
+
const tempPath = `${recordPath}.${record.transactionId}.${randomUUID()}.tmp`;
|
|
461
|
+
const handle = await fs.open(
|
|
462
|
+
tempPath,
|
|
463
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
|
464
|
+
0o600,
|
|
465
|
+
);
|
|
466
|
+
try {
|
|
467
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, "utf8");
|
|
468
|
+
await handle.sync();
|
|
469
|
+
} finally {
|
|
470
|
+
await handle.close();
|
|
471
|
+
}
|
|
472
|
+
const source = await readExactRegularFile(tempPath);
|
|
473
|
+
if (
|
|
474
|
+
!source ||
|
|
475
|
+
source.identity.parentDev !== expectedIdentity.parentDev ||
|
|
476
|
+
source.identity.parentIno !== expectedIdentity.parentIno ||
|
|
477
|
+
source.identity.parentDev !== record.parentIdentity.dev ||
|
|
478
|
+
source.identity.parentIno !== record.parentIdentity.ino
|
|
479
|
+
)
|
|
480
|
+
throw new Error("activation_record_parent_changed");
|
|
481
|
+
const replaced = exactReplacePath(
|
|
482
|
+
tempPath,
|
|
483
|
+
recordPath,
|
|
484
|
+
toNativeIdentity(source.identity),
|
|
485
|
+
toNativeIdentity(expectedIdentity),
|
|
486
|
+
);
|
|
487
|
+
if (!replaced.ok) throw new Error("activation_record_compare_and_swap_failed");
|
|
488
|
+
return source.identity;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export type ReconcileStatus =
|
|
492
|
+
| "missing"
|
|
493
|
+
| "verified"
|
|
494
|
+
| "reconciled"
|
|
495
|
+
| "applied_unverified"
|
|
496
|
+
| "rolled_back"
|
|
497
|
+
| "pending_activation"
|
|
498
|
+
| "conflict"
|
|
499
|
+
| "malformed"
|
|
500
|
+
| "foreign";
|
|
501
|
+
|
|
502
|
+
export interface ReconcileResult {
|
|
503
|
+
readonly status: ReconcileStatus;
|
|
504
|
+
readonly record?: ActivationRecordV1;
|
|
505
|
+
readonly recordIdentity?: FileIdentity;
|
|
506
|
+
readonly reason?: string;
|
|
507
|
+
/** Present only for a pre-mutation Windows sharing violation on the destination open. */
|
|
508
|
+
readonly windowsErrorCode?: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Reconcile the activation record for `targetPath` against durable state.
|
|
513
|
+
*
|
|
514
|
+
* `staged`/`pending_activation` phases are promoted onto the live target via
|
|
515
|
+
* `exactReplaceRetained` only when `allowPromotion` is set and every
|
|
516
|
+
* identity (staging candidate, current target, parent) still matches what
|
|
517
|
+
* the record captured; the previous target is retired intact to the
|
|
518
|
+
* record's `backupName`, never unlinked or blind-renamed. `published`
|
|
519
|
+
* records are re-verified and advanced to `verified` once the target
|
|
520
|
+
* digest/identity matches the candidate. `verified` records are read-only
|
|
521
|
+
* re-confirmations. Anything else (target/parent drift, missing staged
|
|
522
|
+
* candidate, an already-occupied backup name, a pre-mutation Windows
|
|
523
|
+
* sharing violation) is reported as `conflict` with the exact reason and
|
|
524
|
+
* never silently promoted.
|
|
525
|
+
*/
|
|
526
|
+
export async function reconcileActivationRecord(
|
|
527
|
+
targetPath: string,
|
|
528
|
+
options: { readonly allowPromotion?: boolean; readonly expectedRecordIdentity?: FileIdentity } = {},
|
|
529
|
+
): Promise<ReconcileResult> {
|
|
530
|
+
const read = await readActivationRecord(targetPath);
|
|
531
|
+
if (read.status === "missing") return { status: "missing" };
|
|
532
|
+
if (read.status === "malformed" || read.status === "foreign") return { status: read.status, reason: read.reason };
|
|
533
|
+
const { record, identity: recordIdentity } = read;
|
|
534
|
+
if (options.expectedRecordIdentity && !sameFileIdentity(options.expectedRecordIdentity, recordIdentity))
|
|
535
|
+
return { status: "conflict", reason: "activation_record_changed" };
|
|
536
|
+
|
|
537
|
+
const parentIdentity = await readDirectoryIdentity(record.parentPath);
|
|
538
|
+
if (record.parentIdentity && !sameDirectoryIdentity(record.parentIdentity, parentIdentity))
|
|
539
|
+
return { status: "conflict", record, reason: "parent_changed" };
|
|
540
|
+
|
|
541
|
+
if (record.phase === "rolled_back") {
|
|
542
|
+
const current = await snapshotRegularFile(targetPath);
|
|
543
|
+
if (!record.baseline.exists)
|
|
544
|
+
return current
|
|
545
|
+
? { status: "conflict", record, reason: "rollback_identity_changed" }
|
|
546
|
+
: { status: "rolled_back", record, recordIdentity };
|
|
547
|
+
return current &&
|
|
548
|
+
record.targetIdentity &&
|
|
549
|
+
record.backupIdentity &&
|
|
550
|
+
sameFileIdentity(current.identity, record.targetIdentity) &&
|
|
551
|
+
current.identity.sha256 === record.baseline.digest
|
|
552
|
+
? { status: "rolled_back", record, recordIdentity }
|
|
553
|
+
: { status: "conflict", record, reason: "rollback_identity_changed" };
|
|
554
|
+
}
|
|
555
|
+
if (record.phase === "verified") {
|
|
556
|
+
const current = await snapshotRegularFile(targetPath);
|
|
557
|
+
if (!current) return { status: "conflict", record, reason: "target_missing" };
|
|
558
|
+
if (
|
|
559
|
+
!record.targetIdentity ||
|
|
560
|
+
!sameFileIdentity(record.targetIdentity, current.identity) ||
|
|
561
|
+
current.identity.sha256 !== record.candidate.digest
|
|
562
|
+
)
|
|
563
|
+
return { status: "conflict", record, reason: "target_digest_mismatch" };
|
|
564
|
+
return { status: "verified", record, recordIdentity };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (record.phase === "published") {
|
|
568
|
+
const current = await snapshotRegularFile(targetPath);
|
|
569
|
+
if (!current) return { status: "conflict", record, reason: "target_missing" };
|
|
570
|
+
if (current.identity.sha256 !== record.candidate.digest)
|
|
571
|
+
return { status: "conflict", record, reason: "target_digest_mismatch" };
|
|
572
|
+
if (!record.targetIdentity || !sameFileIdentity(record.targetIdentity, current.identity))
|
|
573
|
+
return { status: "conflict", record, reason: "target_changed" };
|
|
574
|
+
return { status: "applied_unverified", record, recordIdentity, reason: "execution_postcheck_required" };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (record.phase === "staged" || record.phase === "pending_activation") {
|
|
578
|
+
if (!options.allowPromotion) return { status: "pending_activation", record, reason: "activation_pending" };
|
|
579
|
+
if ((record.baseline.exists && !record.targetIdentity) || !record.stagingIdentity || !record.parentIdentity)
|
|
580
|
+
return { status: "conflict", record, reason: "original_snapshot_incomplete" };
|
|
581
|
+
const stagingCurrent = await snapshotRegularFile(record.stagingPath);
|
|
582
|
+
if (!stagingCurrent) return { status: "pending_activation", record, reason: "candidate_missing" };
|
|
583
|
+
if (!sameFileIdentity(record.stagingIdentity, stagingCurrent.identity))
|
|
584
|
+
return { status: "conflict", record, reason: "candidate_changed" };
|
|
585
|
+
if (stagingCurrent.identity.sha256 !== record.candidate.digest)
|
|
586
|
+
return { status: "conflict", record, reason: "candidate_digest_mismatch" };
|
|
587
|
+
const targetCurrent = await snapshotRegularFile(targetPath);
|
|
588
|
+
if (
|
|
589
|
+
record.baseline.exists &&
|
|
590
|
+
(!targetCurrent || !record.targetIdentity || !sameFileIdentity(record.targetIdentity, targetCurrent.identity))
|
|
591
|
+
)
|
|
592
|
+
return { status: "conflict", record, reason: "target_changed" };
|
|
593
|
+
if (!record.baseline.exists && targetCurrent) return { status: "conflict", record, reason: "target_appeared" };
|
|
594
|
+
const result =
|
|
595
|
+
record.baseline.exists && record.targetIdentity
|
|
596
|
+
? exactReplaceRetained(
|
|
597
|
+
record.stagingPath,
|
|
598
|
+
targetPath,
|
|
599
|
+
record.backupName,
|
|
600
|
+
toNativeIdentity(record.stagingIdentity),
|
|
601
|
+
toNativeIdentity(record.targetIdentity),
|
|
602
|
+
)
|
|
603
|
+
: exactRestore(record.stagingPath, targetPath, toNativeIdentity(record.stagingIdentity));
|
|
604
|
+
const retainedCreationPlaceholder =
|
|
605
|
+
!record.baseline.exists &&
|
|
606
|
+
result.code === "cleanup_pending" &&
|
|
607
|
+
result.retainedPlaceholderPath &&
|
|
608
|
+
!result.retainedUnknownPath &&
|
|
609
|
+
!result.retainedSuccessorPath;
|
|
610
|
+
if (!result.ok && !retainedCreationPlaceholder) {
|
|
611
|
+
if (
|
|
612
|
+
process.platform === "win32" &&
|
|
613
|
+
record.targetIdentity &&
|
|
614
|
+
result.code === "sharing_violation" &&
|
|
615
|
+
result.windowsErrorCode &&
|
|
616
|
+
!result.detachedPath &&
|
|
617
|
+
!result.retainedSuccessorPath &&
|
|
618
|
+
!result.retainedUnknownPath &&
|
|
619
|
+
!result.retainedPlaceholderPath
|
|
620
|
+
) {
|
|
621
|
+
const unchangedTarget = await snapshotRegularFile(targetPath);
|
|
622
|
+
const unchangedStaging = await snapshotRegularFile(record.stagingPath);
|
|
623
|
+
if (
|
|
624
|
+
unchangedTarget &&
|
|
625
|
+
unchangedStaging &&
|
|
626
|
+
sameFileIdentity(record.targetIdentity, unchangedTarget.identity) &&
|
|
627
|
+
sameFileIdentity(record.stagingIdentity, unchangedStaging.identity)
|
|
628
|
+
) {
|
|
629
|
+
const pending: ActivationRecordV1 = { ...record, phase: "pending_activation" };
|
|
630
|
+
const pendingIdentity = await updateActivationRecordFile(pending, recordIdentity);
|
|
631
|
+
return {
|
|
632
|
+
status: "pending_activation",
|
|
633
|
+
record: pending,
|
|
634
|
+
recordIdentity: pendingIdentity,
|
|
635
|
+
reason: "sharing_violation",
|
|
636
|
+
windowsErrorCode: result.windowsErrorCode,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
status: "conflict",
|
|
642
|
+
record,
|
|
643
|
+
reason: result.code ?? "activation_promotion_failed",
|
|
644
|
+
...(result.windowsErrorCode ? { windowsErrorCode: result.windowsErrorCode } : {}),
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const promoted = await snapshotRegularFile(targetPath);
|
|
648
|
+
if (
|
|
649
|
+
!promoted ||
|
|
650
|
+
promoted.identity.sha256 !== record.candidate.digest ||
|
|
651
|
+
!sameFileIdentity(record.stagingIdentity, promoted.identity)
|
|
652
|
+
)
|
|
653
|
+
return { status: "conflict", record, reason: "post_promotion_digest_mismatch" };
|
|
654
|
+
await syncActivationParent(record.parentPath, record.parentIdentity);
|
|
655
|
+
const publishedRecord: ActivationRecordV1 = {
|
|
656
|
+
...record,
|
|
657
|
+
backupIdentity: record.targetIdentity,
|
|
658
|
+
targetIdentity: promoted.identity,
|
|
659
|
+
phase: "published",
|
|
660
|
+
};
|
|
661
|
+
const publishedIdentity = await updateActivationRecordFile(publishedRecord, recordIdentity);
|
|
662
|
+
return { status: "reconciled", record: publishedRecord, recordIdentity: publishedIdentity };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return { status: "conflict", record, reason: "uncertain_phase_requires_manual_review" };
|
|
666
|
+
}
|