@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.
Files changed (355) hide show
  1. package/CHANGELOG.md +870 -0
  2. package/Dockerfile +33 -0
  3. package/README.md +1007 -66
  4. package/package.json +47 -2
  5. package/packages/claude-code/package.json +12 -0
  6. package/packages/claude-code/src/bridge.ts +79 -0
  7. package/packages/claude-code/src/daemon.ts +339 -0
  8. package/packages/claude-code/src/hook-input.ts +44 -0
  9. package/packages/claude-code/src/ids.ts +27 -0
  10. package/packages/claude-code/src/index.ts +18 -0
  11. package/packages/claude-code/src/launcher.ts +265 -0
  12. package/packages/claude-code/src/main.ts +127 -0
  13. package/packages/claude-code/src/mcp.ts +109 -0
  14. package/packages/claude-code/src/paths.ts +34 -0
  15. package/packages/claude-code/src/plugin.ts +99 -0
  16. package/packages/claude-code/src/references.ts +51 -0
  17. package/packages/claude-code/src/sessions.ts +600 -0
  18. package/packages/claude-code/src/tools.ts +66 -0
  19. package/packages/claude-code/src/wire.ts +71 -0
  20. package/packages/claude-code/src/wrapper.ts +156 -0
  21. package/packages/cli/dist/hub-cli.js +21647 -0
  22. package/packages/cli/package.json +11 -0
  23. package/packages/cli/src/acting.ts +70 -0
  24. package/packages/cli/src/args.ts +91 -0
  25. package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
  26. package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
  27. package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
  28. package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
  29. package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
  30. package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
  31. package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
  32. package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
  33. package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
  34. package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
  35. package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
  36. package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
  37. package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
  38. package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
  39. package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
  40. package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
  41. package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
  42. package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
  43. package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
  44. package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
  45. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
  46. package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
  47. package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
  48. package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
  49. package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
  50. package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
  51. package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
  52. package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
  53. package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
  54. package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
  55. package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
  56. package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
  57. package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
  58. package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
  59. package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
  60. package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
  61. package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
  62. package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
  63. package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
  64. package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
  65. package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
  66. package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
  67. package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
  68. package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
  69. package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
  70. package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
  71. package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
  72. package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
  73. package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
  74. package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
  75. package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
  76. package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
  77. package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
  78. package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
  79. package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
  80. package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
  81. package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
  82. package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
  83. package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
  84. package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
  85. package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
  86. package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
  87. package/packages/cli/src/bootstrap/preview/index.ts +5 -0
  88. package/packages/cli/src/bootstrap/preview/links.ts +31 -0
  89. package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
  90. package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
  91. package/packages/cli/src/bootstrap/preview/render.ts +30 -0
  92. package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
  93. package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
  94. package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
  95. package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
  96. package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
  97. package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
  98. package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
  99. package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
  100. package/packages/cli/src/bootstrap/release/README.md +115 -0
  101. package/packages/cli/src/bootstrap/release/assets.ts +39 -0
  102. package/packages/cli/src/bootstrap/release/cli.ts +332 -0
  103. package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
  104. package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
  105. package/packages/cli/src/bootstrap/release/pins.ts +192 -0
  106. package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
  107. package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
  108. package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
  109. package/packages/cli/src/bootstrap/release/verify.ts +88 -0
  110. package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
  111. package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
  112. package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
  113. package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
  114. package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
  115. package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
  116. package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
  117. package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
  118. package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
  119. package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
  120. package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
  121. package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
  122. package/packages/cli/src/cli.ts +814 -0
  123. package/packages/cli/src/credentials.ts +75 -0
  124. package/packages/cli/src/decisions.ts +106 -0
  125. package/packages/cli/src/format.ts +185 -0
  126. package/packages/cli/src/grants.ts +105 -0
  127. package/packages/cli/src/http.ts +86 -0
  128. package/packages/cli/src/launches.ts +157 -0
  129. package/packages/cli/src/login.ts +160 -0
  130. package/packages/cli/src/main.ts +63 -0
  131. package/packages/cli/src/mcp.ts +879 -0
  132. package/packages/cli/src/messaging.ts +108 -0
  133. package/packages/cli/src/node.ts +279 -0
  134. package/packages/cli/src/openshell.ts +166 -0
  135. package/packages/cli/src/setup/commands.ts +809 -0
  136. package/packages/cli/src/setup/contributors.ts +171 -0
  137. package/packages/cli/src/setup/dispatch.ts +74 -0
  138. package/packages/cli/src/setup/launch-driver.ts +109 -0
  139. package/packages/cli/src/setup/runtime.ts +456 -0
  140. package/packages/cli/src/setup/spoke-probe.ts +120 -0
  141. package/packages/cli/src/setup/state.ts +244 -0
  142. package/packages/cli/src/setup/user-bin.ts +39 -0
  143. package/packages/cli/src/spoke-services.ts +109 -0
  144. package/packages/cli/src/watch.ts +28 -0
  145. package/packages/codex/package.json +12 -0
  146. package/packages/codex/src/app-server.ts +44 -0
  147. package/packages/codex/src/ids.ts +28 -0
  148. package/packages/codex/src/index.ts +11 -0
  149. package/packages/codex/src/jsonrpc.ts +168 -0
  150. package/packages/codex/src/main.ts +123 -0
  151. package/packages/codex/src/paths.ts +65 -0
  152. package/packages/codex/src/sessions.ts +578 -0
  153. package/packages/codex/src/spoke.ts +252 -0
  154. package/packages/codex/src/stdio.ts +101 -0
  155. package/packages/codex/src/websocket.ts +206 -0
  156. package/packages/codex/src/wrapper.ts +104 -0
  157. package/packages/node/package.json +12 -0
  158. package/packages/node/src/config.ts +184 -0
  159. package/packages/node/src/connection.ts +283 -0
  160. package/packages/node/src/daemon.ts +281 -0
  161. package/packages/node/src/git.ts +107 -0
  162. package/packages/node/src/harnesses.ts +95 -0
  163. package/packages/node/src/host.ts +319 -0
  164. package/packages/node/src/index.ts +17 -0
  165. package/packages/node/src/main.ts +11 -0
  166. package/packages/node/src/openshell.ts +73 -0
  167. package/packages/node/src/run.ts +106 -0
  168. package/packages/preset/package.json +12 -0
  169. package/packages/preset/presets/aider.json +8 -0
  170. package/packages/preset/presets/goose.json +8 -0
  171. package/packages/preset/src/index.ts +2 -0
  172. package/packages/preset/src/main.ts +195 -0
  173. package/packages/preset/src/presets.ts +87 -0
  174. package/packages/preset/src/spoke.ts +251 -0
  175. package/packages/preset/src/tmux.ts +160 -0
  176. package/packages/protocol/package.json +12 -0
  177. package/packages/protocol/src/harness-events.ts +207 -0
  178. package/packages/protocol/src/index.ts +1215 -0
  179. package/packages/protocol/src/launch.ts +172 -0
  180. package/packages/protocol/src/node.ts +409 -0
  181. package/packages/protocol/src/preset.ts +144 -0
  182. package/packages/protocol/src/webhook.ts +62 -0
  183. package/packages/protocol/src/wire.ts +54 -0
  184. package/packages/runtime-openshell/image/Dockerfile +36 -0
  185. package/packages/runtime-openshell/image/README.md +78 -0
  186. package/packages/runtime-openshell/image/build.ts +203 -0
  187. package/packages/runtime-openshell/image/entry.sh +163 -0
  188. package/packages/runtime-openshell/image/image.ts +119 -0
  189. package/packages/runtime-openshell/image/start.sh +24 -0
  190. package/packages/runtime-openshell/package.json +12 -0
  191. package/packages/runtime-openshell/src/cli.ts +584 -0
  192. package/packages/runtime-openshell/src/index.ts +6 -0
  193. package/packages/runtime-openshell/src/policy.ts +155 -0
  194. package/packages/runtime-openshell/src/profiles.ts +181 -0
  195. package/packages/runtime-openshell/src/runtime.ts +475 -0
  196. package/packages/runtime-sdk/package.json +12 -0
  197. package/packages/runtime-sdk/src/index.ts +87 -0
  198. package/packages/sdk/package.json +12 -0
  199. package/packages/sdk/src/address.ts +25 -0
  200. package/packages/sdk/src/client.ts +553 -0
  201. package/packages/sdk/src/envelope.ts +108 -0
  202. package/packages/sdk/src/errors.ts +107 -0
  203. package/packages/sdk/src/index.ts +10 -0
  204. package/packages/sdk/src/sse.ts +70 -0
  205. package/packages/sdk/src/types.ts +517 -0
  206. package/packages/sdk/src/webhook.ts +102 -0
  207. package/packages/spoke-sdk/package.json +12 -0
  208. package/packages/spoke-sdk/src/activity.ts +3 -0
  209. package/packages/spoke-sdk/src/connection.ts +295 -0
  210. package/packages/spoke-sdk/src/delivery.ts +119 -0
  211. package/packages/spoke-sdk/src/enroll.ts +101 -0
  212. package/packages/spoke-sdk/src/harness-hello.ts +79 -0
  213. package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
  214. package/packages/spoke-sdk/src/index.ts +12 -0
  215. package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
  216. package/packages/spoke-sdk/src/prompt-source.ts +8 -0
  217. package/packages/spoke-sdk/src/session-ids.ts +49 -0
  218. package/packages/spoke-sdk/src/session-tokens.ts +78 -0
  219. package/scripts/hub-claude-code.service +34 -0
  220. package/scripts/hub-codex-spoke.service +34 -0
  221. package/scripts/hub-node.service +35 -0
  222. package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
  223. package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
  224. package/scripts/io.github.huynle.hub-node.plist +52 -0
  225. package/src/central/actions.ts +233 -0
  226. package/src/central/agent/acting-routes.ts +133 -0
  227. package/src/central/agent/acting-switch.ts +93 -0
  228. package/src/central/agent/acting.ts +599 -0
  229. package/src/central/agent/actor.ts +193 -0
  230. package/src/central/agent/app-tokens.ts +152 -0
  231. package/src/central/agent/archive-inactive.ts +66 -0
  232. package/src/central/agent/audit.ts +185 -0
  233. package/src/central/agent/decisions.ts +530 -0
  234. package/src/central/agent/delivery.ts +678 -0
  235. package/src/central/agent/directory-routes.ts +328 -0
  236. package/src/central/agent/directory.ts +473 -0
  237. package/src/central/agent/envelope.ts +51 -0
  238. package/src/central/agent/errors.ts +74 -0
  239. package/src/central/agent/escalation.ts +219 -0
  240. package/src/central/agent/event-cursor.ts +49 -0
  241. package/src/central/agent/event-writers.ts +181 -0
  242. package/src/central/agent/events-routes.ts +148 -0
  243. package/src/central/agent/events.ts +277 -0
  244. package/src/central/agent/grant-requests.ts +221 -0
  245. package/src/central/agent/grant-routes.ts +70 -0
  246. package/src/central/agent/grants.ts +235 -0
  247. package/src/central/agent/launch-engine.ts +1025 -0
  248. package/src/central/agent/launch-routes.ts +252 -0
  249. package/src/central/agent/launch-spec.ts +289 -0
  250. package/src/central/agent/launch-store.ts +264 -0
  251. package/src/central/agent/launch-tokens.ts +206 -0
  252. package/src/central/agent/message-limits.ts +123 -0
  253. package/src/central/agent/message-routes.ts +385 -0
  254. package/src/central/agent/messages.ts +442 -0
  255. package/src/central/agent/names.ts +86 -0
  256. package/src/central/agent/placement.ts +419 -0
  257. package/src/central/agent/policy.ts +378 -0
  258. package/src/central/agent/read-routes.ts +385 -0
  259. package/src/central/agent/routes.ts +281 -0
  260. package/src/central/agent/session-tokens.ts +227 -0
  261. package/src/central/agent/versioned.ts +26 -0
  262. package/src/central/agent/watch.ts +112 -0
  263. package/src/central/agent/webhook-routes.ts +86 -0
  264. package/src/central/agent/webhooks.ts +493 -0
  265. package/src/central/agent-data.ts +177 -0
  266. package/src/central/auth.ts +350 -121
  267. package/src/central/backend/file-state.ts +240 -0
  268. package/src/central/backend/index.ts +116 -0
  269. package/src/central/backend/memory-bus.ts +93 -0
  270. package/src/central/backend/memory-catalog.ts +90 -0
  271. package/src/central/backend/memory-counters.ts +102 -0
  272. package/src/central/backend/memory-events.ts +45 -0
  273. package/src/central/backend/memory-kv.ts +38 -0
  274. package/src/central/backend/memory-state.ts +77 -0
  275. package/src/central/backend/postgres-migrate.ts +100 -0
  276. package/src/central/backend/postgres-state.ts +197 -0
  277. package/src/central/backend/types.ts +246 -0
  278. package/src/central/backend/valkey-bus.ts +425 -0
  279. package/src/central/backend/valkey-catalog.ts +190 -0
  280. package/src/central/backend/valkey-client.ts +36 -0
  281. package/src/central/backend/valkey-counters.ts +38 -0
  282. package/src/central/backend/valkey-events.ts +168 -0
  283. package/src/central/backend/valkey-kv.ts +54 -0
  284. package/src/central/backend/valkey-locks.ts +92 -0
  285. package/src/central/cluster/directory.ts +141 -0
  286. package/src/central/cluster/feed.ts +84 -0
  287. package/src/central/cluster/leader.ts +232 -0
  288. package/src/central/cluster/live.ts +151 -0
  289. package/src/central/cluster/recommend.ts +59 -0
  290. package/src/central/cluster/rpc.ts +150 -0
  291. package/src/central/cluster/view.ts +232 -0
  292. package/src/central/config.ts +197 -3
  293. package/src/central/done.ts +96 -0
  294. package/src/central/events-log.ts +100 -0
  295. package/src/central/http.ts +1149 -170
  296. package/src/central/import.ts +318 -0
  297. package/src/central/main.ts +211 -12
  298. package/src/central/mcp-http.ts +116 -0
  299. package/src/central/nodes.ts +314 -0
  300. package/src/central/oidc.ts +34 -14
  301. package/src/central/push-target.ts +118 -18
  302. package/src/central/push.ts +256 -82
  303. package/src/central/registry.ts +902 -92
  304. package/src/central/say-stream.ts +128 -0
  305. package/src/central/session-token-push.ts +233 -0
  306. package/src/central/speech/openai.ts +21 -14
  307. package/src/central/speech/openrouter.ts +27 -10
  308. package/src/central/speech/provider.ts +54 -0
  309. package/src/central/speech/service.ts +22 -7
  310. package/src/central/spoke-ws.ts +522 -44
  311. package/src/central/store-shared.ts +518 -0
  312. package/src/central/store.ts +625 -189
  313. package/src/central/token-keys.ts +165 -0
  314. package/src/config.ts +118 -7
  315. package/src/index.ts +195 -43
  316. package/src/protocol.ts +3 -650
  317. package/src/rpc.ts +27 -2
  318. package/src/spoke/client.ts +3 -272
  319. package/src/spoke/conversation.ts +16 -12
  320. package/src/spoke/deliver.ts +75 -0
  321. package/src/spoke/enroll.ts +2 -79
  322. package/src/spoke/env-inject.ts +160 -0
  323. package/src/spoke/hub-rpc.ts +115 -0
  324. package/src/spoke/hub-tool.ts +409 -0
  325. package/src/spoke/launch-source.ts +22 -0
  326. package/src/spoke/launchers.ts +174 -0
  327. package/src/spoke/locations.ts +3 -2
  328. package/src/spoke/node-roots.ts +61 -0
  329. package/src/spoke/prompt-refs.ts +116 -0
  330. package/src/spoke/session-tokens.ts +2 -0
  331. package/src/spoke/spoke.ts +465 -28
  332. package/src/spoke/taskboard.ts +243 -0
  333. package/src/spoke/timeline.ts +184 -6
  334. package/src/spoke/tracker.ts +79 -4
  335. package/src/status.ts +203 -10
  336. package/src/tui-commands.ts +13 -6
  337. package/src/tui-messaging.ts +93 -0
  338. package/src/tui.ts +15 -0
  339. package/web/dist/apple-touch-icon.png +0 -0
  340. package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
  341. package/web/dist/assets/index-DWnMhXk9.js +20 -0
  342. package/web/dist/assets/index-DmUXEnw9.css +1 -0
  343. package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
  344. package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
  345. package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
  346. package/web/dist/favicon.ico +0 -0
  347. package/web/dist/icon-192.png +0 -0
  348. package/web/dist/icon-512.png +0 -0
  349. package/web/dist/icon-maskable-512.png +0 -0
  350. package/web/dist/icon.svg +5 -5
  351. package/web/dist/index.html +17 -4
  352. package/web/dist/manifest.webmanifest +2 -2
  353. package/web/dist/sw.js +79 -15
  354. package/web/dist/assets/index-DClBvfLL.css +0 -1
  355. 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"