@phyzess/pappra 0.1.0-beta.14 → 0.1.0-beta.19

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 (365) hide show
  1. package/README.md +133 -14
  2. package/assets/integrations/skills/pappra/SKILL.md +56 -7
  3. package/assets/integrations/skills/pappra/references/collaboration.md +646 -0
  4. package/assets/integrations/skills/pappra/references/environment.md +367 -0
  5. package/assets/integrations/skills/pappra/references/hosts.md +20 -0
  6. package/assets/integrations/skills/pappra/references/operations.md +36 -5
  7. package/dist/adapters/pi/recovery-extension.d.ts +65 -0
  8. package/dist/adapters/pi/recovery-extension.js +238 -0
  9. package/dist/adapters/pi/session-transition-extension.d.ts +30 -0
  10. package/dist/adapters/pi/session-transition-extension.js +265 -0
  11. package/dist/app/automatic-handoff-events.d.ts +26 -0
  12. package/dist/app/automatic-handoff-events.js +134 -0
  13. package/dist/app/bounded-file.d.ts +2 -0
  14. package/dist/app/bounded-file.js +2 -0
  15. package/dist/app/capability-evidence/claude.d.ts +37 -0
  16. package/dist/app/capability-evidence/claude.js +206 -0
  17. package/dist/app/capability-evidence/codex.d.ts +35 -0
  18. package/dist/app/capability-evidence/codex.js +193 -0
  19. package/dist/app/capability-evidence/index.d.ts +33 -0
  20. package/dist/app/capability-evidence/index.js +117 -0
  21. package/dist/app/capability-evidence/pi.d.ts +73 -0
  22. package/dist/app/capability-evidence/pi.js +669 -0
  23. package/dist/app/capability-evidence/shared.d.ts +69 -0
  24. package/dist/app/capability-evidence/shared.js +195 -0
  25. package/dist/app/collab-auto-handoff.d.ts +55 -0
  26. package/dist/app/collab-auto-handoff.js +384 -0
  27. package/dist/app/collab-bindings.d.ts +57 -0
  28. package/dist/app/collab-bindings.js +1006 -0
  29. package/dist/app/collab-bootstrap-transport.d.ts +163 -0
  30. package/dist/app/collab-bootstrap-transport.js +769 -0
  31. package/dist/app/collab-bootstrap.d.ts +137 -0
  32. package/dist/app/collab-bootstrap.js +3455 -0
  33. package/dist/app/collab-handoff.d.ts +12 -0
  34. package/dist/app/collab-handoff.js +511 -0
  35. package/dist/app/collab-messages.d.ts +2 -0
  36. package/dist/app/collab-messages.js +2 -0
  37. package/dist/app/collab-notify.d.ts +156 -0
  38. package/dist/app/collab-notify.js +1208 -0
  39. package/dist/app/collab-preflight.d.ts +34 -0
  40. package/dist/app/collab-preflight.js +96 -0
  41. package/dist/app/collab-recovery-executor.d.ts +161 -0
  42. package/dist/app/collab-recovery-executor.js +1153 -0
  43. package/dist/app/collab-recovery-projection.d.ts +103 -0
  44. package/dist/app/collab-recovery-projection.js +659 -0
  45. package/dist/app/collab-recovery.d.ts +25 -0
  46. package/dist/app/collab-recovery.js +429 -0
  47. package/dist/app/collab-resources.d.ts +16 -0
  48. package/dist/app/collab-resources.js +278 -0
  49. package/dist/app/collab-role-events.d.ts +22 -0
  50. package/dist/app/collab-role-events.js +329 -0
  51. package/dist/app/collab-role-orchestration.d.ts +53 -0
  52. package/dist/app/collab-role-orchestration.js +601 -0
  53. package/dist/app/collab-role-plan.d.ts +81 -0
  54. package/dist/app/collab-role-plan.js +504 -0
  55. package/dist/app/collab-role-repair.d.ts +109 -0
  56. package/dist/app/collab-role-repair.js +821 -0
  57. package/dist/app/collab-rollover.d.ts +263 -0
  58. package/dist/app/collab-rollover.js +1112 -0
  59. package/dist/app/collab-route.d.ts +64 -0
  60. package/dist/app/collab-route.js +240 -0
  61. package/dist/app/collab-session-transition-request.d.ts +50 -0
  62. package/dist/app/collab-session-transition-request.js +339 -0
  63. package/dist/app/collab-session-transition-validation.d.ts +19 -0
  64. package/dist/app/collab-session-transition-validation.js +44 -0
  65. package/dist/app/collab-session-transition.d.ts +181 -0
  66. package/dist/app/collab-session-transition.js +2970 -0
  67. package/dist/app/collab-state.d.ts +7 -0
  68. package/dist/app/collab-state.js +162 -0
  69. package/dist/app/collab-task-store.d.ts +22 -0
  70. package/dist/app/collab-task-store.js +204 -0
  71. package/dist/app/collab-work-execution.d.ts +127 -0
  72. package/dist/app/collab-work-execution.js +1027 -0
  73. package/dist/app/collab-work-plan.d.ts +87 -0
  74. package/dist/app/collab-work-plan.js +437 -0
  75. package/dist/app/collaboration.d.ts +8 -2
  76. package/dist/app/collaboration.js +101 -12
  77. package/dist/app/doctor.js +104 -3
  78. package/dist/app/host-mcp.js +34 -8
  79. package/dist/app/host-probe.js +4 -4
  80. package/dist/app/init.d.ts +58 -4
  81. package/dist/app/init.js +768 -155
  82. package/dist/app/managed-pi-bootstrap-admission.d.ts +14 -0
  83. package/dist/app/managed-pi-bootstrap-admission.js +104 -0
  84. package/dist/app/managed-pi-bootstrap-refresh.d.ts +10 -0
  85. package/dist/app/managed-pi-bootstrap-refresh.js +78 -0
  86. package/dist/app/managed-pi-delivery.d.ts +8 -0
  87. package/dist/app/managed-pi-delivery.js +110 -0
  88. package/dist/app/managed-pi-handoff-receiver.d.ts +15 -0
  89. package/dist/app/managed-pi-handoff-receiver.js +263 -0
  90. package/dist/app/managed-pi-handoff-reference.d.ts +3 -0
  91. package/dist/app/managed-pi-handoff-reference.js +60 -0
  92. package/dist/app/managed-pi-herdr.d.ts +21 -0
  93. package/dist/app/managed-pi-herdr.js +185 -0
  94. package/dist/app/managed-pi-initial-admission.d.ts +28 -0
  95. package/dist/app/managed-pi-initial-admission.js +416 -0
  96. package/dist/app/managed-pi-initial-launch.d.ts +5 -0
  97. package/dist/app/managed-pi-initial-launch.js +61 -0
  98. package/dist/app/managed-pi-native-observation.d.ts +36 -0
  99. package/dist/app/managed-pi-native-observation.js +191 -0
  100. package/dist/app/managed-pi-readiness.d.ts +29 -0
  101. package/dist/app/managed-pi-readiness.js +288 -0
  102. package/dist/app/managed-pi-repair-stop.d.ts +21 -0
  103. package/dist/app/managed-pi-repair-stop.js +371 -0
  104. package/dist/app/managed-pi-resume-admission.d.ts +32 -0
  105. package/dist/app/managed-pi-resume-admission.js +618 -0
  106. package/dist/app/managed-pi-retirement.d.ts +28 -0
  107. package/dist/app/managed-pi-retirement.js +112 -0
  108. package/dist/app/managed-pi-sdk-control.d.ts +7 -0
  109. package/dist/app/managed-pi-sdk-control.js +169 -0
  110. package/dist/app/managed-pi-sdk-runtime.d.ts +3 -0
  111. package/dist/app/managed-pi-sdk-runtime.js +483 -0
  112. package/dist/app/managed-pi-source-admission.d.ts +14 -0
  113. package/dist/app/managed-pi-source-admission.js +323 -0
  114. package/dist/app/managed-pi-transition-host.d.ts +36 -0
  115. package/dist/app/managed-pi-transition-host.js +292 -0
  116. package/dist/app/managed-pi.d.ts +54 -0
  117. package/dist/app/managed-pi.js +248 -0
  118. package/dist/app/shell-readiness.d.ts +66 -0
  119. package/dist/app/shell-readiness.js +368 -0
  120. package/dist/app/task-pin.d.ts +2 -23
  121. package/dist/app/task-pin.js +2 -113
  122. package/dist/cli/collaboration-router.d.ts +17 -0
  123. package/dist/cli/collaboration-router.js +604 -0
  124. package/dist/cli/commands/collaboration-binding.d.ts +13 -0
  125. package/dist/cli/commands/collaboration-binding.js +118 -0
  126. package/dist/cli/commands/collaboration-bootstrap.d.ts +10 -0
  127. package/dist/cli/commands/collaboration-bootstrap.js +84 -0
  128. package/dist/cli/commands/collaboration-capabilities.d.ts +13 -0
  129. package/dist/cli/commands/collaboration-capabilities.js +127 -0
  130. package/dist/cli/commands/collaboration-handoff.d.ts +13 -0
  131. package/dist/cli/commands/collaboration-handoff.js +102 -0
  132. package/dist/cli/commands/collaboration-message.d.ts +17 -0
  133. package/dist/cli/commands/collaboration-message.js +177 -0
  134. package/dist/cli/commands/collaboration-plan.d.ts +8 -0
  135. package/dist/cli/commands/collaboration-plan.js +108 -0
  136. package/dist/cli/commands/collaboration-preflight.d.ts +10 -0
  137. package/dist/cli/commands/collaboration-preflight.js +167 -0
  138. package/dist/cli/commands/collaboration-recovery-run.d.ts +46 -0
  139. package/dist/cli/commands/collaboration-recovery-run.js +224 -0
  140. package/dist/cli/commands/collaboration-recovery.d.ts +11 -0
  141. package/dist/cli/commands/collaboration-recovery.js +104 -0
  142. package/dist/cli/commands/collaboration-resources.d.ts +11 -0
  143. package/dist/cli/commands/collaboration-resources.js +96 -0
  144. package/dist/cli/commands/collaboration-role-plan.d.ts +9 -0
  145. package/dist/cli/commands/collaboration-role-plan.js +69 -0
  146. package/dist/cli/commands/collaboration-role-run.d.ts +11 -0
  147. package/dist/cli/commands/collaboration-role-run.js +128 -0
  148. package/dist/cli/commands/collaboration-rollover.d.ts +10 -0
  149. package/dist/cli/commands/collaboration-rollover.js +103 -0
  150. package/dist/cli/commands/collaboration-state.d.ts +10 -0
  151. package/dist/cli/commands/collaboration-state.js +96 -0
  152. package/dist/cli/commands/collaboration-transition.d.ts +10 -0
  153. package/dist/cli/commands/collaboration-transition.js +75 -0
  154. package/dist/cli/commands/collaboration-work-run.d.ts +34 -0
  155. package/dist/cli/commands/collaboration-work-run.js +178 -0
  156. package/dist/cli/commands/collaboration.d.ts +4 -0
  157. package/dist/cli/commands/collaboration.js +39 -4
  158. package/dist/cli/commands/continuity.js +2 -6
  159. package/dist/cli/commands/docs.d.ts +2 -0
  160. package/dist/cli/commands/docs.js +89 -15
  161. package/dist/cli/commands/host.d.ts +7 -1
  162. package/dist/cli/commands/host.js +122 -7
  163. package/dist/cli/commands/init.d.ts +20 -3
  164. package/dist/cli/commands/init.js +897 -232
  165. package/dist/cli/commands/mcp.d.ts +1 -0
  166. package/dist/cli/commands/mcp.js +11 -5
  167. package/dist/cli/commands/repair.d.ts +12 -0
  168. package/dist/cli/commands/repair.js +41 -0
  169. package/dist/cli/commands/runtime.d.ts +1 -1
  170. package/dist/cli/commands/runtime.js +139 -38
  171. package/dist/cli/commands/session.d.ts +7 -0
  172. package/dist/cli/commands/session.js +82 -0
  173. package/dist/cli/commands/task.js +19 -5
  174. package/dist/cli/options.d.ts +806 -0
  175. package/dist/cli/options.js +293 -0
  176. package/dist/cli/run.js +280 -310
  177. package/dist/code/identity.d.ts +7 -0
  178. package/dist/code/identity.js +7 -3
  179. package/dist/code/registration.js +7 -0
  180. package/dist/code/service.d.ts +9 -0
  181. package/dist/code/service.js +16 -3
  182. package/dist/code/text.js +19 -7
  183. package/dist/collab/admission.d.ts +91 -66
  184. package/dist/collab/admission.js +21 -6
  185. package/dist/collab/canonical.d.ts +1 -1
  186. package/dist/collab/canonical.js +44 -3
  187. package/dist/collab/envelope.d.ts +2 -2
  188. package/dist/collab/envelope.js +12 -12
  189. package/dist/collab/first-work.d.ts +3 -0
  190. package/dist/collab/first-work.js +15 -0
  191. package/dist/collab/handoff.d.ts +16 -0
  192. package/dist/collab/handoff.js +39 -5
  193. package/dist/collab/index.d.ts +97 -1
  194. package/dist/collab/index.js +9 -1
  195. package/dist/collab/messages.js +126 -3
  196. package/dist/collab/persisted-messages.d.ts +123 -0
  197. package/dist/collab/persisted-messages.js +496 -0
  198. package/dist/collab/preflight.d.ts +1 -1
  199. package/dist/collab/resources.d.ts +13 -3
  200. package/dist/collab/resources.js +37 -6
  201. package/dist/collab/rollover.d.ts +311 -0
  202. package/dist/collab/rollover.js +1024 -0
  203. package/dist/collab/store.d.ts +1 -0
  204. package/dist/collab/store.js +1 -4
  205. package/dist/collab/types.d.ts +2 -354
  206. package/dist/collab/validation.d.ts +2 -10
  207. package/dist/collab/validation.js +2 -303
  208. package/dist/collab/work-plan.d.ts +105 -0
  209. package/dist/collab/work-plan.js +843 -0
  210. package/dist/continuity/context.d.ts +11 -2
  211. package/dist/continuity/context.js +100 -13
  212. package/dist/continuity/handoff-bundle.d.ts +296 -0
  213. package/dist/continuity/handoff-bundle.js +477 -0
  214. package/dist/continuity/index.d.ts +2 -0
  215. package/dist/continuity/index.js +1 -0
  216. package/dist/continuity/paths.js +10 -2
  217. package/dist/continuity/service.js +91 -15
  218. package/dist/continuity/task-pin.d.ts +24 -0
  219. package/dist/continuity/task-pin.js +590 -0
  220. package/dist/doctor.d.ts +1 -0
  221. package/dist/doctor.js +6 -0
  222. package/dist/experimental/index.d.ts +13 -0
  223. package/dist/experimental/index.js +25 -0
  224. package/dist/experimental/pi.d.ts +4 -0
  225. package/dist/experimental/pi.js +12 -0
  226. package/dist/hosts/claude/native.js +1 -1
  227. package/dist/hosts/codex/native.js +1 -1
  228. package/dist/hosts/codex/protocol.js +1 -1
  229. package/dist/hosts/integration/bootstrap-config.d.ts +58 -0
  230. package/dist/hosts/integration/bootstrap-config.js +254 -0
  231. package/dist/hosts/integration/bundle-export.d.ts +44 -0
  232. package/dist/hosts/integration/bundle-export.js +366 -3
  233. package/dist/hosts/integration/bundle.js +3 -0
  234. package/dist/hosts/integration/hook.js +36 -9
  235. package/dist/hosts/integration/pappra-bootstrap-extension.mjs +80 -0
  236. package/dist/hosts/integration/pappra-extension-runtime.mjs +59 -24631
  237. package/dist/hosts/integration/pappra-hook.mjs +61 -24749
  238. package/dist/hosts/integration/pappra-recovery-extension.mjs +259 -0
  239. package/dist/hosts/integration/pappra-session-transition-extension.mjs +276 -0
  240. package/dist/hosts/integration/pi-bootstrap-extension.d.ts +36 -0
  241. package/dist/hosts/integration/pi-bootstrap-extension.js +101 -0
  242. package/dist/hosts/integration/pi-bootstrap-guard.d.ts +125 -0
  243. package/dist/hosts/integration/pi-bootstrap-guard.js +438 -0
  244. package/dist/hosts/integration/pi-bootstrap-runtime.d.ts +63 -0
  245. package/dist/hosts/integration/pi-bootstrap-runtime.js +223 -0
  246. package/dist/hosts/integration/pi-native-events.d.ts +112 -0
  247. package/dist/hosts/integration/pi-native-events.js +348 -0
  248. package/dist/hosts/integration/pi-rollover-extension.d.ts +166 -0
  249. package/dist/hosts/integration/pi-rollover-extension.js +431 -0
  250. package/dist/hosts/integration/recovery-config.d.ts +48 -0
  251. package/dist/hosts/integration/recovery-config.js +117 -0
  252. package/dist/hosts/integration/service.d.ts +6 -1
  253. package/dist/hosts/integration/service.js +485 -35
  254. package/dist/hosts/integration/support.d.ts +7 -0
  255. package/dist/hosts/integration/support.js +9 -1
  256. package/dist/hosts/integration/templates.d.ts +3 -0
  257. package/dist/hosts/integration/templates.js +26 -3
  258. package/dist/hosts/pi/managed-host-identity.d.ts +2 -0
  259. package/dist/hosts/pi/managed-host-identity.js +40 -0
  260. package/dist/hosts/pi/managed-provider-diagnostics.d.ts +6 -0
  261. package/dist/hosts/pi/managed-provider-diagnostics.js +184 -0
  262. package/dist/hosts/pi/managed-sdk-session.d.ts +335 -0
  263. package/dist/hosts/pi/managed-sdk-session.js +1639 -0
  264. package/dist/hosts/pi/managed-session-extension.d.ts +20 -0
  265. package/dist/hosts/pi/managed-session-extension.js +84 -0
  266. package/dist/hosts/pi/managed-session.d.ts +177 -0
  267. package/dist/hosts/pi/managed-session.js +1557 -0
  268. package/dist/hosts/pi/protocol.js +1 -1
  269. package/dist/hosts/pi/query-result.js +1 -1
  270. package/dist/hosts/pi/resume-capability.d.ts +41 -0
  271. package/dist/hosts/pi/resume-capability.js +33 -0
  272. package/dist/hosts/pi/rpc.d.ts +182 -134
  273. package/dist/hosts/pi/rpc.js +230 -37
  274. package/dist/hosts/pilot.d.ts +90 -67
  275. package/dist/hosts/pilot.js +5 -1
  276. package/dist/hosts/version.d.ts +3 -0
  277. package/dist/hosts/version.js +18 -0
  278. package/dist/index.d.ts +0 -1
  279. package/dist/index.js +0 -1
  280. package/dist/infra/bounded-file.d.ts +19 -0
  281. package/dist/infra/bounded-file.js +66 -0
  282. package/dist/infra/fs.js +10 -2
  283. package/dist/infra/git.d.ts +29 -0
  284. package/dist/infra/git.js +45 -9
  285. package/dist/infra/json-property.d.ts +2 -0
  286. package/dist/infra/json-property.js +69 -0
  287. package/dist/infra/json.d.ts +4 -0
  288. package/dist/infra/json.js +24 -0
  289. package/dist/infra/owned-process-group.js +1 -1
  290. package/dist/infra/private-lock.d.ts +16 -0
  291. package/dist/infra/private-lock.js +148 -0
  292. package/dist/infra/private-state.d.ts +7 -0
  293. package/dist/infra/private-state.js +146 -0
  294. package/dist/infra/process-identity.d.ts +12 -0
  295. package/dist/infra/process-identity.js +142 -0
  296. package/dist/infra/validation.d.ts +5 -0
  297. package/dist/infra/validation.js +15 -0
  298. package/dist/init.d.ts +1 -0
  299. package/dist/init.js +6 -0
  300. package/dist/install/apply.d.ts +9 -1
  301. package/dist/install/apply.js +176 -12
  302. package/dist/install/dependencies.d.ts +38 -0
  303. package/dist/install/dependencies.js +282 -0
  304. package/dist/install/ownership.js +30 -1
  305. package/dist/install/references.d.ts +31 -0
  306. package/dist/install/references.js +208 -0
  307. package/dist/install/repair.d.ts +37 -0
  308. package/dist/install/repair.js +798 -0
  309. package/dist/install/reset-local.d.ts +16 -0
  310. package/dist/install/reset-local.js +304 -0
  311. package/dist/install/rollback.d.ts +3 -0
  312. package/dist/install/rollback.js +9 -3
  313. package/dist/install/runtime.d.ts +10 -6
  314. package/dist/install/runtime.js +299 -59
  315. package/dist/install/source-fingerprint.d.ts +2 -0
  316. package/dist/install/source-fingerprint.js +103 -0
  317. package/dist/knowledge/catalog.d.ts +1 -0
  318. package/dist/knowledge/catalog.js +143 -10
  319. package/dist/knowledge/journal.js +17 -7
  320. package/dist/knowledge/paths.js +2 -1
  321. package/dist/knowledge/refs.js +4 -0
  322. package/dist/knowledge/register-batch.d.ts +4 -0
  323. package/dist/knowledge/register-batch.js +225 -0
  324. package/dist/knowledge/relocate.d.ts +5 -0
  325. package/dist/knowledge/relocate.js +92 -0
  326. package/dist/knowledge/scan.js +103 -47
  327. package/dist/knowledge/service.d.ts +3 -0
  328. package/dist/knowledge/service.js +33 -1
  329. package/dist/knowledge/write.d.ts +4 -1
  330. package/dist/knowledge/write.js +1 -1
  331. package/dist/mcp/server.d.ts +4 -2
  332. package/dist/mcp/server.js +87 -136
  333. package/dist/schemas/collaboration-state-validation.d.ts +6 -0
  334. package/dist/schemas/collaboration-state-validation.js +342 -0
  335. package/dist/schemas/collaboration-state.d.ts +436 -0
  336. package/dist/schemas/collaboration-state.js +1 -0
  337. package/dist/schemas/collaboration.d.ts +335 -0
  338. package/dist/schemas/collaboration.js +272 -0
  339. package/dist/schemas/continuity.d.ts +8 -0
  340. package/dist/schemas/continuity.js +13 -3
  341. package/dist/schemas/doctor.d.ts +1 -0
  342. package/dist/schemas/doctor.js +1 -0
  343. package/dist/schemas/document.d.ts +61 -0
  344. package/dist/schemas/document.js +9 -0
  345. package/dist/schemas/errors.d.ts +1 -0
  346. package/dist/schemas/errors.js +1 -0
  347. package/dist/schemas/host.d.ts +24 -0
  348. package/dist/schemas/host.js +19 -0
  349. package/dist/schemas/install.d.ts +8 -2
  350. package/dist/schemas/install.js +11 -1
  351. package/dist/schemas/managed-pi-execution.d.ts +16 -0
  352. package/dist/schemas/managed-pi-execution.js +2 -0
  353. package/dist/schemas/outcome.d.ts +9 -5
  354. package/dist/workspace/config-file.js +1 -3
  355. package/dist/workspace/documents.js +12 -2
  356. package/dist/workspace/repositories.d.ts +7 -0
  357. package/dist/workspace/repositories.js +14 -5
  358. package/package.json +37 -35
  359. package/skills/pappra/SKILL.md +56 -7
  360. package/skills/pappra/references/collaboration.md +646 -0
  361. package/skills/pappra/references/environment.md +367 -0
  362. package/skills/pappra/references/hosts.md +20 -0
  363. package/skills/pappra/references/operations.md +36 -5
  364. package/dist/app/collab-from.d.ts +0 -35
  365. package/dist/app/collab-from.js +0 -84
