@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,286 @@
|
|
|
1
|
+
// Managed-field drift detection. Hub owns exactly the fields its plans set: each merge-config pointer, its mcp
|
|
2
|
+
// registration, and the files it writes (write-file, install-service). Nothing else in a config is hub's.
|
|
3
|
+
//
|
|
4
|
+
// For each managed field, detection compares three states:
|
|
5
|
+
// applied what the last apply set: the applied plan's value, counted only when its journal entry is "done"
|
|
6
|
+
// desired what the fresh plan (for the current profile) sets
|
|
7
|
+
// current what is there: the snapshot's file digests, plus observed values for config fields. When a file's
|
|
8
|
+
// digest still equals what the journal says hub left, the applied values are known to be current.
|
|
9
|
+
// and gives one status:
|
|
10
|
+
// in-sync current equals desired
|
|
11
|
+
// missing absent and hub never set it: hub may set it
|
|
12
|
+
// outdated still exactly hub's applied value, but hub now wants another: hub may update it
|
|
13
|
+
// user-changed anything else (changed, removed, or a value the user had before hub): reported, never overwritten
|
|
14
|
+
// retired hub set it before but no longer manages it: left in place, reported
|
|
15
|
+
// unobserved the current state is unknown (no observation, file not discovered): reported, never repaired
|
|
16
|
+
//
|
|
17
|
+
// detectDrift is pure and never writes. Reports carry digests of current values, never the values themselves, so a
|
|
18
|
+
// user's secret in a field hub happens to manage cannot leak into a report. driftRepairs turns a report into the
|
|
19
|
+
// minimal lossless actions for the repairable fields only, pinned to the file's current digest.
|
|
20
|
+
import {
|
|
21
|
+
canonicalJSON,
|
|
22
|
+
Checker,
|
|
23
|
+
digestOf,
|
|
24
|
+
ok,
|
|
25
|
+
planDigest,
|
|
26
|
+
resolvePathRef,
|
|
27
|
+
validateDiscoverySnapshot,
|
|
28
|
+
validateJournal,
|
|
29
|
+
validateSetupPlan,
|
|
30
|
+
type Action,
|
|
31
|
+
type DiscoverySnapshot,
|
|
32
|
+
type FileState,
|
|
33
|
+
type HarnessID,
|
|
34
|
+
type Json,
|
|
35
|
+
type PathRef,
|
|
36
|
+
type Precondition,
|
|
37
|
+
type Result,
|
|
38
|
+
type SetupPlan,
|
|
39
|
+
type TransactionJournal,
|
|
40
|
+
} from "../contracts/index.ts"
|
|
41
|
+
|
|
42
|
+
export type ManagedFieldType = "config-value" | "array-entry" | "mcp-registration" | "file"
|
|
43
|
+
|
|
44
|
+
/** One field hub owns, as a plan sets it. */
|
|
45
|
+
export interface ManagedField {
|
|
46
|
+
/** "<root>:<rel>#<pointer>" for config fields, "<root>:<rel>" for files, "mcp:<harness>/<name>" for registrations. */
|
|
47
|
+
readonly key: string
|
|
48
|
+
readonly type: ManagedFieldType
|
|
49
|
+
readonly actionID: string
|
|
50
|
+
readonly path: PathRef
|
|
51
|
+
/** Config fields: the RFC 6901 pointer as the plan sets it ("/x/-" appends to the array at "/x"). */
|
|
52
|
+
readonly pointer?: string
|
|
53
|
+
/** Config fields: the value hub sets. */
|
|
54
|
+
readonly value?: Json
|
|
55
|
+
/** Files: the content digest hub writes. */
|
|
56
|
+
readonly sha256?: string
|
|
57
|
+
readonly registration?: { readonly harness: HarnessID; readonly name: string }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type DriftStatus = "in-sync" | "missing" | "outdated" | "user-changed" | "retired" | "unobserved"
|
|
61
|
+
|
|
62
|
+
export interface FieldDrift {
|
|
63
|
+
readonly key: string
|
|
64
|
+
readonly actionID: string
|
|
65
|
+
readonly path: PathRef
|
|
66
|
+
readonly pointer?: string
|
|
67
|
+
readonly status: DriftStatus
|
|
68
|
+
/** Whether hub may change it (missing, or outdated and replaceable by a set). */
|
|
69
|
+
readonly repairable: boolean
|
|
70
|
+
/** Digest of the current value or file; null when absent; omitted when unobserved. Never the value itself. */
|
|
71
|
+
readonly currentDigest?: string | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface DriftReport {
|
|
75
|
+
/** Desired fields in plan order, then retired ones. */
|
|
76
|
+
readonly fields: readonly FieldDrift[]
|
|
77
|
+
/** The fields the user changed: reported, never overwritten. */
|
|
78
|
+
readonly userChanged: readonly FieldDrift[]
|
|
79
|
+
/** Files hub wrote that changed since (by anyone; user edits to unmanaged fields show up only here). */
|
|
80
|
+
readonly changedSinceApply: readonly PathRef[]
|
|
81
|
+
readonly inSync: boolean
|
|
82
|
+
/** Always zero: detection never writes. */
|
|
83
|
+
readonly writes: 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What a reader saw (data, read by the caller; this module never reads). For an append pointer ("/plugin/-"),
|
|
88
|
+
* observe the array it appends to ("/plugin").
|
|
89
|
+
*/
|
|
90
|
+
export type ObservedField =
|
|
91
|
+
| { readonly path: PathRef; readonly pointer: string; readonly state: "absent" }
|
|
92
|
+
| { readonly path: PathRef; readonly pointer: string; readonly state: "value"; readonly value: Json }
|
|
93
|
+
|
|
94
|
+
export interface ObservedRegistration {
|
|
95
|
+
readonly harness: HarnessID
|
|
96
|
+
readonly name: string
|
|
97
|
+
/** ours: this hubctl's mcp subcommand. foreign: a server with that name that is not hubctl's. */
|
|
98
|
+
readonly state: "ours" | "absent" | "foreign"
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface Observation {
|
|
102
|
+
readonly fields?: readonly ObservedField[]
|
|
103
|
+
readonly registrations?: readonly ObservedRegistration[]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface DriftInput {
|
|
107
|
+
/** The plan for the current profile and snapshot. */
|
|
108
|
+
readonly desired: SetupPlan
|
|
109
|
+
readonly snapshot: DiscoverySnapshot
|
|
110
|
+
/** The last apply: its plan and journal (only entries that are "done" count as hub's). */
|
|
111
|
+
readonly applied?: { readonly plan: SetupPlan; readonly journal: TransactionJournal }
|
|
112
|
+
readonly observed?: Observation
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const pathKey = (p: PathRef) => `${p.root}:${p.rel}`
|
|
116
|
+
const samePath = (a: PathRef, b: PathRef) => a.root === b.root && a.rel === b.rel
|
|
117
|
+
const same = (a: unknown, b: unknown) => canonicalJSON(a) === canonicalJSON(b)
|
|
118
|
+
|
|
119
|
+
/** Every field a plan sets, in plan order. */
|
|
120
|
+
export function managedFields(plan: SetupPlan): ManagedField[] {
|
|
121
|
+
return plan.actions.flatMap((action): ManagedField[] => {
|
|
122
|
+
switch (action.kind) {
|
|
123
|
+
case "merge-config":
|
|
124
|
+
return action.set.map((s) => ({
|
|
125
|
+
key: `${pathKey(action.path)}#${s.pointer}`,
|
|
126
|
+
type: s.pointer.endsWith("/-") ? "array-entry" : "config-value",
|
|
127
|
+
actionID: action.id,
|
|
128
|
+
path: action.path,
|
|
129
|
+
pointer: s.pointer,
|
|
130
|
+
value: s.value,
|
|
131
|
+
}))
|
|
132
|
+
case "register-mcp-server":
|
|
133
|
+
return [{ key: `mcp:${action.harness}/${action.name}`, type: "mcp-registration", actionID: action.id, path: action.path, registration: { harness: action.harness, name: action.name } }]
|
|
134
|
+
case "write-file":
|
|
135
|
+
case "install-service":
|
|
136
|
+
return [{ key: pathKey(action.path), type: "file", actionID: action.id, path: action.path, sha256: action.sha256 }]
|
|
137
|
+
default:
|
|
138
|
+
return []
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The file's digest as discovered: null when it does not exist, undefined when discovery did not look at it. */
|
|
144
|
+
function discoveredDigest(snapshot: DiscoverySnapshot, path: PathRef): string | null | undefined {
|
|
145
|
+
const abs = resolvePathRef(path, snapshot.roots)
|
|
146
|
+
if (!abs.ok) return undefined
|
|
147
|
+
const found = snapshot.configs.find((c) => c.path === abs.value)
|
|
148
|
+
return found ? found.sha256 : undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function validate(input: DriftInput): Result<DriftInput> {
|
|
152
|
+
const c = new Checker()
|
|
153
|
+
c.merge("/desired", validateSetupPlan(input.desired))
|
|
154
|
+
c.merge("/snapshot", validateDiscoverySnapshot(input.snapshot))
|
|
155
|
+
if (input.applied) {
|
|
156
|
+
c.merge("/applied/plan", validateSetupPlan(input.applied.plan))
|
|
157
|
+
c.merge("/applied/journal", validateJournal(input.applied.journal))
|
|
158
|
+
}
|
|
159
|
+
if (c.issues.length > 0) return c.result(input)
|
|
160
|
+
const host = input.snapshot.host.fingerprint
|
|
161
|
+
if (input.desired.hostFingerprint !== host) c.add("/desired/hostFingerprint", "inconsistent", "the plan is for another host")
|
|
162
|
+
if (input.applied) {
|
|
163
|
+
if (input.applied.journal.hostFingerprint !== host || input.applied.plan.hostFingerprint !== host) c.add("/applied/journal/hostFingerprint", "inconsistent", "that history is another host's")
|
|
164
|
+
if (input.applied.journal.planDigest !== planDigest(input.applied.plan)) c.add("/applied/journal/planDigest", "inconsistent", "the journal records another plan")
|
|
165
|
+
}
|
|
166
|
+
return c.result(input)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
type Current = { readonly known: false } | { readonly known: true; readonly absent: true } | { readonly known: true; readonly absent: false; readonly value: Json }
|
|
170
|
+
const UNKNOWN: Current = { known: false }
|
|
171
|
+
const ABSENT: Current = { known: true, absent: true }
|
|
172
|
+
const present = (value: Json): Current => ({ known: true, absent: false, value })
|
|
173
|
+
|
|
174
|
+
export function detectDrift(input: DriftInput): Result<DriftReport> {
|
|
175
|
+
const checked = validate(input)
|
|
176
|
+
if (!checked.ok) return checked
|
|
177
|
+
const { desired, snapshot, observed = {} } = input
|
|
178
|
+
|
|
179
|
+
// Hub's applied fields: only actions whose journal entry is done.
|
|
180
|
+
const done = new Set((input.applied?.journal.entries ?? []).filter((e) => e.status === "done").map((e) => e.actionID))
|
|
181
|
+
const applied = new Map<string, ManagedField>()
|
|
182
|
+
if (input.applied) for (const f of managedFields(input.applied.plan)) if (done.has(f.actionID)) applied.set(f.key, f)
|
|
183
|
+
|
|
184
|
+
// What hub left in each file (the newest done entry per path).
|
|
185
|
+
const left = new Map<string, { path: PathRef; after: FileState }>()
|
|
186
|
+
for (const e of input.applied?.journal.entries ?? []) if (e.status === "done" && e.path && e.after) left.set(pathKey(e.path), { path: e.path, after: e.after })
|
|
187
|
+
const untouched = (path: PathRef) => {
|
|
188
|
+
const was = left.get(pathKey(path))
|
|
189
|
+
const now = discoveredDigest(snapshot, path)
|
|
190
|
+
return was !== undefined && now !== undefined && (was.after.exists ? now === was.after.sha256 : now === null)
|
|
191
|
+
}
|
|
192
|
+
const changedSinceApply = [...left.values()].filter(({ path }) => discoveredDigest(snapshot, path) !== undefined && !untouched(path)).map(({ path }) => path)
|
|
193
|
+
|
|
194
|
+
const observedAt = (path: PathRef, pointer: string): Current => {
|
|
195
|
+
const o = observed.fields?.find((f) => samePath(f.path, path) && f.pointer === pointer)
|
|
196
|
+
if (!o) return UNKNOWN
|
|
197
|
+
return o.state === "absent" ? ABSENT : present(o.value)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const judge = (f: ManagedField, a: ManagedField | undefined): Omit<FieldDrift, "key" | "actionID" | "path" | "pointer"> => {
|
|
201
|
+
const userOr = (missing: boolean, currentDigest: string | null) => (missing ? { status: "missing" as const, repairable: true, currentDigest } : { status: "user-changed" as const, repairable: false, currentDigest })
|
|
202
|
+
switch (f.type) {
|
|
203
|
+
case "file": {
|
|
204
|
+
const now = discoveredDigest(snapshot, f.path)
|
|
205
|
+
if (now === undefined) return { status: "unobserved", repairable: false }
|
|
206
|
+
if (now === f.sha256) return { status: "in-sync", repairable: false, currentDigest: now }
|
|
207
|
+
if (a && now === a.sha256) return { status: "outdated", repairable: true, currentDigest: now }
|
|
208
|
+
return userOr(now === null && !a, now)
|
|
209
|
+
}
|
|
210
|
+
case "config-value": {
|
|
211
|
+
let cur = observedAt(f.path, f.pointer!)
|
|
212
|
+
if (!cur.known && a && untouched(f.path)) cur = present(a.value!)
|
|
213
|
+
if (!cur.known) return { status: "unobserved", repairable: false }
|
|
214
|
+
if (cur.absent) return userOr(!a, null)
|
|
215
|
+
const currentDigest = digestOf(cur.value)
|
|
216
|
+
if (same(cur.value, f.value)) return { status: "in-sync", repairable: false, currentDigest }
|
|
217
|
+
if (a && same(cur.value, a.value)) return { status: "outdated", repairable: true, currentDigest }
|
|
218
|
+
return { status: "user-changed", repairable: false, currentDigest }
|
|
219
|
+
}
|
|
220
|
+
case "array-entry": {
|
|
221
|
+
let cur = observedAt(f.path, f.pointer!.slice(0, -2))
|
|
222
|
+
if (!cur.known && a && untouched(f.path)) cur = present([a.value!])
|
|
223
|
+
if (!cur.known) return { status: "unobserved", repairable: false }
|
|
224
|
+
if (cur.absent) return userOr(!a, null)
|
|
225
|
+
const currentDigest = digestOf(cur.value)
|
|
226
|
+
if (!Array.isArray(cur.value)) return { status: "user-changed", repairable: false, currentDigest }
|
|
227
|
+
const has = (v: unknown) => (cur.value as Json[]).some((item) => same(item, v))
|
|
228
|
+
if (has(f.value)) return { status: "in-sync", repairable: false, currentDigest }
|
|
229
|
+
// An append cannot replace hub's older entry, so an outdated entry is reported, not appended next to it.
|
|
230
|
+
if (a && has(a.value)) return { status: "outdated", repairable: false, currentDigest }
|
|
231
|
+
return userOr(!a, currentDigest)
|
|
232
|
+
}
|
|
233
|
+
case "mcp-registration": {
|
|
234
|
+
const reg = f.registration!
|
|
235
|
+
const o = observed.registrations?.find((r) => r.harness === reg.harness && r.name === reg.name)
|
|
236
|
+
const state = o?.state ?? (a && untouched(f.path) ? "ours" : undefined)
|
|
237
|
+
if (state === undefined) return { status: "unobserved", repairable: false }
|
|
238
|
+
if (state === "ours") return { status: "in-sync", repairable: false, currentDigest: digestOf(state) }
|
|
239
|
+
return userOr(state === "absent" && !a, digestOf(state))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const describe = (f: ManagedField) => ({ key: f.key, actionID: f.actionID, path: f.path, ...(f.pointer !== undefined ? { pointer: f.pointer } : {}) })
|
|
245
|
+
const wanted = managedFields(desired)
|
|
246
|
+
const fields: FieldDrift[] = [
|
|
247
|
+
...wanted.map((f) => ({ ...describe(f), ...judge(f, applied.get(f.key)) })),
|
|
248
|
+
...[...applied.values()].filter((a) => !wanted.some((f) => f.key === a.key)).map((a): FieldDrift => ({ ...describe(a), status: "retired", repairable: false })),
|
|
249
|
+
]
|
|
250
|
+
return ok({ fields, userChanged: fields.filter((f) => f.status === "user-changed"), changedSinceApply, inSync: fields.every((f) => f.status === "in-sync"), writes: 0 })
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The file's current state as a merge precondition, or undefined when discovery did not see the file. */
|
|
254
|
+
function preconditionNow(snapshot: DiscoverySnapshot, path: PathRef): Precondition | undefined {
|
|
255
|
+
const now = discoveredDigest(snapshot, path)
|
|
256
|
+
if (now === undefined) return undefined
|
|
257
|
+
return now === null ? { exists: false } : { exists: true, sha256: now }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The actions that bring hub's repairable fields back to the desired state, and nothing else: a merge sets only
|
|
262
|
+
* its repairable pointers (losslessly, pinned to the file's current digest), a registration or file is redone only
|
|
263
|
+
* when it is repairable. Fields the user changed are never in it.
|
|
264
|
+
*/
|
|
265
|
+
export function driftRepairs(report: DriftReport, desired: SetupPlan, snapshot: DiscoverySnapshot): Action[] {
|
|
266
|
+
const repairable = new Set(report.fields.filter((f) => f.repairable).map((f) => f.key))
|
|
267
|
+
return desired.actions.flatMap((action): Action[] => {
|
|
268
|
+
const fields = managedFields({ ...desired, actions: [action] })
|
|
269
|
+
switch (action.kind) {
|
|
270
|
+
case "merge-config": {
|
|
271
|
+
const set = action.set.filter((_, i) => repairable.has(fields[i]!.key))
|
|
272
|
+
const precondition = preconditionNow(snapshot, action.path)
|
|
273
|
+
return set.length > 0 && precondition ? [{ ...action, set, precondition }] : []
|
|
274
|
+
}
|
|
275
|
+
case "register-mcp-server": {
|
|
276
|
+
const precondition = preconditionNow(snapshot, action.path)
|
|
277
|
+
return repairable.has(fields[0]!.key) && precondition ? [{ ...action, precondition }] : []
|
|
278
|
+
}
|
|
279
|
+
case "write-file":
|
|
280
|
+
case "install-service":
|
|
281
|
+
return repairable.has(fields[0]!.key) ? [action] : []
|
|
282
|
+
default:
|
|
283
|
+
return []
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Rollback history, derived only from the contract's transaction journals (and, when available, the plans they
|
|
2
|
+
// record by digest). Each record lists what one apply on this host owned: per entry its class, path and before/
|
|
3
|
+
// after digests and modes, never contents and never credentials. Harness installs are annotated with the pinned
|
|
4
|
+
// version they installed and the version they replaced, so "what is installed" and "what a rollback restores" come
|
|
5
|
+
// from the history rather than a guess. v1.1 layout: each version has its own directory (a rolled-back install is
|
|
6
|
+
// removed) and select-harness-version switches the `current` link (a rolled-back select restores the previous link,
|
|
7
|
+
// so the version it records as `previous` is current again).
|
|
8
|
+
//
|
|
9
|
+
// rollbackTo plans undoing every transaction after a target, newest first, with the contract's recovery rules
|
|
10
|
+
// (recoveryDecision) per entry. It simulates the file states as it goes: undoing a newer change to a file yields
|
|
11
|
+
// the state the older change left, so chained changes resolve correctly. A user edit is kept and reported as a
|
|
12
|
+
// conflict (so are older changes to that file). A cleared credential's next state cannot be predicted, so older
|
|
13
|
+
// changes to that file are kept as conflicts too. The plan itself writes nothing.
|
|
14
|
+
import {
|
|
15
|
+
Checker,
|
|
16
|
+
EMBEDDED_ASSETS,
|
|
17
|
+
fail,
|
|
18
|
+
ok,
|
|
19
|
+
planDigest,
|
|
20
|
+
recoveryDecision,
|
|
21
|
+
validateJournal,
|
|
22
|
+
validateSetupPlan,
|
|
23
|
+
type ActionKind,
|
|
24
|
+
type EntryClass,
|
|
25
|
+
type FileState,
|
|
26
|
+
type HarnessID,
|
|
27
|
+
type InstallHarness,
|
|
28
|
+
type JournalEntry,
|
|
29
|
+
type SelectHarnessVersion,
|
|
30
|
+
type ServiceID,
|
|
31
|
+
type ServiceManagerID,
|
|
32
|
+
type PathRef,
|
|
33
|
+
type RecoveryDecision,
|
|
34
|
+
type Result,
|
|
35
|
+
type SetupPlan,
|
|
36
|
+
type TransactionJournal,
|
|
37
|
+
} from "../contracts/index.ts"
|
|
38
|
+
|
|
39
|
+
/** One owned change, as the journal records it (digests and modes only). */
|
|
40
|
+
export interface OwnedChange {
|
|
41
|
+
readonly seq: number
|
|
42
|
+
readonly actionID: string
|
|
43
|
+
readonly kind: ActionKind
|
|
44
|
+
readonly class: EntryClass
|
|
45
|
+
readonly path?: PathRef
|
|
46
|
+
readonly before: FileState
|
|
47
|
+
readonly after?: FileState
|
|
48
|
+
/** Whether a backup of `before` exists (credentials never have one). */
|
|
49
|
+
readonly backup: boolean
|
|
50
|
+
readonly status: JournalEntry["status"]
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface HarnessInstallRecord {
|
|
54
|
+
readonly harness: HarnessID
|
|
55
|
+
readonly version: string
|
|
56
|
+
/** The pinned artifact digest. */
|
|
57
|
+
readonly sha256: string
|
|
58
|
+
/** The version it replaced (what a rollback restores). */
|
|
59
|
+
readonly previous?: { readonly version: string; readonly sha256: string }
|
|
60
|
+
readonly status: JournalEntry["status"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TransactionOutcome = "committed" | "rolled-back" | "failed" | "interrupted"
|
|
64
|
+
|
|
65
|
+
export interface TransactionRecord {
|
|
66
|
+
readonly txID: string
|
|
67
|
+
readonly planDigest: string
|
|
68
|
+
readonly outcome: TransactionOutcome
|
|
69
|
+
readonly startedAt: number
|
|
70
|
+
readonly updatedAt: number
|
|
71
|
+
readonly changes: readonly OwnedChange[]
|
|
72
|
+
/** Harness installs whose plan is known (an install without its plan has no version to report). */
|
|
73
|
+
readonly harnesses: readonly HarnessInstallRecord[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HistoryContext {
|
|
77
|
+
/** This host's fingerprint: other hosts' journals (a shared home) are refused. */
|
|
78
|
+
readonly hostFingerprint: string
|
|
79
|
+
/** Plans the journals applied, matched by digest. */
|
|
80
|
+
readonly plans?: readonly SetupPlan[]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const OUTCOME: Readonly<Record<TransactionJournal["state"], TransactionOutcome>> = {
|
|
84
|
+
committed: "committed",
|
|
85
|
+
"rolled-back": "rolled-back",
|
|
86
|
+
failed: "failed",
|
|
87
|
+
prepared: "interrupted",
|
|
88
|
+
applying: "interrupted",
|
|
89
|
+
"rolling-back": "interrupted",
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const byTime = (a: TransactionJournal, b: TransactionJournal) => a.startedAt - b.startedAt || (a.txID < b.txID ? -1 : a.txID > b.txID ? 1 : 0)
|
|
93
|
+
|
|
94
|
+
/** Validates the journals (and plans) and returns them oldest first. */
|
|
95
|
+
function checkedJournals(journals: readonly unknown[], context: HistoryContext): Result<{ journals: TransactionJournal[]; plans: Map<string, SetupPlan> }> {
|
|
96
|
+
const c = new Checker()
|
|
97
|
+
journals.forEach((j, i) => c.merge(`/journals/${i}`, validateJournal(j)))
|
|
98
|
+
;(context.plans ?? []).forEach((p, i) => c.merge(`/plans/${i}`, validateSetupPlan(p)))
|
|
99
|
+
if (c.issues.length > 0) return c.result(undefined)
|
|
100
|
+
const valid = journals as TransactionJournal[]
|
|
101
|
+
const seen = new Set<string>()
|
|
102
|
+
valid.forEach((j, i) => {
|
|
103
|
+
if (j.hostFingerprint !== context.hostFingerprint) c.add(`/journals/${i}/hostFingerprint`, "inconsistent", "that journal is another host's")
|
|
104
|
+
if (seen.has(j.txID)) c.add(`/journals/${i}/txID`, "duplicate", `duplicate transaction ${j.txID}`)
|
|
105
|
+
seen.add(j.txID)
|
|
106
|
+
})
|
|
107
|
+
if (c.issues.length > 0) return c.result(undefined)
|
|
108
|
+
const plans = new Map((context.plans ?? []).map((p) => [planDigest(p), p]))
|
|
109
|
+
return ok({ journals: [...valid].sort(byTime), plans })
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function installAction(plan: SetupPlan | undefined, actionID: string): InstallHarness | undefined {
|
|
113
|
+
return plan?.actions.find((a): a is InstallHarness => a.kind === "install-harness" && a.id === actionID)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The plan action whose rollback brings back a previous harness version: a v1.1 select, or a v1.0 in-place install. */
|
|
117
|
+
function restoringAction(plan: SetupPlan | undefined, entry: JournalEntry): InstallHarness | SelectHarnessVersion | undefined {
|
|
118
|
+
if (entry.kind !== "install-harness" && entry.kind !== "select-harness-version") return undefined
|
|
119
|
+
return plan?.actions.find((a): a is InstallHarness | SelectHarnessVersion => a.kind === entry.kind && a.id === entry.actionID)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function record(journal: TransactionJournal, plan: SetupPlan | undefined): TransactionRecord {
|
|
123
|
+
const harnesses = journal.entries.flatMap((e): HarnessInstallRecord[] => {
|
|
124
|
+
const action = e.kind === "install-harness" ? installAction(plan, e.actionID) : undefined
|
|
125
|
+
if (!action) return []
|
|
126
|
+
return [{ harness: action.harness, version: action.version, sha256: action.sha256, ...(action.previous ? { previous: action.previous } : {}), status: e.status }]
|
|
127
|
+
})
|
|
128
|
+
return {
|
|
129
|
+
txID: journal.txID,
|
|
130
|
+
planDigest: journal.planDigest,
|
|
131
|
+
outcome: OUTCOME[journal.state],
|
|
132
|
+
startedAt: journal.startedAt,
|
|
133
|
+
updatedAt: journal.updatedAt,
|
|
134
|
+
changes: journal.entries.map((e) => ({
|
|
135
|
+
seq: e.seq,
|
|
136
|
+
actionID: e.actionID,
|
|
137
|
+
kind: e.kind,
|
|
138
|
+
class: e.class,
|
|
139
|
+
...(e.path ? { path: e.path } : {}),
|
|
140
|
+
before: e.before,
|
|
141
|
+
...(e.after ? { after: e.after } : {}),
|
|
142
|
+
backup: e.backup !== undefined,
|
|
143
|
+
status: e.status,
|
|
144
|
+
})),
|
|
145
|
+
harnesses,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** This host's transactions, oldest first. */
|
|
150
|
+
export function rollbackHistory(journals: readonly unknown[], context: HistoryContext): Result<TransactionRecord[]> {
|
|
151
|
+
const checked = checkedJournals(journals, context)
|
|
152
|
+
if (!checked.ok) return checked
|
|
153
|
+
return ok(checked.value.journals.map((j) => record(j, checked.value.plans.get(j.planDigest))))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface InstalledHarness {
|
|
157
|
+
readonly version: string
|
|
158
|
+
readonly sha256: string
|
|
159
|
+
/** The transaction that installed it. */
|
|
160
|
+
readonly txID: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The pinned version hub last installed of each harness, from committed transactions only. */
|
|
164
|
+
export function installedHarnesses(history: readonly TransactionRecord[]): Partial<Record<HarnessID, InstalledHarness>> {
|
|
165
|
+
const installed: Partial<Record<HarnessID, InstalledHarness>> = {}
|
|
166
|
+
for (const tx of history) {
|
|
167
|
+
if (tx.outcome !== "committed") continue
|
|
168
|
+
for (const h of tx.harnesses) if (h.status === "done") installed[h.harness] = { version: h.version, sha256: h.sha256, txID: tx.txID }
|
|
169
|
+
}
|
|
170
|
+
return installed
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* v1.1: the digest of each service unit hub installed and still owns for `manager`, from committed transactions
|
|
175
|
+
* (newest wins; a later removal drops it). Profile downgrades remove a unit only while it is exactly this.
|
|
176
|
+
*/
|
|
177
|
+
export function installedServiceUnits(history: readonly TransactionRecord[], manager: ServiceManagerID): Partial<Record<ServiceID, string>> {
|
|
178
|
+
const byFile = new Map(Object.values(EMBEDDED_ASSETS).filter((a) => a.manager === manager).map((a) => [a.installAs, a.service]))
|
|
179
|
+
const root = manager === "launchd" ? "launch-agents" : "systemd-user"
|
|
180
|
+
const units: Partial<Record<ServiceID, string>> = {}
|
|
181
|
+
for (const tx of history) {
|
|
182
|
+
if (tx.outcome !== "committed") continue
|
|
183
|
+
for (const change of tx.changes) {
|
|
184
|
+
if (change.status !== "done" || change.path?.root !== root) continue
|
|
185
|
+
const service = byFile.get(change.path.rel)
|
|
186
|
+
if (!service) continue
|
|
187
|
+
if (change.kind === "install-service" && change.after?.exists && change.after.sha256) units[service] = change.after.sha256
|
|
188
|
+
if (change.kind === "remove-service") delete units[service]
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return units
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export interface RollbackStep {
|
|
195
|
+
readonly txID: string
|
|
196
|
+
readonly seq: number
|
|
197
|
+
readonly actionID: string
|
|
198
|
+
readonly class: EntryClass
|
|
199
|
+
readonly path?: PathRef
|
|
200
|
+
readonly decision: RecoveryDecision
|
|
201
|
+
/** For a harness restored from its backup: the version that comes back. */
|
|
202
|
+
readonly restores?: { readonly harness: HarnessID; readonly version: string; readonly sha256: string }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface RollbackPlan {
|
|
206
|
+
/** Newest transaction first; each transaction's entries newest first. */
|
|
207
|
+
readonly steps: readonly RollbackStep[]
|
|
208
|
+
/** Steps that keep a user edit (reported, never overwritten). */
|
|
209
|
+
readonly conflicts: readonly RollbackStep[]
|
|
210
|
+
/** True when nothing had to be kept: the target state is fully restored. */
|
|
211
|
+
readonly complete: boolean
|
|
212
|
+
/** Always zero: this is a plan. */
|
|
213
|
+
readonly writes: 0
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const pathKey = (p: PathRef) => `${p.root}:${p.rel}`
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Plans rolling this host back to just after `target` (a committed transaction), or to before every recorded
|
|
220
|
+
* transaction when `target` is undefined. `stateOf` reports each entry's file as it is now.
|
|
221
|
+
*/
|
|
222
|
+
export function rollbackTo(journals: readonly unknown[], target: string | undefined, stateOf: (entry: JournalEntry) => FileState, context: HistoryContext): Result<RollbackPlan> {
|
|
223
|
+
const checked = checkedJournals(journals, context)
|
|
224
|
+
if (!checked.ok) return checked
|
|
225
|
+
const ordered = checked.value.journals
|
|
226
|
+
let start = 0
|
|
227
|
+
if (target !== undefined) {
|
|
228
|
+
const at = ordered.findIndex((j) => j.txID === target)
|
|
229
|
+
if (at < 0) return fail("/target", "invalid_value", `no transaction ${target} on this host`)
|
|
230
|
+
if (ordered[at]!.state !== "committed") return fail("/target", "invalid_value", `${target} did not commit; roll back to a committed transaction`)
|
|
231
|
+
start = at + 1
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Simulated state per path; null: unpredictable (a credential was cleared).
|
|
235
|
+
const predicted = new Map<string, FileState | null>()
|
|
236
|
+
const steps: RollbackStep[] = []
|
|
237
|
+
for (const journal of ordered.slice(start).reverse()) {
|
|
238
|
+
if (journal.state === "rolled-back") continue
|
|
239
|
+
const plan = checked.value.plans.get(journal.planDigest)
|
|
240
|
+
for (const entry of [...journal.entries].reverse()) {
|
|
241
|
+
const key = entry.path ? pathKey(entry.path) : undefined
|
|
242
|
+
const known = key !== undefined && predicted.has(key) ? predicted.get(key)! : stateOf(entry)
|
|
243
|
+
const decision: RecoveryDecision = known === null && entry.class !== "external" ? "keep-user-edit" : recoveryDecision(entry, known ?? { exists: false })
|
|
244
|
+
if (key !== undefined) {
|
|
245
|
+
if (decision === "restore-backup") predicted.set(key, entry.before)
|
|
246
|
+
else if (decision === "remove-created") predicted.set(key, { exists: false })
|
|
247
|
+
else if (decision === "clear-credential") predicted.set(key, null)
|
|
248
|
+
}
|
|
249
|
+
const action = decision === "restore-backup" ? restoringAction(plan, entry) : undefined
|
|
250
|
+
steps.push({
|
|
251
|
+
txID: journal.txID,
|
|
252
|
+
seq: entry.seq,
|
|
253
|
+
actionID: entry.actionID,
|
|
254
|
+
class: entry.class,
|
|
255
|
+
...(entry.path ? { path: entry.path } : {}),
|
|
256
|
+
decision,
|
|
257
|
+
...(action?.previous ? { restores: { harness: action.harness, ...action.previous } } : {}),
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const conflicts = steps.filter((s) => s.decision === "keep-user-edit")
|
|
262
|
+
return ok({ steps, conflicts, complete: conflicts.length === 0, writes: 0 })
|
|
263
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Bootstrap profiles: versioned profile documents, the built-in catalog, profile → desired state, an audit-only
|
|
2
|
+
// verification mode, managed-field drift, rollback history and upgrade/downgrade planning. Everything here is a
|
|
3
|
+
// pure function over the contract documents (packages/cli/src/bootstrap/contracts): snapshots, plans, journals and
|
|
4
|
+
// verified manifests in, reports and typed actions out. Nothing here reads or writes the machine.
|
|
5
|
+
export * from "./versions.ts"
|
|
6
|
+
export * from "./catalog.ts"
|
|
7
|
+
export * from "./desired.ts"
|
|
8
|
+
export * from "./audit.ts"
|
|
9
|
+
export * from "./drift.ts"
|
|
10
|
+
export * from "./history.ts"
|
|
11
|
+
export * from "./upgrade.ts"
|