@huynle/hub 0.6.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +870 -0
- package/Dockerfile +33 -0
- package/README.md +1007 -66
- package/package.json +47 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +156 -0
- package/packages/cli/dist/hub-cli.js +21647 -0
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +123 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +252 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +104 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +95 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +553 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +107 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +70 -0
- package/packages/sdk/src/types.ts +517 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +101 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +152 -0
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +328 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +74 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +385 -0
- package/src/central/agent/routes.ts +281 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +112 -0
- package/src/central/agent/webhook-routes.ts +86 -0
- package/src/central/agent/webhooks.ts +493 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +350 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +116 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +246 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1149 -170
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/mcp-http.ts +116 -0
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +625 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +118 -7
- package/src/index.ts +195 -43
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +174 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-DWnMhXk9.js +20 -0
- package/web/dist/assets/index-DmUXEnw9.css +1 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +17 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
// The transactional apply engine. It applies a contract SetupPlan on this host, one typed action at a time,
|
|
2
|
+
// through per-action executors (executor.ts), and guarantees:
|
|
3
|
+
//
|
|
4
|
+
// - one apply at a time: the O_EXCL apply lock (lock.ts); a live holder is refused (locked), another host's lock
|
|
5
|
+
// is never broken, a stale one is only broken once no interrupted transaction is waiting for recovery;
|
|
6
|
+
// - nothing runs on a stale preview: apply re-plans through the caller's hook and revalidates the token
|
|
7
|
+
// (revalidatePreview: host, expiry, clock skew, single-use nonce, snapshot and plan digests), then refuses
|
|
8
|
+
// blockers and failed evidence (previewApplicable);
|
|
9
|
+
// - explicit consent: an elevated action needs `escalate`, a service activation `confirmActivation`;
|
|
10
|
+
// - preflight before any change: a valid plan for exactly this host's target, an executor for every action,
|
|
11
|
+
// every target path resolved without symlinks, and every executor's read-only check (preconditions, pinned
|
|
12
|
+
// artifacts, versions);
|
|
13
|
+
// - write-ahead ownership journal: each change is journaled ("pending", with the state before, the state it will
|
|
14
|
+
// leave and the backup) before it happens, and "done" after; contents and credentials never enter it;
|
|
15
|
+
// - atomic replacement: new content is staged beside the target and renamed over it;
|
|
16
|
+
// - bounded memory: large content (a harness, hubctl) reaches the engine as files of the step's staging area
|
|
17
|
+
// (stream.ts), never as bytes; it is moved or copied in chunks, digested in chunks, and backed up in chunks;
|
|
18
|
+
// - rollback on any failure and recovery after a crash (recover): newest entry first, undo only what is still
|
|
19
|
+
// exactly as we left it, keep anything changed since (reported as a conflict), clear (never restore) a
|
|
20
|
+
// credential, refuse a backup that does not match what it backed up;
|
|
21
|
+
// - resume after a crash: continue the stored plan from the interrupted step, when the host still agrees.
|
|
22
|
+
//
|
|
23
|
+
// The engine performs only the TxFs operations; it never spawns processes or runs commands.
|
|
24
|
+
import { randomBytes } from "node:crypto"
|
|
25
|
+
import {
|
|
26
|
+
isSecretValue,
|
|
27
|
+
JOURNAL_SCHEMA,
|
|
28
|
+
LOCK_MAX_MS,
|
|
29
|
+
planDigest,
|
|
30
|
+
previewApplicable,
|
|
31
|
+
recoveryDecision,
|
|
32
|
+
revalidatePreview,
|
|
33
|
+
SETUP_PREVIEW_SCHEMA,
|
|
34
|
+
snapshotDigest,
|
|
35
|
+
validateSetupPlan,
|
|
36
|
+
type Action,
|
|
37
|
+
type ApplyLock,
|
|
38
|
+
type DiscoverySnapshot,
|
|
39
|
+
type ExecutionContext,
|
|
40
|
+
type ErrorCode,
|
|
41
|
+
type EvidenceResult,
|
|
42
|
+
type FileState,
|
|
43
|
+
type JournalEntry,
|
|
44
|
+
type JournalHooks,
|
|
45
|
+
type JournalState,
|
|
46
|
+
type PathRef,
|
|
47
|
+
type PlanBlocker,
|
|
48
|
+
type PreviewEvidence,
|
|
49
|
+
type RecoveryStep,
|
|
50
|
+
type RootMapping,
|
|
51
|
+
type SetupPlan,
|
|
52
|
+
type TransactionJournal,
|
|
53
|
+
} from "../contracts/index.ts"
|
|
54
|
+
import type { ActionExecutor, Change, ExecutorMap, HostInfo, StepContext, StepPlan, TreeFile } from "./executor.ts"
|
|
55
|
+
import { nodeFs, type TxFs } from "./fs.ts"
|
|
56
|
+
import { breakStaleLock, releaseLock, renewLock, tryAcquireLock, type LockIO } from "./lock.ts"
|
|
57
|
+
import { resolveTarget, rootDir, type ResolveOptions } from "./resolve.ts"
|
|
58
|
+
import { errnoOf, fromResult, TxError, txFail, txOk, type TxFailure, type TxResult } from "./result.ts"
|
|
59
|
+
import { fileState, sameState, sha256Of, treeDigestOfFiles } from "./state.ts"
|
|
60
|
+
import { TxStore } from "./store.ts"
|
|
61
|
+
import { copyFile, fsStaging, type StagedFile, type StagingArea } from "./stream.ts"
|
|
62
|
+
|
|
63
|
+
export interface ApplierConfig {
|
|
64
|
+
/** The roots discovery mapped on this host. */
|
|
65
|
+
readonly roots: readonly RootMapping[]
|
|
66
|
+
/**
|
|
67
|
+
* v1.1: where this apply runs. "container": inside the container discovery found itself in, so every root
|
|
68
|
+
* resolves to containerPath ?? path (contract rootPathIn); "host" (default): to path.
|
|
69
|
+
*/
|
|
70
|
+
readonly context?: ExecutionContext
|
|
71
|
+
/** This host, computed fresh (hostFingerprint, targetOf). */
|
|
72
|
+
readonly host: HostInfo
|
|
73
|
+
readonly executors: ExecutorMap
|
|
74
|
+
readonly fs?: TxFs
|
|
75
|
+
readonly now?: () => number
|
|
76
|
+
readonly pid?: number
|
|
77
|
+
/** Whether a process on this host is alive (default: signal 0). */
|
|
78
|
+
readonly isAlive?: (pid: number) => boolean
|
|
79
|
+
/** Transaction IDs (default: random). */
|
|
80
|
+
readonly newID?: () => string
|
|
81
|
+
/** Observers (logging, progress); they cannot change actions. */
|
|
82
|
+
readonly hooks?: JournalHooks
|
|
83
|
+
/**
|
|
84
|
+
* Keep a committed transaction's backups (`hubctl setup rollback` restores from them later). Default false: a
|
|
85
|
+
* commit drops them, so only an interrupted or failed transaction can be undone exactly.
|
|
86
|
+
*/
|
|
87
|
+
readonly keepBackups?: boolean
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Explicit consent flags (--escalate, --confirm-activation). Never defaults. */
|
|
91
|
+
export interface Consent {
|
|
92
|
+
readonly escalate?: boolean
|
|
93
|
+
readonly confirmActivation?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What a fresh discovery + plan produced: apply compares it against the token and refuses blockers. */
|
|
97
|
+
export interface FreshPlan {
|
|
98
|
+
readonly plan: SetupPlan
|
|
99
|
+
/** v1.1: the fresh snapshot the plan was made from; when given, its canonical digest must be the plan's. */
|
|
100
|
+
readonly snapshot?: DiscoverySnapshot
|
|
101
|
+
readonly blockers?: readonly PlanBlocker[]
|
|
102
|
+
readonly evidence?: readonly PreviewEvidence[]
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ApplyRequest {
|
|
106
|
+
/** The preview token (unknown: it is validated here). */
|
|
107
|
+
readonly token: unknown
|
|
108
|
+
/**
|
|
109
|
+
* The revalidation hook: rediscover and re-plan now. Must only read: apply calls it once before taking the lock
|
|
110
|
+
* (so a refused apply writes nothing) and again under the lock.
|
|
111
|
+
*/
|
|
112
|
+
readonly replan: () => Promise<FreshPlan>
|
|
113
|
+
readonly consent?: Consent
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface RecoverRequest {
|
|
117
|
+
/** rollback: undo the interrupted transaction. resume: finish its stored plan (needs the same consent again). */
|
|
118
|
+
readonly mode: "rollback" | "resume"
|
|
119
|
+
readonly consent?: Consent
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** `hubctl setup rollback`: undo committed transactions, newest first, back to just after `to` (or all of them). */
|
|
123
|
+
export interface RollbackRequest {
|
|
124
|
+
readonly to?: string
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface Conflict {
|
|
128
|
+
readonly seq: number
|
|
129
|
+
readonly actionID: string
|
|
130
|
+
readonly path?: string
|
|
131
|
+
readonly reason: string
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface StepOutcome {
|
|
135
|
+
readonly actionID: string
|
|
136
|
+
readonly kind: Action["kind"]
|
|
137
|
+
readonly outcome: "applied" | "skipped" | "failed" | "resumed"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface TxReport {
|
|
141
|
+
readonly txID: string
|
|
142
|
+
readonly state: JournalState
|
|
143
|
+
readonly steps: readonly StepOutcome[]
|
|
144
|
+
/** Files recovery left alone because they changed since hub wrote them (or could not be restored safely). */
|
|
145
|
+
readonly conflicts: readonly Conflict[]
|
|
146
|
+
/**
|
|
147
|
+
* Informational, not a conflict: directories hub created that a rollback left in place because they now also hold
|
|
148
|
+
* files hub did not write (a harness probe's cache, say). Nothing of the user's was touched.
|
|
149
|
+
*/
|
|
150
|
+
readonly kept: readonly Conflict[]
|
|
151
|
+
/** Upstream evidence recorded for harness installs (never part of the journal). */
|
|
152
|
+
readonly evidence: readonly { readonly actionID: string; readonly results: readonly EvidenceResult[] }[]
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type TxOutcome = { readonly ok: true; readonly report: TxReport } | (TxFailure & { readonly report?: TxReport })
|
|
156
|
+
|
|
157
|
+
export interface Applier {
|
|
158
|
+
apply(request: ApplyRequest): Promise<TxOutcome>
|
|
159
|
+
/** Recovers an interrupted transaction; ok without a report when there was nothing to recover. */
|
|
160
|
+
recover(request: RecoverRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }>
|
|
161
|
+
/**
|
|
162
|
+
* Undoes committed transactions of this host, newest first, with the recovery rules (restore backups, remove what
|
|
163
|
+
* hub created, clear a credential, keep anything changed since as a conflict). Ok without a report when there is
|
|
164
|
+
* nothing to undo. The report is the oldest transaction undone, with every transaction's steps and conflicts.
|
|
165
|
+
*/
|
|
166
|
+
rollback(request: RollbackRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }>
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const FINISHED: readonly JournalState[] = ["committed", "rolled-back"]
|
|
170
|
+
const PARENT_MODE = 0o700
|
|
171
|
+
|
|
172
|
+
function defaultIsAlive(pid: number): boolean {
|
|
173
|
+
try {
|
|
174
|
+
process.kill(pid, 0)
|
|
175
|
+
return true
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return errnoOf(error) === "EPERM"
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Hooks observe: they get copies, so nothing they do reaches the journal or the plan. */
|
|
182
|
+
const copy = <T>(value: T): T => structuredClone(value)
|
|
183
|
+
|
|
184
|
+
/** Strings in anything the engine returns that look like a credential are withheld (defense in depth). */
|
|
185
|
+
function redact<T>(value: T): T {
|
|
186
|
+
const scrub = (v: unknown): unknown => {
|
|
187
|
+
if (typeof v === "string") {
|
|
188
|
+
if (!isSecretValue(v)) return v
|
|
189
|
+
const words = v.split(/(\s+)/).map((w) => (isSecretValue(w) ? "[redacted]" : w)).join("")
|
|
190
|
+
return isSecretValue(words) ? "(details withheld: the message looked like it carried a credential)" : words
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(v)) return v.map(scrub)
|
|
193
|
+
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, item]) => [k, scrub(item)]))
|
|
194
|
+
return v
|
|
195
|
+
}
|
|
196
|
+
return scrub(value) as T
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const randomTxID = () => `tx_${randomBytes(12).toString("hex")}`
|
|
200
|
+
const samePath = (a?: PathRef, b?: PathRef) => (a === undefined && b === undefined) || (a !== undefined && b !== undefined && a.root === b.root && a.rel === b.rel)
|
|
201
|
+
const dirname = (p: string) => p.slice(0, p.lastIndexOf("/")) || "/"
|
|
202
|
+
const basename = (p: string) => p.slice(p.lastIndexOf("/") + 1)
|
|
203
|
+
|
|
204
|
+
/** Converts anything thrown inside a step into a failure; filesystem errors name their errno, never contents. */
|
|
205
|
+
function failureOf(error: unknown): TxFailure {
|
|
206
|
+
if (error instanceof TxError) return error.failure
|
|
207
|
+
const code = errnoOf(error)
|
|
208
|
+
if (code === "EACCES" || code === "EPERM") return txFail("forbidden", `permission denied: ${(error as Error).message}`)
|
|
209
|
+
return txFail("internal", code ? `filesystem error ${code}: ${(error as Error).message}` : `unexpected error: ${error instanceof Error ? error.message : String(error)}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function createApplier(config: ApplierConfig): Applier {
|
|
213
|
+
const fs = config.fs ?? nodeFs
|
|
214
|
+
const now = config.now ?? Date.now
|
|
215
|
+
const pid = config.pid ?? process.pid
|
|
216
|
+
const isAlive = config.isAlive ?? defaultIsAlive
|
|
217
|
+
const newID = config.newID ?? randomTxID
|
|
218
|
+
const hooks = config.hooks ?? {}
|
|
219
|
+
const context: ExecutionContext = config.context ?? "host"
|
|
220
|
+
|
|
221
|
+
const lockIO = (store: TxStore): LockIO => ({ fs, path: store.lockPath, hostFingerprint: config.host.fingerprint, pid, now, isAlive })
|
|
222
|
+
|
|
223
|
+
/** Interrupted transactions of this host. Another host's (a shared home) are reported, never touched here. */
|
|
224
|
+
const unfinished = (store: TxStore) => {
|
|
225
|
+
const all = store.journals().filter((j) => !FINISHED.includes(j.state))
|
|
226
|
+
const foreign = all.find((j) => j.hostFingerprint !== config.host.fingerprint)
|
|
227
|
+
if (foreign) throw new TxError("recovery_required", `transaction ${foreign.txID} of another host was interrupted on this shared home; recover it on that host`)
|
|
228
|
+
return all
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Runs `body` holding the apply lock. */
|
|
232
|
+
async function locked<T extends { ok: boolean }>(txID: string, forRecovery: boolean, body: (store: TxStore, lock: { current: ApplyLock }) => Promise<T | TxFailure>): Promise<T | TxFailure> {
|
|
233
|
+
let store: TxStore
|
|
234
|
+
try {
|
|
235
|
+
store = new TxStore(fs, config.roots, context)
|
|
236
|
+
store.init()
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return failureOf(error)
|
|
239
|
+
}
|
|
240
|
+
const io = lockIO(store)
|
|
241
|
+
let attempt
|
|
242
|
+
try {
|
|
243
|
+
attempt = tryAcquireLock(io, txID)
|
|
244
|
+
if (attempt.state === "stale") {
|
|
245
|
+
if (!forRecovery && unfinished(store).length > 0)
|
|
246
|
+
return txFail("recovery_required", `apply ${attempt.lock.txID} was interrupted (its process is gone); run \`hubctl setup recover\` first`)
|
|
247
|
+
if (!breakStaleLock(io, attempt.lock)) return txFail("locked", "another apply took the lock")
|
|
248
|
+
attempt = tryAcquireLock(io, txID)
|
|
249
|
+
}
|
|
250
|
+
} catch (error) {
|
|
251
|
+
return failureOf(error)
|
|
252
|
+
}
|
|
253
|
+
if (attempt.state !== "acquired") {
|
|
254
|
+
const why =
|
|
255
|
+
attempt.state === "foreign"
|
|
256
|
+
? "another host holds the apply lock on this shared home; it is never broken automatically"
|
|
257
|
+
: attempt.state === "corrupt"
|
|
258
|
+
? `the apply lock ${io.path} is unreadable; remove it once no apply is running`
|
|
259
|
+
: `another apply (pid ${attempt.state === "held" || attempt.state === "stale" ? attempt.lock.pid : "?"}) is running`
|
|
260
|
+
return txFail("locked", why)
|
|
261
|
+
}
|
|
262
|
+
const lock = { current: attempt.lock }
|
|
263
|
+
try {
|
|
264
|
+
store.sweep()
|
|
265
|
+
return await body(store, lock)
|
|
266
|
+
} catch (error) {
|
|
267
|
+
return failureOf(error)
|
|
268
|
+
} finally {
|
|
269
|
+
try {
|
|
270
|
+
releaseLock(io, txID)
|
|
271
|
+
} catch {}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function consentProblem(plan: SetupPlan, consent: Consent = {}): TxFailure | undefined {
|
|
276
|
+
if (plan.requiresEscalation && !consent.escalate) return txFail("escalation_required", "this plan writes system locations; pass --escalate to allow it")
|
|
277
|
+
if (plan.activatesServices && !consent.confirmActivation) return txFail("needs_user", "this plan activates a background service; pass --confirm-activation to allow it")
|
|
278
|
+
if (plan.target !== config.host.target) return txFail("wrong_platform", `the plan is for ${plan.target}, but this host is ${config.host.target}`)
|
|
279
|
+
return undefined
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Re-plans and revalidates the preview, with `store`'s journals as the used nonces: the plan to apply and the
|
|
284
|
+
* token's nonce, or why it is refused. Reads only; nothing is written.
|
|
285
|
+
*/
|
|
286
|
+
async function admit(store: TxStore, request: ApplyRequest): Promise<{ ok: true; plan: SetupPlan; nonce: string } | TxFailure> {
|
|
287
|
+
const fresh = await request.replan()
|
|
288
|
+
const checked = fromResult(validateSetupPlan(fresh.plan), "the fresh plan is invalid")
|
|
289
|
+
if (!checked.ok) return checked
|
|
290
|
+
const plan = checked.value
|
|
291
|
+
if (fresh.snapshot && snapshotDigest(fresh.snapshot) !== plan.snapshotDigest) return txFail("preview_stale", "the fresh plan was not made from the fresh snapshot; run the preview again")
|
|
292
|
+
const usedNonces = new Set(store.journals().map((j) => j.nonce))
|
|
293
|
+
const token = revalidatePreview(request.token, { now: now(), hostFingerprint: config.host.fingerprint, plan, usedNonces })
|
|
294
|
+
if (!token.ok) {
|
|
295
|
+
const issue = token.issues[0]!
|
|
296
|
+
return txFail(issue.code.startsWith("preview_") ? (issue.code as ErrorCode) : "preview_invalid", issue.message)
|
|
297
|
+
}
|
|
298
|
+
const applicable = fromResult(
|
|
299
|
+
previewApplicable({ schema: SETUP_PREVIEW_SCHEMA, version: 1, plan, planDigest: planDigest(plan), token: token.value, blockers: fresh.blockers ?? [], evidence: fresh.evidence ?? [] }),
|
|
300
|
+
)
|
|
301
|
+
if (!applicable.ok) return applicable
|
|
302
|
+
const problem = consentProblem(plan, request.consent)
|
|
303
|
+
if (problem) return problem
|
|
304
|
+
return { ok: true, plan, nonce: token.value.nonce }
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The admission check before the lock is taken, so a refused apply (expired, stale, replayed or invalid preview,
|
|
309
|
+
* missing consent) writes nothing: not even the state directories the lock lives in. Undefined when it passes
|
|
310
|
+
* or cannot decide (an interrupted transaction, an unusable state directory): the locked path decides then, and
|
|
311
|
+
* re-runs the whole check under the lock either way.
|
|
312
|
+
*/
|
|
313
|
+
async function refusedBeforeLock(request: ApplyRequest): Promise<TxFailure | undefined> {
|
|
314
|
+
let store: TxStore
|
|
315
|
+
try {
|
|
316
|
+
store = new TxStore(fs, config.roots, context)
|
|
317
|
+
if (unfinished(store).length > 0) return undefined
|
|
318
|
+
} catch {
|
|
319
|
+
return undefined
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const admitted = await admit(store, request)
|
|
323
|
+
return admitted.ok ? undefined : admitted
|
|
324
|
+
} catch (error) {
|
|
325
|
+
return failureOf(error)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function apply(request: ApplyRequest): Promise<TxOutcome> {
|
|
330
|
+
const refused = await refusedBeforeLock(request)
|
|
331
|
+
if (refused) return refused
|
|
332
|
+
const txID = newID()
|
|
333
|
+
return locked<TxOutcome>(txID, false, async (store, lock) => {
|
|
334
|
+
if (unfinished(store).length > 0) return txFail("recovery_required", "an interrupted apply must be recovered first (`hubctl setup recover`)")
|
|
335
|
+
const admitted = await admit(store, request)
|
|
336
|
+
if (!admitted.ok) return admitted
|
|
337
|
+
const { plan } = admitted
|
|
338
|
+
const run = new Run(store, lock, plan, newJournal(txID, plan, admitted.nonce))
|
|
339
|
+
const pre = await run.preflight()
|
|
340
|
+
if (!pre.ok) return pre
|
|
341
|
+
store.writePlan(txID, plan)
|
|
342
|
+
run.persist()
|
|
343
|
+
return run.forward()
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function newJournal(txID: string, plan: SetupPlan, nonce: string): TransactionJournal {
|
|
348
|
+
const t = now()
|
|
349
|
+
return { schema: JOURNAL_SCHEMA, version: 1, txID, planDigest: planDigest(plan), hostFingerprint: config.host.fingerprint, nonce, state: "applying", startedAt: t, updatedAt: t, entries: [] }
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async function recover(request: RecoverRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }> {
|
|
353
|
+
return locked<TxOutcome | { ok: true; report?: undefined }>(newID(), true, async (store, lock) => {
|
|
354
|
+
const pending = unfinished(store)
|
|
355
|
+
if (pending.length === 0) return { ok: true }
|
|
356
|
+
let last: TxOutcome | undefined
|
|
357
|
+
for (const journal of pending) {
|
|
358
|
+
const plan = store.readPlan(journal)
|
|
359
|
+
const run = new Run(store, lock, plan, journal)
|
|
360
|
+
if (request.mode === "resume") {
|
|
361
|
+
if (!plan) return txFail("recovery_required", `the plan of ${journal.txID} is missing or does not match its journal; roll it back instead`)
|
|
362
|
+
if (journal.state !== "applying" && journal.state !== "prepared") return txFail("recovery_required", `${journal.txID} was already rolling back; it can only be rolled back`)
|
|
363
|
+
const problem = consentProblem(plan, request.consent)
|
|
364
|
+
if (problem) return problem
|
|
365
|
+
last = await run.resume()
|
|
366
|
+
} else last = await run.rollbackAll()
|
|
367
|
+
if (!last.ok) return last
|
|
368
|
+
}
|
|
369
|
+
return last!
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function rollback(request: RollbackRequest): Promise<TxOutcome | { readonly ok: true; readonly report?: undefined }> {
|
|
374
|
+
return locked<TxOutcome | { ok: true; report?: undefined }>(newID(), true, async (store, lock) => {
|
|
375
|
+
if (unfinished(store).length > 0) return txFail("recovery_required", "an interrupted apply must be recovered first (`hubctl setup recover`)")
|
|
376
|
+
const mine = store
|
|
377
|
+
.journals()
|
|
378
|
+
.filter((j) => j.hostFingerprint === config.host.fingerprint)
|
|
379
|
+
.sort((a, b) => a.startedAt - b.startedAt || (a.txID < b.txID ? -1 : a.txID > b.txID ? 1 : 0))
|
|
380
|
+
let start = 0
|
|
381
|
+
if (request.to !== undefined) {
|
|
382
|
+
const at = mine.findIndex((j) => j.txID === request.to)
|
|
383
|
+
if (at < 0) return txFail("invalid", `no transaction ${request.to} on this host`)
|
|
384
|
+
if (mine[at]!.state !== "committed") return txFail("invalid", `${request.to} did not commit; roll back to a committed transaction`)
|
|
385
|
+
start = at + 1
|
|
386
|
+
}
|
|
387
|
+
const undo = mine.slice(start).filter((j) => j.state === "committed").reverse()
|
|
388
|
+
if (undo.length === 0) return { ok: true }
|
|
389
|
+
const steps: StepOutcome[] = []
|
|
390
|
+
const conflicts: Conflict[] = []
|
|
391
|
+
const kept: Conflict[] = []
|
|
392
|
+
let last: TxReport | undefined
|
|
393
|
+
for (const journal of undo) {
|
|
394
|
+
const r = await new Run(store, lock, store.readPlan(journal), journal).rollbackAll()
|
|
395
|
+
if (!r.ok) return r
|
|
396
|
+
last = r.report
|
|
397
|
+
steps.push(...r.report.steps)
|
|
398
|
+
conflicts.push(...r.report.conflicts)
|
|
399
|
+
kept.push(...r.report.kept)
|
|
400
|
+
}
|
|
401
|
+
return { ok: true, report: { ...last!, steps, conflicts, kept } }
|
|
402
|
+
})
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** One transaction: its journal, the plan it applies, and its report. */
|
|
406
|
+
class Run {
|
|
407
|
+
private journal: TransactionJournal
|
|
408
|
+
private readonly steps: StepOutcome[] = []
|
|
409
|
+
private readonly conflicts: Conflict[] = []
|
|
410
|
+
private readonly kept: Conflict[] = []
|
|
411
|
+
private readonly evidence: { actionID: string; results: readonly EvidenceResult[] }[] = []
|
|
412
|
+
private scratch?: ReturnType<typeof fsStaging>
|
|
413
|
+
|
|
414
|
+
constructor(
|
|
415
|
+
private readonly store: TxStore,
|
|
416
|
+
private readonly lock: { current: ApplyLock },
|
|
417
|
+
private readonly plan: SetupPlan | undefined,
|
|
418
|
+
journal: TransactionJournal,
|
|
419
|
+
) {
|
|
420
|
+
this.journal = journal
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
get txID() {
|
|
424
|
+
return this.journal.txID
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
report(): TxReport {
|
|
428
|
+
return { txID: this.txID, state: this.journal.state, steps: [...this.steps], conflicts: [...this.conflicts], kept: [...this.kept], evidence: [...this.evidence] }
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
persist(patch: Partial<Pick<TransactionJournal, "state" | "entries">> = {}): void {
|
|
432
|
+
const next: TransactionJournal = { ...this.journal, ...patch, updatedAt: Math.max(now(), this.journal.startedAt) }
|
|
433
|
+
this.store.writeJournal(next)
|
|
434
|
+
this.journal = next
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private executor(action: Action): ActionExecutor | undefined {
|
|
438
|
+
return config.executors[action.kind] as ActionExecutor | undefined
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** The current step's staging area (its directory is only created once something is written). */
|
|
442
|
+
private staging(): StagingArea {
|
|
443
|
+
this.scratch ??= fsStaging(fs, this.store.stagingDir(this.txID))
|
|
444
|
+
return this.scratch
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Removes the step's staging directory (what was not moved into place); a failure is left for the sweep. */
|
|
448
|
+
private dropStaging(): void {
|
|
449
|
+
const scratch = this.scratch
|
|
450
|
+
this.scratch = undefined
|
|
451
|
+
try {
|
|
452
|
+
scratch?.dispose()
|
|
453
|
+
} catch {}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private resolve(ref: PathRef, options?: ResolveOptions): TxResult<string> {
|
|
457
|
+
return resolveTarget(ref, config.roots, fs, { ...options, context })
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------- preflight ----------
|
|
461
|
+
|
|
462
|
+
async preflight(): Promise<TxResult<void>> {
|
|
463
|
+
for (const action of this.plan!.actions) {
|
|
464
|
+
const exec = this.executor(action)
|
|
465
|
+
if (!exec) return txFail("internal", `this hubctl cannot apply ${action.kind} steps`)
|
|
466
|
+
const step = exec.plan(action)
|
|
467
|
+
const ctx = this.context(action, step)
|
|
468
|
+
if (!ctx.ok) return ctx
|
|
469
|
+
if (exec.check) {
|
|
470
|
+
const r = await exec.check(action, ctx.value)
|
|
471
|
+
if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return txOk(undefined)
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private context(action: Action, step: StepPlan): TxResult<StepContext> {
|
|
478
|
+
let target: string | undefined
|
|
479
|
+
let current: FileState = { exists: false }
|
|
480
|
+
if (step.path) {
|
|
481
|
+
const r = this.resolve(step.path, step.resolve)
|
|
482
|
+
if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
|
|
483
|
+
target = r.value
|
|
484
|
+
current = fileState(fs, target, { tree: step.class === "harness" })
|
|
485
|
+
} else if (step.class !== "external") return txFail("internal", `${action.id}: a ${step.class} step names its path`)
|
|
486
|
+
return txOk({
|
|
487
|
+
fs,
|
|
488
|
+
roots: config.roots,
|
|
489
|
+
context,
|
|
490
|
+
host: config.host,
|
|
491
|
+
now,
|
|
492
|
+
target,
|
|
493
|
+
current,
|
|
494
|
+
original: this.original(step.path, current),
|
|
495
|
+
resolve: (ref, options) => this.resolve(ref, options),
|
|
496
|
+
recordEvidence: (results) => this.evidence.push({ actionID: action.id, results }),
|
|
497
|
+
staging: this.staging(),
|
|
498
|
+
})
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The path's state before this transaction, if our own earlier steps are all that changed it. */
|
|
502
|
+
private original(path: PathRef | undefined, current: FileState): FileState {
|
|
503
|
+
if (!path) return current
|
|
504
|
+
const mine = this.journal.entries.filter((e) => samePath(e.path, path))
|
|
505
|
+
const latest = mine[mine.length - 1]
|
|
506
|
+
if (!latest || latest.status !== "done" || !latest.after || !sameState(current, latest.after)) return current
|
|
507
|
+
return mine[0]!.before
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------- forward ----------
|
|
511
|
+
|
|
512
|
+
async forward(): Promise<TxOutcome> {
|
|
513
|
+
let failure: TxFailure | undefined
|
|
514
|
+
try {
|
|
515
|
+
for (const action of this.plan!.actions) {
|
|
516
|
+
if (this.complete(action)) {
|
|
517
|
+
this.steps.push({ actionID: action.id, kind: action.kind, outcome: "resumed" })
|
|
518
|
+
continue
|
|
519
|
+
}
|
|
520
|
+
const r = await this.step(action)
|
|
521
|
+
if (!r.ok) {
|
|
522
|
+
this.steps.push({ actionID: action.id, kind: action.kind, outcome: "failed" })
|
|
523
|
+
failure = r
|
|
524
|
+
break
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (!failure) this.persist({ state: "committed" })
|
|
528
|
+
} catch (error) {
|
|
529
|
+
failure = failureOf(error)
|
|
530
|
+
}
|
|
531
|
+
if (!failure) {
|
|
532
|
+
if (!config.keepBackups) this.cleanup()
|
|
533
|
+
return { ok: true, report: this.report() }
|
|
534
|
+
}
|
|
535
|
+
const rolled = await this.rollbackAll()
|
|
536
|
+
if (!rolled.ok) return { ...rolled, message: `${failure.message}; then ${rolled.message}`, report: this.report() }
|
|
537
|
+
return { ...failure, report: this.report() }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Whether a step of the plan already finished in this transaction (resume). */
|
|
541
|
+
private complete(action: Action): boolean {
|
|
542
|
+
const exec = this.executor(action)
|
|
543
|
+
if (!exec) return false
|
|
544
|
+
const path = exec.plan(action).path
|
|
545
|
+
return this.journal.entries.some((e) => e.actionID === action.id && samePath(e.path, path) && (e.status === "done" || e.status === "skipped"))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** Keeps the lock from looking overrun during a long apply (renewed once a quarter of its lifetime is used). */
|
|
549
|
+
private renew(): void {
|
|
550
|
+
if (now() - this.lock.current.acquiredAt < LOCK_MAX_MS / 4) return
|
|
551
|
+
this.lock.current = renewLock(lockIO(this.store), this.lock.current)
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async step(action: Action): Promise<TxResult<void>> {
|
|
555
|
+
try {
|
|
556
|
+
return await this.stepOnce(action)
|
|
557
|
+
} finally {
|
|
558
|
+
this.dropStaging()
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private async stepOnce(action: Action): Promise<TxResult<void>> {
|
|
563
|
+
this.renew()
|
|
564
|
+
const exec = this.executor(action)
|
|
565
|
+
if (!exec) return txFail("internal", `this hubctl cannot apply ${action.kind} steps`)
|
|
566
|
+
const step = exec.plan(action)
|
|
567
|
+
const ctx = this.context(action, step)
|
|
568
|
+
if (!ctx.ok) return ctx
|
|
569
|
+
if (exec.check) {
|
|
570
|
+
const r = await exec.check(action, ctx.value)
|
|
571
|
+
if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
|
|
572
|
+
}
|
|
573
|
+
const change = await exec.change(action, ctx.value)
|
|
574
|
+
if (!change.ok) return txFail(change.code, `${action.id}: ${change.message}`)
|
|
575
|
+
const r = await this.perform(action, step, ctx.value, change.value)
|
|
576
|
+
if (r.ok) this.steps.push({ actionID: action.id, kind: action.kind, outcome: r.value })
|
|
577
|
+
return r.ok ? txOk(undefined) : r
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private async append(entry: Omit<JournalEntry, "seq">): Promise<JournalEntry> {
|
|
581
|
+
const full: JournalEntry = { ...entry, seq: this.journal.entries.length + 1 }
|
|
582
|
+
this.persist({ entries: [...this.journal.entries, full] })
|
|
583
|
+
await hooks.beforeAction?.(copy(full), copy(this.journal))
|
|
584
|
+
return full
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private async settle(entry: JournalEntry, patch: Partial<Pick<JournalEntry, "status" | "after">>, observe = true): Promise<JournalEntry> {
|
|
588
|
+
const next: JournalEntry = { ...entry, ...patch }
|
|
589
|
+
this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? next : e)) })
|
|
590
|
+
if (observe) await hooks.afterAction?.(copy(next), copy(this.journal))
|
|
591
|
+
return next
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private stagingPath(target: string, seq: number): string {
|
|
595
|
+
return `${dirname(target)}/.${basename(target)}.hub-${this.txID}-${seq}.tmp`
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* A step that is already exactly as wanted changes nothing, so it gets no journal entry: there is nothing to
|
|
600
|
+
* own or undo, and a resume simply finds it unchanged again.
|
|
601
|
+
*/
|
|
602
|
+
private skip(): TxResult<"skipped"> {
|
|
603
|
+
return txOk("skipped")
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Re-checks the target right before it is written: a symlink planted since the step began is refused. */
|
|
607
|
+
private recheck(action: Action, step: StepPlan): void {
|
|
608
|
+
const r = this.resolve(step.path!, step.resolve)
|
|
609
|
+
if (!r.ok) throw new TxError(r.code, `${action.id}: ${r.message}`)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Creates the missing directories above `ref` (its root first), each journaled as its own owned change. */
|
|
613
|
+
private async parents(action: Action, ref: PathRef): Promise<void> {
|
|
614
|
+
const parts = ref.rel.split("/").slice(0, -1)
|
|
615
|
+
const root = rootDir(ref.root, config.roots, context)!
|
|
616
|
+
if (!fs.lstat(root)) await this.rootDirs(action, ref.root, root)
|
|
617
|
+
for (let i = 1; i <= parts.length; i++) await this.ownedDir(action, { root: ref.root, rel: parts.slice(0, i).join("/") }, `${root === "/" ? "" : root}/${parts.slice(0, i).join("/")}`)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** One missing directory, journaled before it is made (rollback removes it while it is empty). */
|
|
621
|
+
private async ownedDir(action: Action, ref: PathRef, abs: string): Promise<void> {
|
|
622
|
+
const info = fs.lstat(abs)
|
|
623
|
+
if (info?.kind === "directory") return
|
|
624
|
+
if (info) throw new TxError("conflict", `${abs} is not a directory`)
|
|
625
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: "directory", path: ref, before: { exists: false }, after: { exists: true, mode: PARENT_MODE }, status: "pending" })
|
|
626
|
+
const again = this.resolve(ref)
|
|
627
|
+
if (!again.ok) throw new TxError(again.code, `${action.id}: ${again.message}`)
|
|
628
|
+
fs.mkdir(abs, PARENT_MODE)
|
|
629
|
+
fs.chmod(abs, PARENT_MODE)
|
|
630
|
+
await this.settle(entry, { status: "done", after: fileState(fs, abs) })
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* A missing root directory (a fresh home without ~/.local/bin). A root is not a PathRef of its own, but a root
|
|
635
|
+
* under the home root is one of the home root's: each missing level is journaled as a home directory, so a
|
|
636
|
+
* rollback removes what this transaction created (and only while it is empty). A root outside home, or below a
|
|
637
|
+
* symlink or a missing home, is created unjournaled as before (it cannot be owned without following the link).
|
|
638
|
+
*/
|
|
639
|
+
private async rootDirs(action: Action, id: PathRef["root"], root: string): Promise<void> {
|
|
640
|
+
const home = id === "home" ? undefined : rootDir("home", config.roots, context)
|
|
641
|
+
const homeInfo = home ? fs.lstat(home) : undefined
|
|
642
|
+
const rel = home && homeInfo?.kind === "directory" && root.startsWith(`${home}/`) ? root.slice(home.length + 1) : undefined
|
|
643
|
+
const parts = rel?.split("/") ?? []
|
|
644
|
+
const levels = parts.map((_, i) => ({ rel: parts.slice(0, i + 1).join("/"), abs: `${home}/${parts.slice(0, i + 1).join("/")}` }))
|
|
645
|
+
if (!rel || levels.some((l) => fs.lstat(l.abs)?.kind === "symlink")) {
|
|
646
|
+
fs.mkdirp(root, PARENT_MODE)
|
|
647
|
+
return
|
|
648
|
+
}
|
|
649
|
+
for (const level of levels) await this.ownedDir(action, { root: "home", rel: level.rel }, level.abs)
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private backup(seq: number, target: string, current: FileState): { id: string } | undefined {
|
|
653
|
+
if (!current.exists) return undefined
|
|
654
|
+
const id = `bk_${seq}`
|
|
655
|
+
if (current.symlink) this.store.writeBackup(this.txID, id, fs.readlink(target))
|
|
656
|
+
else this.store.writeBackupFrom(this.txID, id, target)
|
|
657
|
+
return { id }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Puts a staged file at `dest` with `mode`: a file this step's staging area wrote (with the digest it recorded) is
|
|
662
|
+
* moved (copied across filesystems); any other file is copied in chunks and must have the digest it claims.
|
|
663
|
+
*/
|
|
664
|
+
private place(file: StagedFile, dest: string, mode: number, staging: StagingArea): void {
|
|
665
|
+
const written = staging.written(file)
|
|
666
|
+
if (written && written.sha256 === file.sha256 && fs.lstat(file.path)?.kind === "file") {
|
|
667
|
+
try {
|
|
668
|
+
fs.rename(file.path, dest)
|
|
669
|
+
fs.chmod(dest, mode)
|
|
670
|
+
return
|
|
671
|
+
} catch (error) {
|
|
672
|
+
if (errnoOf(error) !== "EXDEV") throw error
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
if (fs.lstat(file.path)?.kind !== "file") throw new TxError("not_found", `${file.path} is not a regular file`)
|
|
676
|
+
if (copyFile(fs, file.path, dest, mode) !== file.sha256) {
|
|
677
|
+
fs.unlink(dest)
|
|
678
|
+
throw new TxError("integrity_mismatch", `${file.path} changed after it was verified; nothing was installed`)
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private async perform(action: Action, step: StepPlan, ctx: StepContext, change: Change): Promise<TxResult<"applied" | "skipped">> {
|
|
683
|
+
const { target, current } = ctx
|
|
684
|
+
const path = step.path
|
|
685
|
+
const conflict = (why: string) => txFail("conflict", `${action.id}: ${target} ${why}; nothing was overwritten`)
|
|
686
|
+
const kind = target ? fs.lstat(target)?.kind : undefined
|
|
687
|
+
switch (change.type) {
|
|
688
|
+
case "none":
|
|
689
|
+
return this.skip()
|
|
690
|
+
case "remove": {
|
|
691
|
+
if (!current.exists) return this.skip()
|
|
692
|
+
if (kind !== "file") return conflict("is not a file")
|
|
693
|
+
const seq = this.journal.entries.length + 1
|
|
694
|
+
const backup = this.backup(seq, target!, current)
|
|
695
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: false }, ...(backup ? { backup } : {}), status: "pending" })
|
|
696
|
+
this.recheck(action, step)
|
|
697
|
+
fs.unlink(target!)
|
|
698
|
+
fs.fsyncDir(dirname(target!))
|
|
699
|
+
await this.settle(entry, { status: "done", after: fileState(fs, target!) })
|
|
700
|
+
return txOk("applied")
|
|
701
|
+
}
|
|
702
|
+
case "effect": {
|
|
703
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, ...(path ? { path } : {}), before: current, status: "pending" })
|
|
704
|
+
if (path) this.recheck(action, step)
|
|
705
|
+
const r = await change.perform()
|
|
706
|
+
if (!r.ok) return txFail(r.code, `${action.id}: ${r.message}`)
|
|
707
|
+
await this.settle(entry, { status: "done", ...(target ? { after: fileState(fs, target) } : {}) })
|
|
708
|
+
return txOk("applied")
|
|
709
|
+
}
|
|
710
|
+
case "directory": {
|
|
711
|
+
if (current.exists) return kind === "directory" ? this.skip() : conflict("exists and is not a directory")
|
|
712
|
+
await this.parents(action, path!)
|
|
713
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: { exists: true, mode: change.mode }, status: "pending" })
|
|
714
|
+
this.recheck(action, step)
|
|
715
|
+
fs.mkdir(target!, change.mode)
|
|
716
|
+
fs.chmod(target!, change.mode)
|
|
717
|
+
await this.settle(entry, { status: "done", after: fileState(fs, target!) })
|
|
718
|
+
return txOk("applied")
|
|
719
|
+
}
|
|
720
|
+
case "file":
|
|
721
|
+
case "symlink": {
|
|
722
|
+
const desired: FileState =
|
|
723
|
+
change.type === "file"
|
|
724
|
+
? { exists: true, sha256: "bytes" in change ? sha256Of(change.bytes) : change.staged.sha256, mode: change.mode }
|
|
725
|
+
: { exists: true, symlink: true, sha256: sha256Of(change.target) }
|
|
726
|
+
if (current.exists) {
|
|
727
|
+
if (sameState(current, desired) && (change.type === "symlink" || kind === "file")) return this.skip()
|
|
728
|
+
if (change.replace === "refuse") return conflict("already exists with other content")
|
|
729
|
+
if (kind !== "file" && kind !== "symlink") return conflict("exists and is not a file")
|
|
730
|
+
}
|
|
731
|
+
await this.parents(action, path!)
|
|
732
|
+
const seq = this.journal.entries.length + 1
|
|
733
|
+
const backup = this.backup(seq, target!, current)
|
|
734
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: desired, ...(backup ? { backup } : {}), status: "pending" })
|
|
735
|
+
this.recheck(action, step)
|
|
736
|
+
const staged = this.stagingPath(target!, entry.seq)
|
|
737
|
+
if (change.type === "symlink") fs.symlink(change.target, staged)
|
|
738
|
+
else if ("bytes" in change) fs.writeExclusive(staged, change.bytes, change.mode)
|
|
739
|
+
else this.place(change.staged, staged, change.mode, ctx.staging)
|
|
740
|
+
fs.rename(staged, target!)
|
|
741
|
+
fs.fsyncDir(dirname(target!))
|
|
742
|
+
const after = fileState(fs, target!)
|
|
743
|
+
if (!sameState(after, desired)) throw new TxError("conflict", `${action.id}: ${target} changed while it was being written`)
|
|
744
|
+
await this.settle(entry, { status: "done", after })
|
|
745
|
+
return txOk("applied")
|
|
746
|
+
}
|
|
747
|
+
case "tree": {
|
|
748
|
+
const desired: FileState = { exists: true, sha256: treeDigestOfFiles(change.files, change.mode), mode: change.mode }
|
|
749
|
+
if (current.exists) return sameState(current, desired) ? this.skip() : conflict("already exists with other content (install roots are never overwritten)")
|
|
750
|
+
await this.parents(action, path!)
|
|
751
|
+
const entry = await this.append({ actionID: action.id, kind: action.kind, class: step.class, path: path!, before: current, after: desired, status: "pending" })
|
|
752
|
+
this.recheck(action, step)
|
|
753
|
+
const staged = this.stagingPath(target!, entry.seq)
|
|
754
|
+
this.stageTree(staged, change.files, change.mode, ctx.staging)
|
|
755
|
+
fs.rename(staged, target!)
|
|
756
|
+
fs.fsyncDir(dirname(target!))
|
|
757
|
+
const after = fileState(fs, target!, { tree: true })
|
|
758
|
+
if (!sameState(after, desired)) throw new TxError("conflict", `${action.id}: ${target} changed while it was being installed`)
|
|
759
|
+
await this.settle(entry, { status: "done", after })
|
|
760
|
+
return txOk("applied")
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
private stageTree(dir: string, files: readonly TreeFile[], mode: number, staging: StagingArea): void {
|
|
766
|
+
fs.mkdir(dir, mode)
|
|
767
|
+
fs.chmod(dir, mode)
|
|
768
|
+
const dirs = new Set<string>()
|
|
769
|
+
for (const f of files) {
|
|
770
|
+
const parts = f.rel.split("/")
|
|
771
|
+
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"))
|
|
772
|
+
}
|
|
773
|
+
for (const d of [...dirs].sort()) {
|
|
774
|
+
fs.mkdir(`${dir}/${d}`, mode)
|
|
775
|
+
fs.chmod(`${dir}/${d}`, mode)
|
|
776
|
+
}
|
|
777
|
+
for (const f of files) {
|
|
778
|
+
if ("bytes" in f) fs.writeExclusive(`${dir}/${f.rel}`, f.bytes, f.mode)
|
|
779
|
+
else this.place(f.staged, `${dir}/${f.rel}`, f.mode, staging)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private cleanup(): void {
|
|
784
|
+
try {
|
|
785
|
+
this.store.dropBackups(this.txID)
|
|
786
|
+
} catch (error) {
|
|
787
|
+
if (!errnoOf(error)) throw error
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------- resume ----------
|
|
792
|
+
|
|
793
|
+
async resume(): Promise<TxOutcome> {
|
|
794
|
+
const last = this.journal.entries[this.journal.entries.length - 1]
|
|
795
|
+
if (last?.status === "pending") {
|
|
796
|
+
const r = this.settlePending(last)
|
|
797
|
+
if (!r.ok) return { ...r, report: this.report() }
|
|
798
|
+
}
|
|
799
|
+
if (this.journal.state === "prepared") this.persist({ state: "applying" })
|
|
800
|
+
return this.forward()
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Decides an interrupted (pending) last entry before resuming: finished, never started, or changed by someone. */
|
|
804
|
+
private settlePending(entry: JournalEntry): TxResult<void> {
|
|
805
|
+
const truncate = () => this.persist({ entries: this.journal.entries.slice(0, -1) })
|
|
806
|
+
if (!entry.path) {
|
|
807
|
+
truncate() // an external step: run it again
|
|
808
|
+
return txOk(undefined)
|
|
809
|
+
}
|
|
810
|
+
const target = this.resolve(entry.path, this.resolveOptions(entry))
|
|
811
|
+
if (!target.ok) return txFail("recovery_required", `${entry.actionID}: ${target.message}; roll back instead`)
|
|
812
|
+
const current = fileState(fs, target.value, { tree: entry.class === "harness" })
|
|
813
|
+
if (entry.after && sameState(current, entry.after)) {
|
|
814
|
+
this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? { ...e, status: "done" } : e)) })
|
|
815
|
+
return txOk(undefined)
|
|
816
|
+
}
|
|
817
|
+
if (sameState(current, entry.before)) {
|
|
818
|
+
this.removeStaging(target.value, entry.seq)
|
|
819
|
+
truncate()
|
|
820
|
+
return txOk(undefined)
|
|
821
|
+
}
|
|
822
|
+
if (entry.class === "credential" && !entry.after) {
|
|
823
|
+
// The credential store writes atomically: the interrupted enrollment stored its credential.
|
|
824
|
+
this.persist({ entries: this.journal.entries.map((e) => (e.seq === entry.seq ? { ...e, status: "done", after: current } : e)) })
|
|
825
|
+
return txOk(undefined)
|
|
826
|
+
}
|
|
827
|
+
return txFail("recovery_required", `${entry.actionID}: ${target.value} changed during the interruption; roll back instead`)
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
private resolveOptions(entry: JournalEntry): ResolveOptions {
|
|
831
|
+
return entry.class === "link" ? { finalMayBeSymlink: true } : {}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---------- rollback ----------
|
|
835
|
+
|
|
836
|
+
async rollbackAll(): Promise<TxOutcome> {
|
|
837
|
+
const decided: RecoveryStep[] = []
|
|
838
|
+
try {
|
|
839
|
+
if (this.journal.state !== "rolling-back") this.persist({ state: "rolling-back" })
|
|
840
|
+
for (const entry of [...this.journal.entries].reverse()) {
|
|
841
|
+
if (entry.status === "undone" || entry.status === "skipped") {
|
|
842
|
+
// An interrupted earlier rollback may have left this entry's staging (or a tree moved aside) behind.
|
|
843
|
+
const r = entry.path ? this.resolve(entry.path, this.resolveOptions(entry)) : undefined
|
|
844
|
+
if (r?.ok) this.removeStaging(r.value, entry.seq)
|
|
845
|
+
continue
|
|
846
|
+
}
|
|
847
|
+
decided.push({ seq: entry.seq, actionID: entry.actionID, decision: await this.undo(entry) })
|
|
848
|
+
}
|
|
849
|
+
this.persist({ state: "rolled-back" })
|
|
850
|
+
this.cleanup()
|
|
851
|
+
await hooks.onRecover?.(copy(this.journal), copy(decided))
|
|
852
|
+
return { ok: true, report: this.report() }
|
|
853
|
+
} catch (error) {
|
|
854
|
+
const f = failureOf(error)
|
|
855
|
+
try {
|
|
856
|
+
this.persist({ state: "failed" })
|
|
857
|
+
} catch {}
|
|
858
|
+
return txFail("recovery_required", `rollback of ${this.txID} did not finish (${f.message}); run \`hubctl setup recover\` again`)
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private async conflict(entry: JournalEntry, path: string | undefined, reason: string, current: FileState = { exists: false }) {
|
|
863
|
+
this.conflicts.push({ seq: entry.seq, actionID: entry.actionID, ...(path ? { path } : {}), reason })
|
|
864
|
+
await hooks.onConflict?.(copy(entry), copy(current))
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private async mark(entry: JournalEntry, status: "undone" | "skipped"): Promise<void> {
|
|
868
|
+
await this.settle(entry, { status }, false)
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
private actionOf(entry: JournalEntry): Action | undefined {
|
|
872
|
+
return this.plan?.actions.find((a) => a.id === entry.actionID)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async undo(entry: JournalEntry): Promise<RecoveryStep["decision"]> {
|
|
876
|
+
const action = this.actionOf(entry)
|
|
877
|
+
const exec = action ? this.executor(action) : undefined
|
|
878
|
+
if (entry.class === "external") {
|
|
879
|
+
if (entry.status === "done" && action && exec?.undo) {
|
|
880
|
+
const r = await exec.undo(action as never, { fs, roots: config.roots, context, host: config.host, now, reason: "revert-external" })
|
|
881
|
+
if (!r.ok) await this.conflict(entry, undefined, `could not be reverted: ${r.message}`)
|
|
882
|
+
}
|
|
883
|
+
await this.mark(entry, "undone")
|
|
884
|
+
return "nothing-to-do"
|
|
885
|
+
}
|
|
886
|
+
const resolved = this.resolve(entry.path!, this.resolveOptions(entry))
|
|
887
|
+
if (!resolved.ok) {
|
|
888
|
+
await this.conflict(entry, undefined, `left alone: ${resolved.message}`)
|
|
889
|
+
return "keep-user-edit"
|
|
890
|
+
}
|
|
891
|
+
const target = resolved.value
|
|
892
|
+
this.removeStaging(target, entry.seq)
|
|
893
|
+
const current = fileState(fs, target, { tree: entry.class === "harness" })
|
|
894
|
+
if (sameState(current, entry.before)) {
|
|
895
|
+
await this.mark(entry, entry.after ? "undone" : "skipped")
|
|
896
|
+
return "nothing-to-do"
|
|
897
|
+
}
|
|
898
|
+
// An enrollment interrupted before it was journaled done stored a credential we never recorded: the contract
|
|
899
|
+
// clears it (recoveryDecision, v1.1).
|
|
900
|
+
const decision = recoveryDecision(entry, current)
|
|
901
|
+
switch (decision) {
|
|
902
|
+
case "nothing-to-do":
|
|
903
|
+
await this.mark(entry, entry.after ? "undone" : "skipped")
|
|
904
|
+
break
|
|
905
|
+
case "keep-user-edit":
|
|
906
|
+
await this.conflict(entry, target, "changed since hub wrote it; kept as it is", current)
|
|
907
|
+
break
|
|
908
|
+
case "restore-backup": {
|
|
909
|
+
// A symlink's backup is its target text; a file's is read and copied in chunks, never whole.
|
|
910
|
+
const link = entry.backup && entry.before.symlink ? this.store.readBackup(this.txID, entry.backup.id) : undefined
|
|
911
|
+
const file = entry.backup && !entry.before.symlink ? this.store.backupFile(this.txID, entry.backup.id) : undefined
|
|
912
|
+
const backupDigest = link ? sha256Of(new TextDecoder().decode(link)) : file?.sha256
|
|
913
|
+
if ((!link && !file) || backupDigest !== entry.before.sha256) {
|
|
914
|
+
await this.conflict(entry, target, "its backup is missing or does not match what was backed up; left as hub wrote it", current)
|
|
915
|
+
return "keep-user-edit"
|
|
916
|
+
}
|
|
917
|
+
const staged = this.stagingPath(target, entry.seq)
|
|
918
|
+
if (link) fs.symlink(new TextDecoder().decode(link), staged)
|
|
919
|
+
else if (copyFile(fs, file!.path, staged, entry.before.mode ?? 0o600) !== entry.before.sha256) {
|
|
920
|
+
fs.unlink(staged)
|
|
921
|
+
await this.conflict(entry, target, "its backup changed while it was restored; left as hub wrote it", current)
|
|
922
|
+
return "keep-user-edit"
|
|
923
|
+
}
|
|
924
|
+
fs.rename(staged, target)
|
|
925
|
+
fs.fsyncDir(dirname(target))
|
|
926
|
+
await this.mark(entry, "undone")
|
|
927
|
+
break
|
|
928
|
+
}
|
|
929
|
+
case "remove-created": {
|
|
930
|
+
const kind = fs.lstat(target)?.kind
|
|
931
|
+
if (kind === "directory" && entry.class === "harness") {
|
|
932
|
+
// Move the tree aside atomically, then delete it: an interrupted delete never leaves half a tree.
|
|
933
|
+
const trash = this.stagingPath(target, entry.seq)
|
|
934
|
+
fs.rename(target, trash)
|
|
935
|
+
await this.mark(entry, "undone")
|
|
936
|
+
this.removeTree(trash)
|
|
937
|
+
} else if (kind === "directory") {
|
|
938
|
+
try {
|
|
939
|
+
fs.rmdir(target)
|
|
940
|
+
} catch (error) {
|
|
941
|
+
if (errnoOf(error) !== "ENOTEMPTY" && errnoOf(error) !== "EEXIST") throw error
|
|
942
|
+
// Informational: hub made this directory, and something else (often the harness itself) wrote into it.
|
|
943
|
+
this.kept.push({ seq: entry.seq, actionID: entry.actionID, path: target, reason: "hub created this directory, and it now also holds files hub did not write (a harness may have written them); left in place" })
|
|
944
|
+
return "keep-user-edit"
|
|
945
|
+
}
|
|
946
|
+
await this.mark(entry, "undone")
|
|
947
|
+
} else {
|
|
948
|
+
fs.unlink(target)
|
|
949
|
+
await this.mark(entry, "undone")
|
|
950
|
+
}
|
|
951
|
+
break
|
|
952
|
+
}
|
|
953
|
+
case "clear-credential": {
|
|
954
|
+
if (!action || !exec?.undo) {
|
|
955
|
+
await this.conflict(entry, target, "holds a credential this hubctl cannot clear; remove it with `hubctl machine` commands", current)
|
|
956
|
+
return "keep-user-edit"
|
|
957
|
+
}
|
|
958
|
+
const r = await exec.undo(action as never, { fs, roots: config.roots, context, host: config.host, now, target, reason: "clear-credential" })
|
|
959
|
+
if (!r.ok) {
|
|
960
|
+
await this.conflict(entry, target, `the credential could not be cleared: ${r.message}`, current)
|
|
961
|
+
return "keep-user-edit"
|
|
962
|
+
}
|
|
963
|
+
// An interrupted enrollment recorded no "after"; the journal records what it cleared.
|
|
964
|
+
await this.settle(entry, { status: "undone", after: entry.after ?? current }, false)
|
|
965
|
+
break
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return decision
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private removeStaging(target: string, seq: number): void {
|
|
972
|
+
const staged = this.stagingPath(target, seq)
|
|
973
|
+
const info = fs.lstat(staged)
|
|
974
|
+
if (!info) return
|
|
975
|
+
if (info.kind === "directory") this.removeTree(staged)
|
|
976
|
+
else fs.unlink(staged)
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private removeTree(dir: string): void {
|
|
980
|
+
for (const name of fs.readdir(dir)) {
|
|
981
|
+
const p = `${dir}/${name}`
|
|
982
|
+
if (fs.lstat(p)?.kind === "directory") this.removeTree(p)
|
|
983
|
+
else fs.unlink(p)
|
|
984
|
+
}
|
|
985
|
+
fs.rmdir(dir)
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
return {
|
|
990
|
+
apply: async (request) => redact(await apply(request)),
|
|
991
|
+
recover: async (request) => redact(await recover(request)),
|
|
992
|
+
rollback: async (request) => redact(await rollback(request)),
|
|
993
|
+
}
|
|
994
|
+
}
|