@@ -0,0 +1,798 @@
1
+ import { constants } from 'node:fs';
2
+ import { mkdir, open, readdir, rename, rm } from 'node:fs/promises';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { dirname, join, resolve } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { getPackageInfo } from '../package-info.js';
7
+ import { observePath, readFileBounded, resolveRealpath, sha256Digest, } from '../infra/fs.js';
8
+ import { workspaceInstallManifestSchema, } from '../schemas/install.js';
9
+ import { isPortableRelativePath, resolvePortableRelativePath, } from '../schemas/paths.js';
10
+ import { acquireInstallLock, ensureStatePathBoundary, readInstallJournal, readJsonDocument, readWorkspaceManifest, removeOwnedFile, releaseInstallLock, scanJournalDirectory, workspaceInstallPaths, writeInstallJournal, writeBytesAtomic, writeWorkspaceManifest, } from './ownership.js';
11
+ import { rollbackWorkspaceInstall } from './rollback.js';
12
+ import { resolveUserDataDir, runtimePaths, verifyRuntimeForRepair, } from './runtime.js';
13
+ import { inspectPrivateLock, reclaimDeadPrivateLock, } from '../infra/private-lock.js';
14
+ import { resolveWorkspaceRoot } from '../workspace/resolve.js';
15
+ import { isValidTaskId } from '../schemas/continuity.js';
16
+ import { resetLocalState } from './reset-local.js';
17
+ /** A stale lock is reclaimed only on explicit repair and positive owner-death evidence. */
18
+ async function clearDeadLock(file, boundaryRoot, quarantineUnowned = false, quarantined) {
19
+ const boundary = await ensureStatePathBoundary(boundaryRoot, file);
20
+ if (!boundary.ok)
21
+ throw new Error(boundary.message);
22
+ const observation = await observePath(file);
23
+ if (observation.kind === 'missing')
24
+ return;
25
+ if (observation.kind !== 'file')
26
+ throw new Error(`Unsafe install lock: ${file}`);
27
+ const owner = await inspectPrivateLock(file);
28
+ if (owner.state === 'unknown' && quarantineUnowned) {
29
+ quarantined?.push(await quarantineUnownedLock(file, boundaryRoot));
30
+ return;
31
+ }
32
+ await reclaimDeadPrivateLock(file, async () => {
33
+ const current = await ensureStatePathBoundary(boundaryRoot, file);
34
+ if (!current.ok)
35
+ throw new Error(current.message);
36
+ });
37
+ }
38
+ /** An explicit operator escape preserves unknown evidence; it never asserts owner death. */
39
+ async function quarantineUnownedLock(file, root) {
40
+ const check = async () => {
41
+ const boundary = await ensureStatePathBoundary(root, file);
42
+ if (!boundary.ok)
43
+ throw new Error(boundary.message);
44
+ };
45
+ await check();
46
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
47
+ try {
48
+ const original = await handle.stat();
49
+ if (!original.isFile() || original.size > 64 * 1024)
50
+ throw new Error('Unowned lock is unsafe or too large to quarantine.');
51
+ const bytes = Buffer.alloc(64 * 1024 + 1);
52
+ const read = await handle.read(bytes, 0, bytes.length, 0);
53
+ if (read.bytesRead > 64 * 1024)
54
+ throw new Error('Unowned lock grew during quarantine.');
55
+ const directory = join(dirname(file), 'lock-quarantine');
56
+ const allowed = await ensureStatePathBoundary(root, directory);
57
+ if (!allowed.ok)
58
+ throw new Error(allowed.message);
59
+ await mkdir(directory, { recursive: true, mode: 0o700 });
60
+ const parent = await observePath(directory);
61
+ if (parent.kind !== 'directory' || parent.stats === null)
62
+ throw new Error('Unsafe lock quarantine directory.');
63
+ const target = join(directory, `${randomUUID()}.lock`);
64
+ const receipt = await writeBytesAtomic(`${target}.json`, Buffer.from(`${JSON.stringify({ schema: 'pappra/unowned-lock-quarantine/1', original: file, dev: original.dev, ino: original.ino, digest: sha256Digest(bytes.subarray(0, read.bytesRead)), size: read.bytesRead, operator_asserted_all_writers_stopped: true, owner_death_proven: false, quarantined_at: new Date().toISOString() }, null, 2)}\n`), root);
65
+ if (!receipt.ok)
66
+ throw new Error(receipt.message);
67
+ await check();
68
+ const current = await observePath(file);
69
+ const currentParent = await observePath(directory);
70
+ if (current.kind !== 'file' ||
71
+ current.stats?.dev !== original.dev ||
72
+ current.stats.ino !== original.ino ||
73
+ current.stats.size !== original.size ||
74
+ currentParent.kind !== 'directory' ||
75
+ currentParent.stats?.dev !== parent.stats.dev ||
76
+ currentParent.stats.ino !== parent.stats.ino ||
77
+ !(await ensureStatePathBoundary(root, target)).ok)
78
+ throw new Error('Unowned lock or quarantine directory changed; preserve evidence.');
79
+ await rename(file, target);
80
+ const moved = await observePath(target);
81
+ if (moved.kind !== 'file' ||
82
+ moved.stats?.dev !== original.dev ||
83
+ moved.stats.ino !== original.ino)
84
+ throw new Error('Lock changed during quarantine; moved evidence was retained for inspection.');
85
+ return target;
86
+ }
87
+ finally {
88
+ await handle.close();
89
+ }
90
+ }
91
+ /** Bounded private lock inventory shared by doctor and explicit repair. */
92
+ export async function inspectWorkspaceLocks(root) {
93
+ const files = [
94
+ workspaceInstallPaths(root).lockPath,
95
+ join(root, '.pappra/local/knowledge/apply.lock'),
96
+ join(root, '.pappra/local-archive/reset.lock'),
97
+ ];
98
+ const tasks = join(root, '.pappra/local/tasks');
99
+ const boundary = await ensureStatePathBoundary(root, tasks);
100
+ if (!boundary.ok)
101
+ throw new Error(boundary.message);
102
+ const observation = await observePath(tasks);
103
+ if (observation.kind === 'directory') {
104
+ const entries = await readdir(tasks, { withFileTypes: true });
105
+ if (entries.length > 1000)
106
+ throw new Error('Private task lock inventory exceeds 1000 entries.');
107
+ for (const entry of entries) {
108
+ if (entry.isSymbolicLink() && isValidTaskId(entry.name))
109
+ throw new Error(`Private task directory is a symlink: ${entry.name}`);
110
+ if (entry.isDirectory() && isValidTaskId(entry.name))
111
+ files.push(join(tasks, entry.name, 'checkpoint.lock'));
112
+ }
113
+ }
114
+ else if (observation.kind !== 'missing')
115
+ throw new Error('Private task state directory is unsafe.');
116
+ const results = [];
117
+ for (const file of files) {
118
+ const allowed = await ensureStatePathBoundary(root, file);
119
+ if (!allowed.ok)
120
+ throw new Error(allowed.message);
121
+ const lock = await inspectPrivateLock(file);
122
+ if (lock.state !== 'missing')
123
+ results.push(lock);
124
+ }
125
+ return results;
126
+ }
127
+ async function finish(file, journal, status, boundary) {
128
+ const current = await readInstallJournal(file);
129
+ if (current.value === null ||
130
+ JSON.stringify(current.value) !== JSON.stringify(journal)) {
131
+ throw new Error(`Journal changed during repair: ${file}`);
132
+ }
133
+ const written = await writeInstallJournal(file, {
134
+ ...journal,
135
+ status,
136
+ updatedAt: new Date().toISOString(),
137
+ residuals: [],
138
+ steps: [
139
+ ...journal.steps,
140
+ {
141
+ name: 'explicit-repair',
142
+ status: 'ok',
143
+ at: new Date().toISOString(),
144
+ detail: status,
145
+ },
146
+ ],
147
+ }, boundary);
148
+ if (!written.ok)
149
+ throw new Error(written.message);
150
+ }
151
+ async function repairWorkspace(root, file, journal) {
152
+ if (journal.operation === 'rollback-workspace-files') {
153
+ await repairInterruptedRollback(root, file, journal);
154
+ return;
155
+ }
156
+ if (journal.workspaceRoot !== root ||
157
+ !['apply-workspace-files', 'update-machine-runtime'].includes(journal.operation)) {
158
+ throw new Error(`Unsupported or mismatched workspace journal: ${file}`);
159
+ }
160
+ if (journal.steps.some((step) => step.name === 'manifest-backup')) {
161
+ await repairReconfiguration(root, file, journal);
162
+ return;
163
+ }
164
+ // Missing pending files need no rollback. Existing files without recorded
165
+ // identity remain uncertain and are preserved by the rollback implementation.
166
+ const observations = await Promise.all(journal.entries.map(async (entry) => {
167
+ const target = resolvePortableRelativePath(root, entry.path);
168
+ const boundary = await ensureStatePathBoundary(root, target);
169
+ if (!boundary.ok)
170
+ throw new Error(boundary.message);
171
+ return observePath(target);
172
+ }));
173
+ if (journal.operation === 'apply-workspace-files' &&
174
+ journal.entries.some((entry, index) => entry.identity === null && observations[index]?.kind === 'file')) {
175
+ await retireUnprovenApply(root, file, journal);
176
+ return;
177
+ }
178
+ const noEffects = journal.operation === 'apply-workspace-files' &&
179
+ observations.every((observation) => observation.kind === 'missing');
180
+ const lockPath = workspaceInstallPaths(root).lockPath;
181
+ await clearDeadLock(lockPath, root);
182
+ if (noEffects) {
183
+ const lock = await acquireInstallLock(lockPath, 'rollback-workspace-files', journal.installId, root);
184
+ if (!lock.ok || lock.lock === null)
185
+ throw new Error(lock.message ?? 'Workspace is locked.');
186
+ try {
187
+ await assertJournalCurrent(file, journal);
188
+ for (const entry of journal.entries) {
189
+ const target = resolvePortableRelativePath(root, entry.path);
190
+ if ((await observePath(target)).kind !== 'missing') {
191
+ throw new Error(`Pending file appeared during repair: ${target}`);
192
+ }
193
+ }
194
+ const manifest = await readWorkspaceManifest(root);
195
+ const rootObservation = await observePath(root);
196
+ if (manifest.diagnostics.length > 0 ||
197
+ (manifest.value !== null &&
198
+ (manifest.value.workspaceRoot !== root ||
199
+ manifest.value.rootIdentity.dev !== rootObservation.stats?.dev ||
200
+ manifest.value.rootIdentity.ino !== rootObservation.stats?.ino)))
201
+ throw new Error('Cannot verify ownership after interrupted rollback.');
202
+ if (manifest.value !== null) {
203
+ const owned = manifest.value.entries.filter((entry) => entry.installId === journal.installId);
204
+ if (owned.some((entry) => !journal.entries.some((item) => item.path === entry.path &&
205
+ item.digest === entry.afterDigest &&
206
+ item.identity?.dev === entry.identity.dev &&
207
+ item.identity.ino === entry.identity.ino)))
208
+ throw new Error('Ownership no longer matches the source journal.');
209
+ if (owned.length > 0) {
210
+ const written = await writeWorkspaceManifest(root, {
211
+ ...manifest.value,
212
+ entries: manifest.value.entries.filter((entry) => entry.installId !== journal.installId),
213
+ updatedAt: new Date().toISOString(),
214
+ });
215
+ if (!written.ok)
216
+ throw new Error(written.message);
217
+ }
218
+ }
219
+ await finish(file, journal, 'rolled-back', root);
220
+ }
221
+ finally {
222
+ const released = await releaseInstallLock(lockPath, lock.lock.token);
223
+ if (!released.ok)
224
+ throw new Error(released.message);
225
+ }
226
+ return;
227
+ }
228
+ const result = await rollbackWorkspaceInstall({
229
+ root,
230
+ installId: journal.installId,
231
+ });
232
+ if (!result.ok)
233
+ throw new Error(result.diagnostics.map((entry) => entry.message).join('; ') ||
234
+ `Rollback preserved uncertain files: ${result.residualPaths.join(', ')}`);
235
+ await finish(file, journal, 'rolled-back', root);
236
+ }
237
+ /** Retire only managed effects; files whose creation was never proved stay user-owned. */
238
+ async function retireUnprovenApply(root, file, source) {
239
+ if (source.entries.some((entry) => entry.backupRef !== null || entry.backupDigest !== null))
240
+ throw new Error('Unproven replacement needs its original verified backup; no file was changed.');
241
+ const lockPath = workspaceInstallPaths(root).lockPath;
242
+ await clearDeadLock(lockPath, root);
243
+ const lock = await acquireInstallLock(lockPath, 'rollback-workspace-files', source.installId, root);
244
+ if (!lock.ok || lock.lock === null)
245
+ throw new Error(lock.message ?? 'Workspace is locked.');
246
+ try {
247
+ await assertJournalCurrent(file, source);
248
+ const manifest = await readWorkspaceManifest(root);
249
+ const rootState = await observePath(root);
250
+ if (manifest.diagnostics.length ||
251
+ (manifest.value !== null &&
252
+ (manifest.value.workspaceRoot !== root ||
253
+ manifest.value.rootIdentity.dev !== rootState.stats?.dev ||
254
+ manifest.value.rootIdentity.ino !== rootState.stats?.ino)))
255
+ throw new Error('Cannot verify workspace ownership for interrupted creation.');
256
+ const owned = manifest.value?.entries.filter((entry) => entry.installId === source.installId) ?? [];
257
+ if (owned.some((entry) => !source.entries.some((item) => item.path === entry.path && item.digest === entry.afterDigest)))
258
+ throw new Error('Source journal does not cover its manifest ownership.');
259
+ const entries = [];
260
+ const removals = [];
261
+ for (const entry of source.entries) {
262
+ const target = resolvePortableRelativePath(root, entry.path);
263
+ const boundary = await ensureStatePathBoundary(root, target);
264
+ if (!boundary.ok)
265
+ throw new Error(boundary.message);
266
+ const observed = await observePath(target);
267
+ if (observed.kind === 'missing') {
268
+ entries.push(entry);
269
+ continue;
270
+ }
271
+ if (observed.kind !== 'file')
272
+ throw new Error(`Unsafe interrupted apply target: ${target}`);
273
+ const known = owned.find((item) => item.path === entry.path);
274
+ if (entry.identity === null && known === undefined) {
275
+ entries.push({
276
+ ...entry,
277
+ status: 'residual',
278
+ detail: 'preserved-unproven-file',
279
+ });
280
+ continue;
281
+ }
282
+ const managed = known ?? {
283
+ path: entry.path,
284
+ installId: source.installId,
285
+ operation: 'create',
286
+ beforeDigest: null,
287
+ afterDigest: entry.digest,
288
+ backupRef: null,
289
+ backupDigest: null,
290
+ identity: entry.identity,
291
+ owner: 'pappra',
292
+ verifiedAt: source.updatedAt,
293
+ };
294
+ const read = await readFileBounded(target);
295
+ if (!read.ok ||
296
+ read.value.digest !== managed.afterDigest ||
297
+ observed.stats?.dev !== managed.identity.dev ||
298
+ observed.stats.ino !== managed.identity.ino)
299
+ throw new Error(`Proven managed file changed; preserve ${target}`);
300
+ removals.push(managed);
301
+ entries.push(entry);
302
+ }
303
+ const prepared = await writeInstallJournal(file, {
304
+ ...source,
305
+ entries,
306
+ steps: source.steps.some((step) => step.name === 'preserve-unproven-files')
307
+ ? source.steps
308
+ : [
309
+ ...source.steps,
310
+ {
311
+ name: 'preserve-unproven-files',
312
+ status: 'ok',
313
+ at: new Date().toISOString(),
314
+ detail: 'Files without creation identity remain in place and are not adopted as managed ownership.',
315
+ },
316
+ ],
317
+ }, root);
318
+ if (!prepared.ok)
319
+ throw new Error(prepared.message);
320
+ for (const entry of removals) {
321
+ const removed = await removeOwnedFile(root, entry);
322
+ if (!removed.ok)
323
+ throw new Error(removed.message);
324
+ }
325
+ if (manifest.value !== null && owned.length > 0) {
326
+ const updated = await writeWorkspaceManifest(root, {
327
+ ...manifest.value,
328
+ entries: manifest.value.entries.filter((entry) => entry.installId !== source.installId),
329
+ });
330
+ if (!updated.ok)
331
+ throw new Error(updated.message);
332
+ }
333
+ const current = (await readInstallJournal(file)).value;
334
+ if (current === null)
335
+ throw new Error('Interrupted apply journal became unreadable.');
336
+ await finish(file, current, 'rolled-back', root);
337
+ }
338
+ finally {
339
+ const released = await releaseInstallLock(lockPath, lock.lock.token);
340
+ if (!released.ok)
341
+ throw new Error(released.message);
342
+ }
343
+ }
344
+ async function assertRollbackComplete(root, source) {
345
+ if (source.status !== 'rolled-back' || source.residuals.length > 0) {
346
+ throw new Error('Source installation has not completed rollback.');
347
+ }
348
+ const manifest = await readWorkspaceManifest(root);
349
+ const rootObservation = await observePath(root);
350
+ if (manifest.diagnostics.length > 0 ||
351
+ (manifest.value !== null &&
352
+ (manifest.value.workspaceRoot !== root ||
353
+ manifest.value.rootIdentity.dev !== rootObservation.stats?.dev ||
354
+ manifest.value.rootIdentity.ino !== rootObservation.stats?.ino ||
355
+ manifest.value.entries.some((entry) => entry.installId === source.installId))))
356
+ throw new Error('Source rollback ownership state cannot be verified.');
357
+ for (const entry of source.entries) {
358
+ if (entry.identity === null && entry.detail === 'preserved-unproven-file')
359
+ continue;
360
+ if (!isPortableRelativePath(entry.path))
361
+ throw new Error('Unsafe source rollback path.');
362
+ const target = resolvePortableRelativePath(root, entry.path);
363
+ const boundary = await ensureStatePathBoundary(root, target);
364
+ if (!boundary.ok)
365
+ throw new Error(boundary.message);
366
+ const observation = await observePath(target);
367
+ if (entry.backupDigest === null) {
368
+ if (observation.kind !== 'missing')
369
+ throw new Error(`Source rollback target still exists: ${target}`);
370
+ continue;
371
+ }
372
+ const owned = manifest.value?.entries.find((item) => item.path === entry.path);
373
+ if (observation.kind !== 'file' ||
374
+ owned?.afterDigest !== entry.backupDigest ||
375
+ owned.identity.dev !== observation.stats?.dev ||
376
+ owned.identity.ino !== observation.stats?.ino) {
377
+ throw new Error(`Source restoration ownership cannot be verified: ${target}`);
378
+ }
379
+ const content = await readFileBounded(target);
380
+ if (!content.ok || content.value.digest !== entry.backupDigest) {
381
+ throw new Error(`Source restoration content cannot be verified: ${target}`);
382
+ }
383
+ }
384
+ }
385
+ async function repairInterruptedRollback(root, file, journal) {
386
+ if (journal.workspaceRoot !== root ||
387
+ journal.runtimeVersion !== null ||
388
+ journal.manifestRef !== workspaceInstallPaths(root).manifestPath ||
389
+ !z.uuid().safeParse(journal.target).success ||
390
+ journal.target === journal.installId) {
391
+ throw new Error(`Invalid interrupted rollback journal: ${file}`);
392
+ }
393
+ const sourceFile = join(workspaceInstallPaths(root).journalsDir, `${journal.target}.json`);
394
+ if ((await observePath(sourceFile)).kind !== 'file')
395
+ throw new Error('Source install journal is missing or unsafe.');
396
+ const sourceRead = await readInstallJournal(sourceFile);
397
+ const source = sourceRead.value;
398
+ if (source === null ||
399
+ source.installId !== journal.target ||
400
+ source.workspaceRoot !== root ||
401
+ (source.runtimeVersion !== null &&
402
+ source.operation !== 'update-machine-runtime') ||
403
+ !['apply-workspace-files', 'update-machine-runtime'].includes(source.operation) ||
404
+ source.manifestRef !== workspaceInstallPaths(root).manifestPath ||
405
+ journal.steps.some((step) => step.name.startsWith('recovered-from-') &&
406
+ (step.detail !== source.installId ||
407
+ step.name !==
408
+ (source.operation === 'apply-workspace-files'
409
+ ? 'recovered-from-apply-journal'
410
+ : 'recovered-from-update-journal')))) {
411
+ throw new Error('Interrupted rollback does not identify a supported source install.');
412
+ }
413
+ const knownResiduals = new Set([
414
+ ...source.entries.map((entry) => entry.path),
415
+ file,
416
+ workspaceInstallPaths(root).manifestPath,
417
+ workspaceInstallPaths(root).lockPath,
418
+ ]);
419
+ if (journal.residuals.some((path) => !knownResiduals.has(path)) ||
420
+ journal.entries.some((entry) => {
421
+ const origin = source.entries.find((item) => item.path === entry.path);
422
+ return (origin === undefined ||
423
+ (source.operation === 'apply-workspace-files'
424
+ ? entry.digest !== origin.digest ||
425
+ entry.backupRef !== origin.backupRef ||
426
+ entry.backupDigest !== origin.backupDigest ||
427
+ entry.identity?.dev !== origin.identity?.dev ||
428
+ entry.identity?.ino !== origin.identity?.ino
429
+ : entry.digest !== origin.backupDigest ||
430
+ entry.backupRef !== null ||
431
+ entry.backupDigest !== null));
432
+ }))
433
+ throw new Error('Interrupted rollback entries do not match the source install.');
434
+ if (source.status !== 'rolled-back')
435
+ await repairWorkspace(root, sourceFile, source);
436
+ const recovered = (await readInstallJournal(sourceFile)).value;
437
+ if (recovered === null)
438
+ throw new Error('Recovered source journal is unreadable.');
439
+ const lockPath = workspaceInstallPaths(root).lockPath;
440
+ await clearDeadLock(lockPath, root);
441
+ const lock = await acquireInstallLock(lockPath, 'rollback-workspace-files', source.installId, root);
442
+ if (!lock.ok || lock.lock === null)
443
+ throw new Error(lock.message ?? 'Workspace is locked.');
444
+ try {
445
+ await assertJournalCurrent(file, journal);
446
+ await assertJournalCurrent(sourceFile, recovered);
447
+ await assertRollbackComplete(root, recovered);
448
+ await finish(file, journal, 'rolled-back', root);
449
+ }
450
+ finally {
451
+ const released = await releaseInstallLock(lockPath, lock.lock.token);
452
+ if (!released.ok)
453
+ throw new Error(released.message);
454
+ }
455
+ }
456
+ async function repairReconfiguration(root, file, journal) {
457
+ const backupRef = journal.steps.find((step) => step.name === 'manifest-backup')?.detail;
458
+ const backupDigest = journal.steps.find((step) => step.name === 'manifest-backup-digest')?.detail;
459
+ if (!backupRef || !isPortableRelativePath(backupRef))
460
+ throw new Error('Invalid ownership backup path.');
461
+ const backupFile = resolvePortableRelativePath(root, backupRef);
462
+ const boundary = await ensureStatePathBoundary(root, backupFile);
463
+ if (!boundary.ok || (await observePath(backupFile)).kind !== 'file')
464
+ throw new Error('Unsafe ownership backup.');
465
+ const baseline = await readJsonDocument(backupFile, workspaceInstallManifestSchema);
466
+ const rootObservation = await observePath(root);
467
+ if (baseline.value === null ||
468
+ baseline.digest !== backupDigest ||
469
+ baseline.value.workspaceRoot !== root ||
470
+ baseline.value.rootIdentity.dev !== rootObservation.stats?.dev ||
471
+ baseline.value.rootIdentity.ino !== rootObservation.stats?.ino) {
472
+ throw new Error('Ownership backup does not match this workspace.');
473
+ }
474
+ const lockPath = workspaceInstallPaths(root).lockPath;
475
+ await clearDeadLock(lockPath, root);
476
+ const lock = await acquireInstallLock(lockPath, 'rollback-workspace-files', journal.installId, root);
477
+ if (!lock.ok || lock.lock === null)
478
+ throw new Error(lock.message ?? 'Workspace is locked.');
479
+ try {
480
+ await assertJournalCurrent(file, journal);
481
+ const actions = [];
482
+ for (const entry of journal.entries) {
483
+ if (!isPortableRelativePath(entry.path))
484
+ throw new Error(`Unsafe journal path: ${entry.path}`);
485
+ const target = resolvePortableRelativePath(root, entry.path);
486
+ const targetBoundary = await ensureStatePathBoundary(root, target);
487
+ if (!targetBoundary.ok)
488
+ throw new Error(targetBoundary.message);
489
+ const current = await observePath(target);
490
+ if (current.kind === 'missing' && entry.backupRef === null)
491
+ continue;
492
+ if (current.kind !== 'file')
493
+ throw new Error(`Unsafe recovery target: ${target}`);
494
+ const content = await readFileBounded(target);
495
+ if (!content.ok)
496
+ throw new Error(content.message);
497
+ if (entry.backupDigest !== null &&
498
+ content.value.digest === entry.backupDigest)
499
+ continue;
500
+ if (entry.identity === null ||
501
+ current.stats?.dev !== entry.identity.dev ||
502
+ current.stats.ino !== entry.identity.ino ||
503
+ content.value.digest !== entry.digest) {
504
+ throw new Error(`Recovery target changed; it was preserved: ${target}`);
505
+ }
506
+ let bytes = null;
507
+ if (entry.backupRef !== null) {
508
+ if (!isPortableRelativePath(entry.backupRef))
509
+ throw new Error('Unsafe replacement backup path.');
510
+ const backup = resolvePortableRelativePath(root, entry.backupRef);
511
+ if (!(await ensureStatePathBoundary(root, backup)).ok ||
512
+ (await observePath(backup)).kind !== 'file') {
513
+ throw new Error(`Unsafe replacement backup: ${backup}`);
514
+ }
515
+ const read = await readFileBounded(backup);
516
+ if (!read.ok || sha256Digest(read.value.bytes) !== entry.backupDigest)
517
+ throw new Error(`Replacement backup changed: ${backup}`);
518
+ bytes = read.value.bytes;
519
+ }
520
+ actions.push({ entry, target, bytes });
521
+ }
522
+ for (const action of actions) {
523
+ if (action.bytes !== null) {
524
+ const restored = await writeBytesAtomic(action.target, action.bytes, root);
525
+ if (!restored.ok)
526
+ throw new Error(restored.message);
527
+ }
528
+ else {
529
+ const owned = {
530
+ path: action.entry.path,
531
+ installId: journal.installId,
532
+ operation: 'create',
533
+ beforeDigest: null,
534
+ afterDigest: action.entry.digest,
535
+ backupRef: null,
536
+ backupDigest: null,
537
+ identity: action.entry.identity,
538
+ owner: 'pappra',
539
+ verifiedAt: journal.updatedAt,
540
+ };
541
+ const removed = await removeOwnedFile(root, owned);
542
+ if (!removed.ok)
543
+ throw new Error(removed.message);
544
+ }
545
+ }
546
+ const entries = await Promise.all(baseline.value.entries.map(async (entry) => {
547
+ if (!journal.entries.some((item) => item.path === entry.path && item.backupRef !== null))
548
+ return entry;
549
+ const observation = await observePath(resolvePortableRelativePath(root, entry.path));
550
+ if (observation.kind !== 'file' || observation.stats === null)
551
+ throw new Error('Restored target is unreadable.');
552
+ // Reconfiguration may start from a manually edited configuration. Its
553
+ // exact pre-reconfiguration bytes, rather than an older digest, are restored.
554
+ const prior = journal.entries.find((item) => item.path === entry.path);
555
+ return {
556
+ ...entry,
557
+ afterDigest: prior.backupDigest,
558
+ identity: { dev: observation.stats.dev, ino: observation.stats.ino },
559
+ };
560
+ }));
561
+ const restored = await writeWorkspaceManifest(root, {
562
+ ...baseline.value,
563
+ entries,
564
+ updatedAt: new Date().toISOString(),
565
+ });
566
+ if (!restored.ok)
567
+ throw new Error(restored.message);
568
+ await finish(file, journal, 'rolled-back', root);
569
+ }
570
+ finally {
571
+ const released = await releaseInstallLock(lockPath, lock.lock.token);
572
+ if (!released.ok)
573
+ throw new Error(released.message);
574
+ }
575
+ }
576
+ async function repairRuntime(dataDir, version, file, journal) {
577
+ const paths = runtimePaths(version, dataDir);
578
+ if (journal.operation !== 'install-runtime' ||
579
+ journal.runtimeVersion !== version ||
580
+ journal.workspaceRoot !== null ||
581
+ journal.target !== paths.runtimeRoot ||
582
+ journal.manifestRef !== join(paths.runtimeRoot, 'install-manifest.json')) {
583
+ throw new Error(`Mismatched runtime journal: ${file}`);
584
+ }
585
+ await clearDeadLock(paths.lockPath, dataDir);
586
+ const lock = await acquireInstallLock(paths.lockPath, 'install-runtime', paths.runtimeRoot, dataDir);
587
+ if (!lock.ok || lock.lock === null)
588
+ throw new Error(lock.message ?? 'Runtime is locked.');
589
+ try {
590
+ await assertJournalCurrent(file, journal);
591
+ const boundary = await ensureStatePathBoundary(dataDir, paths.runtimeRoot);
592
+ if (!boundary.ok)
593
+ throw new Error(boundary.message);
594
+ const published = await observePath(paths.runtimeRoot);
595
+ if (published.kind !== 'missing') {
596
+ if (published.kind !== 'directory' ||
597
+ !(await verifyRuntimeForRepair(paths.runtimeRoot, version, getPackageInfo().name, journal.installId)))
598
+ throw new Error(`Published runtime cannot be verified; it was preserved: ${paths.runtimeRoot}`);
599
+ await finish(file, journal, 'verified', dataDir);
600
+ return;
601
+ }
602
+ const stage = join(paths.stagingRoot, journal.installId);
603
+ const stageBoundary = await ensureStatePathBoundary(dataDir, stage);
604
+ if (!stageBoundary.ok)
605
+ throw new Error(stageBoundary.message);
606
+ const staged = await observePath(stage);
607
+ if (staged.kind !== 'missing') {
608
+ if (staged.kind !== 'directory' ||
609
+ !journal.steps.some((step) => step.name === 'staging-created' &&
610
+ step.status === 'ok' &&
611
+ step.detail === stage))
612
+ throw new Error(`Staging ownership cannot be verified: ${stage}`);
613
+ await rm(stage, { recursive: true, force: false });
614
+ }
615
+ await finish(file, journal, 'rolled-back', dataDir);
616
+ }
617
+ finally {
618
+ const released = await releaseInstallLock(paths.lockPath, lock.lock.token);
619
+ if (!released.ok)
620
+ throw new Error(released.message);
621
+ }
622
+ }
623
+ /** Explicit, bounded journal recovery. Preview never removes a lock or changes state. */
624
+ export async function repairInstall(options) {
625
+ const entries = [];
626
+ const repaired = [];
627
+ const errors = [];
628
+ const locks = [];
629
+ const lockStates = [];
630
+ const preserved = [];
631
+ const quarantined = [];
632
+ let reset = null;
633
+ const mode = options.apply === true ? 'apply' : 'plan';
634
+ try {
635
+ if (options.installId !== undefined &&
636
+ !z.uuid().safeParse(options.installId).success) {
637
+ throw new Error('Use an exact journal install UUID.');
638
+ }
639
+ if (options.runtimeVersion !== undefined &&
640
+ !/^[0-9A-Za-z][0-9A-Za-z0-9._+-]{0,127}$/.test(options.runtimeVersion)) {
641
+ throw new Error('Use an exact runtime version without path separators.');
642
+ }
643
+ const runtime = options.runtimeVersion !== undefined;
644
+ const resolution = runtime
645
+ ? null
646
+ : await resolveWorkspaceRoot({ startPath: options.root });
647
+ const root = runtime
648
+ ? resolve(options.dataDir ?? resolveUserDataDir())
649
+ : (resolution?.root ?? (await resolveRealpath(options.root)));
650
+ if (root === null)
651
+ throw new Error('Workspace root does not exist.');
652
+ if (options.resetLocal === true) {
653
+ if (runtime || options.installId !== undefined)
654
+ throw new Error('Local reset cannot target a runtime version or install ID.');
655
+ lockStates.push(...(await inspectWorkspaceLocks(root)));
656
+ locks.push(...lockStates.map((state) => state.file));
657
+ reset = await resetLocalState(root, {
658
+ apply: options.apply === true,
659
+ allWritersStopped: options.allWritersStopped === true,
660
+ assertNoLiveLocks: async () => {
661
+ const current = await inspectWorkspaceLocks(root);
662
+ if (current.some((state) => state.state === 'alive' &&
663
+ state.file !== join(root, '.pappra/local-archive/reset.lock')))
664
+ throw new Error('A live writer lock prevents local reset; stop it first.');
665
+ },
666
+ recoverUnknownResetLock: async (file) => {
667
+ await clearDeadLock(file, root, true, quarantined);
668
+ },
669
+ ...(options.afterLocalArchive === undefined
670
+ ? {}
671
+ : { afterArchive: options.afterLocalArchive }),
672
+ });
673
+ return {
674
+ ok: true,
675
+ mode,
676
+ entries,
677
+ repaired: options.apply ? [join(root, '.pappra/local')] : [],
678
+ locks,
679
+ errors,
680
+ lockStates: lockStates.map(({ file, state }) => ({ file, state })),
681
+ preserved: reset.archive === null ? [] : [reset.archive],
682
+ quarantined,
683
+ reset,
684
+ };
685
+ }
686
+ const directory = runtime
687
+ ? runtimePaths(options.runtimeVersion, root).journalsDir
688
+ : workspaceInstallPaths(root).journalsDir;
689
+ const boundary = await ensureStatePathBoundary(root, directory);
690
+ if (!boundary.ok)
691
+ throw new Error(boundary.message);
692
+ const lockPath = runtime
693
+ ? runtimePaths(options.runtimeVersion, root).lockPath
694
+ : workspaceInstallPaths(root).lockPath;
695
+ if (runtime) {
696
+ const state = await inspectPrivateLock(lockPath);
697
+ if (state.state !== 'missing')
698
+ lockStates.push(state);
699
+ }
700
+ else
701
+ lockStates.push(...(await inspectWorkspaceLocks(root)));
702
+ locks.push(...lockStates.map((state) => state.file));
703
+ const scan = await scanJournalDirectory(directory);
704
+ if (scan.kind === 'unreadable' || scan.nonRegular.length > 0) {
705
+ throw new Error(`Unsafe or unreadable journal directory: ${directory}`);
706
+ }
707
+ const candidates = [];
708
+ for (const file of scan.files) {
709
+ const read = await readInstallJournal(file);
710
+ if (read.value === null)
711
+ throw new Error(`Unreadable journal: ${file}`);
712
+ const journal = read.value;
713
+ if (file !== join(directory, `${journal.installId}.json`)) {
714
+ throw new Error(`Journal filename and identity disagree: ${file}`);
715
+ }
716
+ if (runtime && journal.runtimeVersion !== options.runtimeVersion)
717
+ continue;
718
+ if (options.installId !== undefined &&
719
+ journal.installId !== options.installId)
720
+ continue;
721
+ if (journal.status === 'verified' || journal.status === 'rolled-back')
722
+ continue;
723
+ candidates.push({ file, journal });
724
+ entries.push({
725
+ journal: file,
726
+ installId: journal.installId,
727
+ operation: journal.operation,
728
+ status: journal.status,
729
+ });
730
+ }
731
+ if (options.installId !== undefined && candidates.length === 0) {
732
+ throw new Error('No incomplete journal matches this install ID.');
733
+ }
734
+ if (options.apply === true) {
735
+ if (options.quarantineUnownedLocks === true) {
736
+ for (const state of lockStates.filter((item) => item.state === 'unknown'))
737
+ await clearDeadLock(state.file, root, true, quarantined);
738
+ }
739
+ for (const state of lockStates.filter((item) => item.file !== lockPath)) {
740
+ await clearDeadLock(state.file, root);
741
+ repaired.push(state.file);
742
+ }
743
+ if (candidates.length === 0 && locks.length > 0) {
744
+ await clearDeadLock(lockPath, root);
745
+ repaired.push(lockPath);
746
+ }
747
+ for (const { file, journal } of candidates) {
748
+ const current = (await readInstallJournal(file)).value;
749
+ if (current === null ||
750
+ current.installId !== journal.installId ||
751
+ current.operation !== journal.operation ||
752
+ current.workspaceRoot !== journal.workspaceRoot ||
753
+ current.target !== journal.target ||
754
+ current.runtimeVersion !== journal.runtimeVersion ||
755
+ current.manifestRef !== journal.manifestRef) {
756
+ throw new Error(`Journal identity changed during repair: ${file}`);
757
+ }
758
+ if (current !== null &&
759
+ (current.status === 'rolled-back' || current.status === 'verified')) {
760
+ repaired.push(file);
761
+ continue;
762
+ }
763
+ await assertJournalCurrent(file, journal);
764
+ if (runtime)
765
+ await repairRuntime(root, options.runtimeVersion, file, journal);
766
+ else
767
+ await repairWorkspace(root, file, journal);
768
+ const completed = (await readInstallJournal(file)).value;
769
+ for (const entry of completed?.entries ?? [])
770
+ if (entry.detail === 'preserved-unproven-file')
771
+ preserved.push(entry.path);
772
+ repaired.push(file);
773
+ }
774
+ }
775
+ }
776
+ catch (error) {
777
+ errors.push(error instanceof Error ? error.message : String(error));
778
+ }
779
+ return {
780
+ ok: errors.length === 0,
781
+ mode,
782
+ entries,
783
+ repaired,
784
+ locks,
785
+ errors,
786
+ lockStates: lockStates.map(({ file, state }) => ({ file, state })),
787
+ preserved,
788
+ quarantined,
789
+ reset,
790
+ };
791
+ }
792
+ async function assertJournalCurrent(file, journal) {
793
+ const current = await readInstallJournal(file);
794
+ if (current.value === null ||
795
+ JSON.stringify(current.value) !== JSON.stringify(journal)) {
796
+ throw new Error(`Journal changed during repair: ${file}`);
797
+ }
798
+ }