@tea-agent/loop-agent 0.41.1-beta.0 → 0.42.0-next.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 (277) hide show
  1. package/CHANGELOG.md +78 -206
  2. package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
  3. package/dist/adapters/context-transfer/pi-session.js +61 -0
  4. package/dist/application/dag/generate-task-dag.js +19 -75
  5. package/dist/application/task-lifecycle/advance.js +5 -24
  6. package/dist/application/task-lifecycle/observe.js +17 -171
  7. package/dist/application/task-lifecycle/plan-transitions.js +7 -42
  8. package/dist/application/task-lifecycle/recommendations.js +2 -13
  9. package/dist/build-stamp.json +3 -3
  10. package/dist/cli/command-definitions.js +12 -7
  11. package/dist/cli/program.js +23 -6
  12. package/dist/commands/client-recovery.js +0 -3
  13. package/dist/commands/dag-artifact.js +284 -0
  14. package/dist/commands/dag-context.js +184 -0
  15. package/dist/commands/dag-rerun.js +206 -1
  16. package/dist/commands/init.js +1 -27
  17. package/dist/commands/task-advance.js +0 -19
  18. package/dist/executors/dag-pi-executor.js +266 -3792
  19. package/dist/executors/pi-executor.js +4 -15
  20. package/dist/executors/pi-sdk-executor.js +3 -0
  21. package/dist/executors/shell-executor.js +217 -945
  22. package/dist/executors/shell-write-guard.js +0 -7
  23. package/dist/{worker → infrastructure}/console/app-data.js +4 -0
  24. package/dist/infrastructure/console/artifact-revision-store.js +430 -0
  25. package/dist/infrastructure/console/context-export-store.js +160 -0
  26. package/dist/infrastructure/console/dir-lock.js +132 -0
  27. package/dist/infrastructure/console/operation-store.js +197 -0
  28. package/dist/infrastructure/harness/artifact-store.js +10 -1
  29. package/dist/infrastructure/harness/atomic-write.js +12 -2
  30. package/dist/shared/context-transfer/artifact-revision.js +172 -0
  31. package/dist/shared/context-transfer.js +418 -0
  32. package/dist/shared/dag-failure-category.js +0 -12
  33. package/dist/shared/openspec-spec.js +4 -70
  34. package/dist/shared/operator/capabilities.js +14 -7
  35. package/dist/shared/operator/safe-run-summary.js +1 -0
  36. package/dist/shared/path-safety.js +93 -0
  37. package/dist/shared/preview.js +28 -4
  38. package/dist/task/config-types.js +80 -35
  39. package/dist/task/contract/adopt.js +0 -4
  40. package/dist/task/contract/import-revision.js +0 -4
  41. package/dist/task/contract/project.js +0 -3
  42. package/dist/task/contract/schema.js +1 -2
  43. package/dist/task/frontend-project-capability.js +20 -203
  44. package/dist/task/runtime.js +2 -5
  45. package/dist/task/source-prepare/build-draft.js +3 -3
  46. package/dist/task/source-prepare/fragment-inventory.js +9 -15
  47. package/dist/task/source-prepare/prepare.js +1 -89
  48. package/dist/task/source-prepare/semantic-intake.js +2 -6
  49. package/dist/task/source-references.js +1 -22
  50. package/dist/task/task-demand-routing.js +3 -0
  51. package/dist/worker/console/chat/chat-event-store.js +2 -2
  52. package/dist/worker/console/chat/pi-runtime.js +3 -2
  53. package/dist/worker/console/chat/resource-preferences-store.js +1 -1
  54. package/dist/worker/console/chat/routes.js +13 -4
  55. package/dist/worker/console/chat/semantic-activity.js +12 -11
  56. package/dist/worker/console/chat/session-stats.js +96 -0
  57. package/dist/worker/console/chat/session-store.js +1 -1
  58. package/dist/worker/console/chat/turn-process.js +28 -4
  59. package/dist/worker/console/chat/user-questions.js +1 -1
  60. package/dist/worker/console/console-update-runtime.js +1 -1
  61. package/dist/worker/console/context-transfer-diagnostics.js +198 -0
  62. package/dist/worker/console/dag-confirmation.js +1 -1
  63. package/dist/worker/console/dag-execution-receipt.js +1 -1
  64. package/dist/worker/console/doctor.js +1 -1
  65. package/dist/worker/console/draft-store.js +1 -1
  66. package/dist/worker/console/human-gate-token.js +1 -1
  67. package/dist/worker/console/index.js +3 -6
  68. package/dist/worker/console/interview/assessment.js +1 -1
  69. package/dist/worker/console/interview/session.js +1 -1
  70. package/dist/worker/console/operation-runner.js +45 -4
  71. package/dist/worker/console/operation-sse.js +1 -1
  72. package/dist/worker/console/operation-wait.js +1 -1
  73. package/dist/worker/console/operator-actions.js +107 -168
  74. package/dist/worker/console/operator-surface-health.js +1 -1
  75. package/dist/worker/console/operator-user-error.js +169 -0
  76. package/dist/worker/console/pi-plugins.js +60 -1
  77. package/dist/worker/console/routes.js +637 -8
  78. package/dist/worker/console/security.js +35 -0
  79. package/dist/worker/console/server.js +6 -6
  80. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Bho4we4c.js → abnfDiagram-N423BO3Z-DZZ8m3PO.js} +1 -1
  81. package/dist/worker/console/static/assets/{arc-TBDkTeK0.js → arc-D6PvaVd-.js} +1 -1
  82. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BF_fZrn2.js → architectureDiagram-T3A2C74G-B_OTOiI8.js} +1 -1
  83. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CCnPgUe2.js → blockDiagram-VBNYF7ZC-Bv6rqHBg.js} +1 -1
  84. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BCYZJLAT.js → c4Diagram-5PPSVZJV-B8eHr0oz.js} +1 -1
  85. package/dist/worker/console/static/assets/channel-BU5gOilw.js +1 -0
  86. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D8K6adhU.js → chunk-2GRJ4B5K-DYwR0im2.js} +1 -1
  87. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DkT1Q78b.js → chunk-2Q5K7J3B-D2WPGqXt.js} +1 -1
  88. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DuhvpIr7.js → chunk-5RXB4S5H-CQISJ_I7.js} +1 -1
  89. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BnK6dC49.js → chunk-5VM5RSS4-C0o2Du1e.js} +1 -1
  90. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DPz__z7r.js → chunk-6Q2QTUOP-4f8kr-U7.js} +1 -1
  91. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-0kkppM2j.js → chunk-GF5L2VYU-D_OWvXzX.js} +1 -1
  92. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BB41iK5t.js → chunk-JWPE2WC7-CasdPz5X.js} +1 -1
  93. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CiVrxKHU.js → chunk-KBJHAD2P-Cj8lRrla.js} +1 -1
  94. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-C2h9Xje1.js → chunk-RYQCIY6F-CUvD1FSd.js} +1 -1
  95. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-3iOSHdbM.js → chunk-XXDRQBXY-CqLw_eqB.js} +1 -1
  96. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +1 -0
  97. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +1 -0
  98. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BN3A7tkk.js → cose-bilkent-JH36ORCC-ku-WqJPx.js} +1 -1
  99. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CTuBy0Or.js → cynefin-VYW2F7L2-Bq_PYDhl.js} +1 -1
  100. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-wb6tUlMN.js → cynefinDiagram-MW4NZA55-BiGZV641.js} +1 -1
  101. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-GP1Ivx8B.js → dagre-VZM6K2ZE-C4JRlglF.js} +1 -1
  102. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-ejrcK6L0.js → diagram-7IWD3JNH-B0dNeWYG.js} +1 -1
  103. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-YFkCCvUU.js → diagram-B4RE2ZJO-Cj35YvYM.js} +1 -1
  104. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-B4rrvIaL.js → diagram-LBJQPF4R-uzQoJ2-8.js} +1 -1
  105. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C-YIZcUf.js → diagram-Q27KOJAE-D1a-Buoz.js} +1 -1
  106. package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPF5wcL1.js → diagram-UB23O5K3-XjRrLRSs.js} +1 -1
  107. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CU5SzXjO.js → ebnfDiagram-BXEA7PRR-Da_O24cW.js} +1 -1
  108. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CpL414DE.js → erDiagram-JOGREHBK-BLJ8jrYU.js} +1 -1
  109. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-MrhLzNau.js → flowDiagram-UKHOOZJN-B9GrLjM0.js} +1 -1
  110. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Bg4qFuUo.js → ganttDiagram-PKOTCBZU-CeJ0TqiK.js} +1 -1
  111. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-ColxSXLR.js → gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js} +1 -1
  112. package/dist/worker/console/static/assets/index-H9rFJiGL.css +1 -0
  113. package/dist/worker/console/static/assets/index-xwu9GxEc.js +451 -0
  114. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czvtm6gc.js → infoDiagram-6WML65LV-DB26i3d8.js} +1 -1
  115. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B7Xp8HyJ.js → ishikawaDiagram-WSZJBQD7-BOGRyeZT.js} +1 -1
  116. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DWYZcvph.js → journeyDiagram-NVQOT4AX-CZRoBO_6.js} +1 -1
  117. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-KTLZRUjx.js → kanban-definition-27J2QSJJ-qlWhJyoB.js} +1 -1
  118. package/dist/worker/console/static/assets/{linear-_Exn7bfl.js → linear-CfUiDDB3.js} +1 -1
  119. package/dist/worker/console/static/assets/{mermaid.core-Cxg8kX8V.js → mermaid.core-BQe6fpqj.js} +5 -5
  120. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-oKrlo2Bt.js → mindmap-definition-FAOFIHXS-zFzWHw64.js} +1 -1
  121. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-XAeteiNd.js → pegDiagram-VL7TDLO6-DFGUqjZO.js} +1 -1
  122. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BTf1K-lp.js → pieDiagram-7S7Q4E2Y-BB5i0l8Z.js} +1 -1
  123. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C0IlY0Jf.js → quadrantDiagram-CIZ2JOQS-obbVZ_X5.js} +1 -1
  124. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BVbKtZhW.js → railroadDiagram-AXF67PYL-BwdaNzc1.js} +1 -1
  125. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-C5e4JFcr.js → requirementDiagram-LRYGKXZP-CkTJaugh.js} +1 -1
  126. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cz30hc29.js → sankeyDiagram-W5VNT64P-BwJ-hIgq.js} +1 -1
  127. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DS33n6d0.js → sequenceDiagram-SI44F4Z6-BzSgmm7w.js} +1 -1
  128. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-46w7v-9T.js → sizeCapture-X5ZJPWSS-Bn3oj5Q1.js} +1 -1
  129. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-pbcD5SsZ.js → stateDiagram-OKZ733FA-dxhGtL8K.js} +1 -1
  130. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +1 -0
  131. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DuivYBSj.js → swimlanes-SLNWSIFB-BLpEInya.js} +2 -2
  132. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +8 -0
  133. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cb9VZVIl.js → timeline-definition-Z64GVDOM-ChJGYcXy.js} +1 -1
  134. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-D2-mfOgR.js → vennDiagram-T6HMQDX7-DK-qjRer.js} +1 -1
  135. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DeS2aC76.js → wardleyDiagram-T6FBY63Y-3qeaWAg-.js} +1 -1
  136. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-WGsrFjxH.js → xychartDiagram-ELKLHX3M-CootlyP9.js} +1 -1
  137. package/dist/worker/console/static/index.html +7 -2
  138. package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
  139. package/dist/worker/console/static-src/app/useRunProgress.js +9 -2
  140. package/dist/worker/console/static-src/operator-chat/dag-progress-link.js +22 -0
  141. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +2 -0
  142. package/dist/worker/console/static-src/operator-chat/useChatThread.js +6 -0
  143. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +28 -3
  144. package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +106 -0
  145. package/dist/worker/console/static-src/shell/console-update-reload.js +25 -0
  146. package/dist/worker/console/workspace-context.js +28 -112
  147. package/dist/worker/console/workspace-registry.js +2 -2
  148. package/dist/worker/continuation/worker-continuation.js +278 -0
  149. package/dist/worker/observability/read-model.js +4 -0
  150. package/dist/worker/observe/health.js +1 -1
  151. package/dist/worker/observe/node-transparency.js +572 -0
  152. package/dist/worker/observe/routes.js +51 -1
  153. package/dist/worker/observe/static/api.js +69 -5
  154. package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
  155. package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
  156. package/dist/worker/observe/static/dag-node-purpose.js +0 -5
  157. package/dist/worker/observe/static/state.js +3 -1
  158. package/dist/worker/observe/static/styles.css +1037 -126
  159. package/dist/worker/observe/static/views/dag-inspector.js +1217 -65
  160. package/dist/worker/observe/static/views/session-timeline.js +121 -25
  161. package/dist/worker/outcomes/adapters.js +19 -0
  162. package/dist/worker/outcomes/types.js +1 -0
  163. package/dist/worker/pool/attempt-lease.js +97 -66
  164. package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
  165. package/dist/worker/pool/reconcile.js +46 -1
  166. package/dist/worker/pool/run-store.js +2 -0
  167. package/dist/worker/pool/state-projection.js +2 -0
  168. package/dist/worker/task-spec/workflow-routing.js +8 -3
  169. package/dist/workflows/dag/artifact-bindings.js +149 -0
  170. package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
  171. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +99 -4
  172. package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
  173. package/dist/workflows/dag/backend-test-result-contract.js +1 -1
  174. package/dist/workflows/dag/backend-test-writer-completeness.js +45 -3
  175. package/dist/workflows/dag/budget-enforcement.js +10 -0
  176. package/dist/workflows/dag/context-receipt.js +305 -0
  177. package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
  178. package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
  179. package/dist/workflows/dag/context-transfer/renderers.js +93 -0
  180. package/dist/workflows/dag/contract-validator-registrations.js +2 -1
  181. package/dist/workflows/dag/frontend-implementation-contract.js +162 -932
  182. package/dist/workflows/dag/frontend-plan-render.js +1 -2
  183. package/dist/workflows/dag/frontend-prewrite-gate.js +349 -255
  184. package/dist/workflows/dag/frontend-recovery-plan.js +10 -17
  185. package/dist/workflows/dag/frontend-recovery-run.js +20 -33
  186. package/dist/workflows/dag/frontend-repair.js +432 -1
  187. package/dist/workflows/dag/frontend-review-context.js +15 -261
  188. package/dist/workflows/dag/frontend-verification-trace.js +24 -249
  189. package/dist/workflows/dag/frontend-worktree-diff.js +17 -250
  190. package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
  191. package/dist/workflows/dag/init-hybrid.js +740 -718
  192. package/dist/workflows/dag/interrupt-request.js +0 -7
  193. package/dist/workflows/dag/node-execution.js +136 -759
  194. package/dist/workflows/dag/path-safety.js +1 -0
  195. package/dist/workflows/dag/prompt.js +30 -38
  196. package/dist/workflows/dag/report.js +1 -37
  197. package/dist/workflows/dag/rerun-feedback.js +1 -256
  198. package/dist/workflows/dag/rerun-plan.js +557 -11
  199. package/dist/workflows/dag/rerun-run.js +601 -38
  200. package/dist/workflows/dag/rerun-task.js +0 -29
  201. package/dist/workflows/dag/retry-policy.js +109 -214
  202. package/dist/workflows/dag/runner.js +142 -430
  203. package/dist/workflows/dag/scheduler.js +16 -114
  204. package/dist/workflows/dag/skill-snapshot.js +17 -0
  205. package/dist/workflows/dag/types.js +195 -246
  206. package/dist/workflows/dag/validate.js +12 -28
  207. package/docs/architecture/runtime-boundaries.md +6 -6
  208. package/docs/architecture/worker-and-feature.md +1 -1
  209. package/docs/init-surface.manifest.json +12 -30
  210. package/docs/skills/vetted-skill-registry.md +2 -4
  211. package/docs/templates/README.md +0 -2
  212. package/docs/templates/agent-dag-report.schema.json +2 -8
  213. package/docs/templates/agent-dag.schema.json +33 -1
  214. package/docs/templates/backend-test-dag.json +18 -18
  215. package/docs/templates/frontend-implementation-contract.schema.json +1 -4
  216. package/docs/templates/product-line/task.yaml +1 -1
  217. package/harness.json +3 -3
  218. package/package.json +3 -1
  219. package/skills/frontend-bounded-implement/SKILL.md +14 -15
  220. package/skills/frontend-design-review/SKILL.md +41 -22
  221. package/skills/frontend-implementation/SKILL.md +52 -0
  222. package/skills/frontend-implementation/references/code-standards.md +33 -0
  223. package/skills/frontend-implementation/references/design-spec.md +56 -0
  224. package/skills/frontend-implementation/references/node-contracts.md +31 -0
  225. package/skills/frontend-review/SKILL.md +15 -20
  226. package/skills/frontend-review/references/review-findings.md +7 -6
  227. package/skills/frontend-verification/SKILL.md +1 -1
  228. package/skills/loop-agent/references/command-reference.md +6 -1
  229. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  230. package/dist/commands/dag-follow-up.js +0 -138
  231. package/dist/executors/pi-read-budget-policy.js +0 -239
  232. package/dist/worker/console/frontend-human-decision-adapter.js +0 -19
  233. package/dist/worker/console/frontend-split-operation-adapter.js +0 -20
  234. package/dist/worker/console/operation-store.js +0 -389
  235. package/dist/worker/console/static/assets/channel-bnXW0U3C.js +0 -1
  236. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-rZpq_twR.js +0 -1
  237. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-rZpq_twR.js +0 -1
  238. package/dist/worker/console/static/assets/index-D3CC4eUz.css +0 -1
  239. package/dist/worker/console/static/assets/index-DLt4ZFvD.js +0 -437
  240. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-zBMYiUyi.js +0 -1
  241. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-d_rms-5i.js +0 -8
  242. package/dist/worker/console/static-src/active-run-badge.js +0 -17
  243. package/dist/worker/materialize/frontend-split-task-materializer.js +0 -72
  244. package/dist/workflows/dag/dag-retry-schema.js +0 -138
  245. package/dist/workflows/dag/frontend-closeout.js +0 -221
  246. package/dist/workflows/dag/frontend-design-policy.js +0 -400
  247. package/dist/workflows/dag/frontend-human-decision.js +0 -182
  248. package/dist/workflows/dag/frontend-provider-capability-matrix.js +0 -159
  249. package/dist/workflows/dag/frontend-read-budgets.js +0 -12
  250. package/dist/workflows/dag/frontend-recovery-capsule.js +0 -455
  251. package/dist/workflows/dag/frontend-recovery-controller.js +0 -202
  252. package/dist/workflows/dag/frontend-recovery-lineage.js +0 -178
  253. package/dist/workflows/dag/frontend-review-findings.js +0 -270
  254. package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -914
  255. package/dist/workflows/dag/frontend-shape-capsule-store.js +0 -191
  256. package/dist/workflows/dag/frontend-shape-facts.js +0 -409
  257. package/dist/workflows/dag/frontend-shape.js +0 -427
  258. package/dist/workflows/dag/frontend-source-fidelity-ledger.js +0 -108
  259. package/dist/workflows/dag/frontend-split-application-service.js +0 -203
  260. package/dist/workflows/dag/frontend-split-orchestrator.js +0 -899
  261. package/dist/workflows/dag/frontend-typed-event-store.js +0 -452
  262. package/dist/workflows/dag/frontend-typed-event-transaction.js +0 -180
  263. package/dist/workflows/dag/frontend-writer-admission.js +0 -285
  264. package/dist/workflows/dag/frontend-writer-status.js +0 -256
  265. package/dist/workflows/dag/recovery-lease.js +0 -80
  266. package/docs/templates/frontend-implementation-dag.json +0 -89
  267. package/docs/templates/spec-registry.schema.json +0 -45
  268. package/skills/frontend-bounded-implement/references/code-standards.md +0 -19
  269. package/skills/frontend-contract/SKILL.md +0 -23
  270. package/skills/frontend-contract/references/contract-protocol.md +0 -34
  271. package/skills/frontend-plan/SKILL.md +0 -26
  272. package/skills/frontend-plan/references/decision-contract.md +0 -37
  273. package/skills/frontend-plan/references/design-decisions.md +0 -17
  274. package/skills/frontend-scout/SKILL.md +0 -25
  275. package/skills/frontend-scout/references/design-evidence.md +0 -16
  276. package/skills/frontend-scout/references/scout-evidence.md +0 -23
  277. /package/dist/{worker → infrastructure}/console/repo-fingerprint.js +0 -0
@@ -124,13 +124,6 @@ export function isEphemeralToolCachePath(filePath) {
124
124
  if (normalized === ".coverage" || normalized.startsWith(".coverage.")) {
125
125
  return true;
126
126
  }
127
- // vite/vitest runtime caches live under node_modules/.vite (e.g.
128
- // node_modules/.vite/vitest/results.json). node_modules is the gitignored
129
- // dependency tree — tool cache writes there are never a writer's product
130
- // change and must not trip the write guard.
131
- if (normalized.startsWith("node_modules/.vite/")) {
132
- return true;
133
- }
134
127
  if (/(?:^|\/)__pycache__(?:\/|$)/.test(normalized))
135
128
  return true;
136
129
  if (/\.(?:pyc|pyo)$/.test(normalized))
@@ -35,6 +35,8 @@ export function openConsoleAppData(options) {
35
35
  fingerprint,
36
36
  drafts: path.join(repoNamespace, "drafts"),
37
37
  operations: path.join(repoNamespace, "operations"),
38
+ contextExports: path.join(repoNamespace, "context-exports"),
39
+ artifactRevisions: path.join(repoNamespace, "artifact-revisions"),
38
40
  confirmations: path.join(repoNamespace, "confirmations"),
39
41
  dagReceipts: path.join(repoNamespace, "dag-receipts"),
40
42
  staged: path.join(repoNamespace, "staged"),
@@ -48,6 +50,8 @@ export function openConsoleAppData(options) {
48
50
  for (const dir of [
49
51
  paths.drafts,
50
52
  paths.operations,
53
+ paths.contextExports,
54
+ paths.artifactRevisions,
51
55
  paths.confirmations,
52
56
  paths.dagReceipts,
53
57
  paths.staged,
@@ -0,0 +1,430 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { lstat, readdir, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { validateAndDescribeArtifactRevision, } from "../../shared/context-transfer/artifact-revision.js";
5
+ import { artifactRevisionV1Schema, computeOverrideSetHash, } from "../../shared/context-transfer.js";
6
+ import { assertSafeAppDataPath, ensureSecureDir, readJsonIfExists, writeSecureJson, } from "./app-data.js";
7
+ import { acquireDirLock, isDirLockStale, } from "./dir-lock.js";
8
+ import { readFileBoundarySafe } from "../../shared/path-safety.js";
9
+ export const DEFAULT_ARTIFACT_REVISION_TTL_MS = 60 * 60 * 1000;
10
+ /** Per-repo staging quota across uploads + staged candidates (M4b). */
11
+ export const ARTIFACT_REVISION_STAGING_QUOTA_BYTES = 200 * 1024 * 1024;
12
+ export const DEFAULT_REVISION_LEASE_TTL_MS = 5 * 60 * 1000;
13
+ /**
14
+ * Execute holds the revision across an entire child run (potentially hours);
15
+ * align with the Console default operation timeout instead of the short
16
+ * staging TTL.
17
+ */
18
+ export const EXECUTE_REVISION_LEASE_TTL_MS = 4 * 60 * 60 * 1000;
19
+ const STAGING_LOCK_DIR = ".staging-lock";
20
+ const STAGING_LOCK_STALE_MS = 30_000;
21
+ function stagingLockDirPath(appData) {
22
+ return path.join(root(appData), STAGING_LOCK_DIR);
23
+ }
24
+ function revisionMutationLockDirPath(appData, revisionId) {
25
+ if (!/^[a-f0-9]{32}$/.test(revisionId))
26
+ throw new Error("invalid revision id");
27
+ return assertSafeAppDataPath(root(appData), path.join(root(appData), "locks", `${revisionId}.lock`));
28
+ }
29
+ /**
30
+ * Cross-process repo-wide mutex around the quota "measure → decide → write"
31
+ * window. In-process promise chains alone leave a TOCTOU window between the
32
+ * Console upload route and sibling CLI stage processes; a stale-tolerant
33
+ * exclusive directory lock closes it.
34
+ *
35
+ * Ownership is token-guarded: each holder writes a unique owner file inside
36
+ * the lock directory and only ever removes a lock whose owner token matches
37
+ * its own. Release verifies owner + freshness BEFORE touching the path (B2):
38
+ * a stale lock is never moved by its former holder, so an old holder cannot
39
+ * race a successor's takeover and a third contender cannot acquire while the
40
+ * new holder is still in its critical section.
41
+ */
42
+ export async function acquireRepoStagingLock(appData, options) {
43
+ return acquireDirLock(stagingLockDirPath(appData), {
44
+ staleMs: options?.staleMs ?? STAGING_LOCK_STALE_MS,
45
+ token: options?.token,
46
+ });
47
+ }
48
+ async function withRepoStagingLock(appData, operation) {
49
+ const handle = await acquireRepoStagingLock(appData);
50
+ try {
51
+ return await operation();
52
+ }
53
+ finally {
54
+ await handle.release();
55
+ }
56
+ }
57
+ /**
58
+ * Cross-process per-revision serialization of metadata read-modify-write
59
+ * (B1). `serializeRevision` only serializes within one process; the Console
60
+ * and sibling CLI processes are separate processes that can both read
61
+ * metadata.json, mutate it, and write it back — losing an update (e.g. two
62
+ * executes, or an execute racing a discard, both "taking over" the lease).
63
+ * A per-revision directory lock held across the whole read-modify-write
64
+ * closes that window.
65
+ */
66
+ async function withRevisionMutationLock(appData, revisionId, operation) {
67
+ const lockDir = revisionMutationLockDirPath(appData, revisionId);
68
+ const handle = await acquireDirLock(lockDir);
69
+ try {
70
+ return await operation();
71
+ }
72
+ finally {
73
+ await handle.release();
74
+ }
75
+ }
76
+ function root(appData) {
77
+ if (!appData.artifactRevisions)
78
+ throw new Error("artifact revision root unavailable");
79
+ return assertSafeAppDataPath(appData.root, appData.artifactRevisions);
80
+ }
81
+ function dir(appData, revisionId) {
82
+ if (!/^[a-f0-9]{32}$/.test(revisionId))
83
+ throw new Error("invalid revision id");
84
+ return assertSafeAppDataPath(root(appData), path.join(root(appData), revisionId));
85
+ }
86
+ function metadataPath(appData, revisionId) {
87
+ return path.join(dir(appData, revisionId), "metadata.json");
88
+ }
89
+ export function artifactRevisionUploadPath(appData, candidateSha256) {
90
+ if (!/^[a-f0-9]{64}$/.test(candidateSha256)) {
91
+ throw new Error("invalid candidate upload hash");
92
+ }
93
+ const uploadsDir = assertSafeAppDataPath(root(appData), path.join(root(appData), "uploads"));
94
+ return assertSafeAppDataPath(uploadsDir, path.join(uploadsDir, `${candidateSha256}.bin`));
95
+ }
96
+ export async function totalArtifactRevisionStagingBytes(appData) {
97
+ let total = 0;
98
+ const stagingRoot = root(appData);
99
+ async function addFile(target) {
100
+ const stat = await lstat(target).catch(() => undefined);
101
+ if (stat?.isFile())
102
+ total += stat.size;
103
+ }
104
+ const uploadsDir = path.join(stagingRoot, "uploads");
105
+ const uploadsStat = await lstat(uploadsDir).catch(() => undefined);
106
+ if (uploadsStat?.isDirectory()) {
107
+ for (const entry of await readdir(uploadsDir)) {
108
+ if (!/^[a-f0-9]{64}\.bin$/.test(entry))
109
+ continue;
110
+ await addFile(path.join(uploadsDir, entry));
111
+ }
112
+ }
113
+ for (const name of await readdir(stagingRoot)) {
114
+ if (!/^[a-f0-9]{32}$/.test(name))
115
+ continue;
116
+ await addFile(path.join(stagingRoot, name, "candidate.bin"));
117
+ }
118
+ return total;
119
+ }
120
+ function assertStagingQuota(input) {
121
+ const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
122
+ if (!Number.isSafeInteger(quota) || quota < 1)
123
+ throw new Error("invalid staging quota");
124
+ if (input.incomingBytes > quota) {
125
+ throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: candidate exceeds staging quota");
126
+ }
127
+ }
128
+ export async function writeArtifactRevisionUpload(input) {
129
+ return withRepoStagingLock(input.appData, async () => {
130
+ const actual = createHash("sha256").update(input.candidateBytes).digest("hex");
131
+ if (actual !== input.candidateSha256) {
132
+ throw new Error("ARTIFACT_REVISION_DRIFT: candidate hash mismatch");
133
+ }
134
+ const uploadPath = artifactRevisionUploadPath(input.appData, input.candidateSha256);
135
+ // Idempotent upload (SF6): if the exact candidate already exists in the
136
+ // uploads dir, a retry of the same request must not consume quota again —
137
+ // the bytes are already accounted for. This prevents a spurious quota
138
+ // error when an idempotent retry arrives near the staging limit.
139
+ const existing = await lstat(uploadPath).catch(() => undefined);
140
+ if (existing) {
141
+ const existingBytes = await readFileBoundarySafe({
142
+ boundaryRoot: path.dirname(uploadPath),
143
+ targetPath: uploadPath,
144
+ label: "artifact revision upload",
145
+ });
146
+ if (createHash("sha256").update(existingBytes).digest("hex") !== input.candidateSha256) {
147
+ throw new Error("ARTIFACT_REVISION_DRIFT: existing staged upload hash mismatch");
148
+ }
149
+ return uploadPath;
150
+ }
151
+ assertStagingQuota({
152
+ appData: input.appData,
153
+ incomingBytes: input.candidateBytes.byteLength,
154
+ quotaBytes: input.quotaBytes,
155
+ });
156
+ const stagedBytes = await totalArtifactRevisionStagingBytes(input.appData);
157
+ const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
158
+ if (stagedBytes + input.candidateBytes.byteLength > quota) {
159
+ throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: repo staging quota exhausted");
160
+ }
161
+ ensureSecureDir(path.dirname(uploadPath));
162
+ await writeFile(uploadPath, input.candidateBytes, { flag: "wx", mode: 0o600 });
163
+ return uploadPath;
164
+ });
165
+ }
166
+ export async function createArtifactRevision(input) {
167
+ return withRepoStagingLock(input.appData, async () => {
168
+ const revisionId = randomBytes(16).toString("hex");
169
+ const revisionDir = dir(input.appData, revisionId);
170
+ ensureSecureDir(revisionDir);
171
+ const now = input.now ?? new Date();
172
+ const ttlMs = input.ttlMs ?? DEFAULT_ARTIFACT_REVISION_TTL_MS;
173
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
174
+ throw new Error("invalid revision TTL");
175
+ assertStagingQuota({
176
+ appData: input.appData,
177
+ incomingBytes: input.candidateBytes.byteLength,
178
+ quotaBytes: input.quotaBytes,
179
+ });
180
+ const stagedBytes = await totalArtifactRevisionStagingBytes(input.appData);
181
+ const quota = input.quotaBytes ?? ARTIFACT_REVISION_STAGING_QUOTA_BYTES;
182
+ if (stagedBytes + input.candidateBytes.byteLength > quota) {
183
+ throw new Error("ARTIFACT_REVISION_QUOTA_EXCEEDED: repo staging quota exhausted");
184
+ }
185
+ const stagingPath = `artifact-revisions/${revisionId}/candidate.bin`;
186
+ const described = validateAndDescribeArtifactRevision({
187
+ revisionId,
188
+ parentRunId: input.parentRunId,
189
+ producerNodeId: input.producerNodeId,
190
+ declaration: input.declaration,
191
+ parentSha256: input.parentSha256,
192
+ parentBytes: input.parentBytes,
193
+ candidateBytes: input.candidateBytes,
194
+ mediaType: input.mediaType,
195
+ stagingPath,
196
+ uploadedAt: now.toISOString(),
197
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
198
+ ...(input.artifactSchemaResolver
199
+ ? { artifactSchemaResolver: input.artifactSchemaResolver }
200
+ : {}),
201
+ });
202
+ await writeFile(path.join(revisionDir, "candidate.bin"), input.candidateBytes, {
203
+ flag: "wx",
204
+ mode: 0o600,
205
+ });
206
+ if (described.diffPreview) {
207
+ await writeFile(path.join(revisionDir, "diff.txt"), described.diffPreview, {
208
+ flag: "wx",
209
+ mode: 0o600,
210
+ });
211
+ }
212
+ await writeSecureJson(metadataPath(input.appData, revisionId), described.revision);
213
+ return described.revision;
214
+ });
215
+ }
216
+ export async function readArtifactRevision(appData, revisionId) {
217
+ const raw = await readJsonIfExists(metadataPath(appData, revisionId));
218
+ if (raw === undefined)
219
+ return undefined;
220
+ return artifactRevisionV1Schema.parse(raw);
221
+ }
222
+ /**
223
+ * Explicit staging lease (M4c). A fresh lease held by another holder blocks
224
+ * conflicting mutations with LEASE_HELD; the same holder may renew (sliding
225
+ * TTL); a stale/expired lease is taken over. Recorded in metadata.json so a
226
+ * crashed holder stays visible until the TTL lapses.
227
+ */
228
+ export async function acquireArtifactRevisionLease(input) {
229
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
230
+ const current = await readArtifactRevision(input.appData, input.revisionId);
231
+ if (!current || current.state !== "validated") {
232
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
233
+ }
234
+ const now = input.now ?? new Date();
235
+ const ttlMs = input.ttlMs ?? DEFAULT_REVISION_LEASE_TTL_MS;
236
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
237
+ throw new Error("invalid lease TTL");
238
+ const existing = current.lease;
239
+ if (existing && Date.parse(existing.expiresAt) > now.getTime() && existing.holder !== input.holder) {
240
+ throw new Error("LEASE_HELD");
241
+ }
242
+ const leased = artifactRevisionV1Schema.parse({
243
+ ...current,
244
+ lease: {
245
+ holder: input.holder,
246
+ acquiredAt: now.toISOString(),
247
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
248
+ },
249
+ });
250
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), leased);
251
+ return leased;
252
+ });
253
+ }
254
+ export async function releaseArtifactRevisionLease(input) {
255
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
256
+ const current = await readArtifactRevision(input.appData, input.revisionId);
257
+ if (!current)
258
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
259
+ if (!current.lease)
260
+ return current;
261
+ if (current.lease.holder !== input.holder)
262
+ throw new Error("LEASE_HELD");
263
+ const { lease: _released, ...withoutLease } = current;
264
+ const released = artifactRevisionV1Schema.parse(withoutLease);
265
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), released);
266
+ return released;
267
+ });
268
+ }
269
+ export async function readArtifactRevisionCandidate(input) {
270
+ if (input.revision.state !== "validated")
271
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
272
+ if (Date.parse(input.revision.expiresAt) <= (input.now ?? new Date()).getTime()) {
273
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
274
+ }
275
+ const candidatePath = path.join(dir(input.appData, input.revision.revisionId), "candidate.bin");
276
+ // fd-based TOCTOU-safe read: open with O_NOFOLLOW and verify the opened fd
277
+ // (regular single-link) instead of lstat-then-readFile on the path. Map any
278
+ // path-safety rejection back to the stable ARTIFACT_REVISION_PATH_INVALID
279
+ // contract.
280
+ let bytes;
281
+ try {
282
+ bytes = await readFileBoundarySafe({
283
+ boundaryRoot: dir(input.appData, input.revision.revisionId),
284
+ targetPath: candidatePath,
285
+ label: `revision candidate ${input.revision.revisionId}`,
286
+ });
287
+ }
288
+ catch (error) {
289
+ const message = error instanceof Error ? error.message : String(error);
290
+ if (/symlink|hardlink|single-link|regular|boundary|escap/i.test(message)) {
291
+ throw new Error("ARTIFACT_REVISION_PATH_INVALID");
292
+ }
293
+ throw error;
294
+ }
295
+ const actual = await import("node:crypto").then(({ createHash }) => createHash("sha256").update(bytes).digest("hex"));
296
+ if (actual !== input.revision.candidate.sha256 || bytes.byteLength !== input.revision.candidate.bytes) {
297
+ throw new Error("ARTIFACT_REVISION_DRIFT");
298
+ }
299
+ return bytes;
300
+ }
301
+ export function buildArtifactOverrideSet(revision) {
302
+ const overrides = [{
303
+ revisionId: revision.revisionId,
304
+ parentArtifact: {
305
+ runId: revision.parent.runId,
306
+ nodeId: revision.parent.nodeId,
307
+ path: revision.parent.artifactPath,
308
+ sha256: revision.parent.artifactSha256,
309
+ },
310
+ replacement: {
311
+ sha256: revision.candidate.sha256,
312
+ bytes: revision.candidate.bytes,
313
+ mediaType: revision.candidate.mediaType,
314
+ },
315
+ supersedes: revision.parent.artifactSha256,
316
+ }];
317
+ return { schemaVersion: 1, overrides, overrideSetHash: computeOverrideSetHash(overrides) };
318
+ }
319
+ export async function consumeArtifactRevision(input) {
320
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
321
+ const current = await readArtifactRevision(input.appData, input.revisionId);
322
+ if (!current || current.state !== "validated")
323
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
324
+ const now = input.now ?? new Date();
325
+ const existing = current.lease;
326
+ if (existing &&
327
+ Date.parse(existing.expiresAt) > now.getTime() &&
328
+ existing.holder !== (input.holder ?? "cli-mutation")) {
329
+ throw new Error("LEASE_HELD");
330
+ }
331
+ if (current.candidate.sha256 !== input.expectedSha256)
332
+ throw new Error("ARTIFACT_REVISION_DRIFT");
333
+ // Terminal states must never keep a live staging lock.
334
+ const consumed = artifactRevisionV1Schema.parse({
335
+ ...current,
336
+ state: "consumed",
337
+ lease: undefined,
338
+ });
339
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), consumed);
340
+ return consumed;
341
+ });
342
+ }
343
+ export async function discardArtifactRevision(input) {
344
+ return withRevisionMutationLock(input.appData, input.revisionId, async () => {
345
+ const current = await readArtifactRevision(input.appData, input.revisionId);
346
+ if (!current)
347
+ throw new Error("ARTIFACT_REVISION_EXPIRED");
348
+ const now = input.now ?? new Date();
349
+ if (current.state === "discarded")
350
+ return current;
351
+ if (current.state === "consumed")
352
+ throw new Error("ARTIFACT_REVISION_ALREADY_CONSUMED");
353
+ const existing = current.lease;
354
+ if (existing &&
355
+ Date.parse(existing.expiresAt) > now.getTime() &&
356
+ existing.holder !== (input.holder ?? "cli-mutation")) {
357
+ throw new Error("LEASE_HELD");
358
+ }
359
+ const discarded = artifactRevisionV1Schema.parse({
360
+ ...current,
361
+ state: "discarded",
362
+ lease: undefined,
363
+ });
364
+ await writeSecureJson(metadataPath(input.appData, input.revisionId), discarded);
365
+ await Promise.all([
366
+ rm(path.join(dir(input.appData, input.revisionId), "candidate.bin"), { force: true }),
367
+ rm(path.join(dir(input.appData, input.revisionId), "diff.txt"), { force: true }),
368
+ ]);
369
+ return discarded;
370
+ });
371
+ }
372
+ export async function reconcileArtifactRevisions(input) {
373
+ ensureSecureDir(root(input.appData));
374
+ const now = input.now ?? new Date();
375
+ const removed = [];
376
+ for (const name of await readdir(root(input.appData))) {
377
+ if (!/^[a-f0-9]{32}$/.test(name))
378
+ continue;
379
+ const revision = await readArtifactRevision(input.appData, name).catch(() => undefined);
380
+ if (!revision || Date.parse(revision.expiresAt) <= now.getTime()) {
381
+ await rm(dir(input.appData, name), { recursive: true, force: true });
382
+ removed.push(name);
383
+ }
384
+ }
385
+ const uploadsDir = path.join(root(input.appData), "uploads");
386
+ const uploadsBefore = await lstat(uploadsDir).catch(() => undefined);
387
+ if (uploadsBefore?.isDirectory()) {
388
+ const cutoff = now.getTime() - DEFAULT_ARTIFACT_REVISION_TTL_MS;
389
+ for (const entry of await readdir(uploadsDir)) {
390
+ if (!/^[a-f0-9]{64}\.bin$/.test(entry))
391
+ continue;
392
+ const stat = await lstat(path.join(uploadsDir, entry)).catch(() => undefined);
393
+ if (!stat?.isFile() || stat.mtimeMs > cutoff)
394
+ continue;
395
+ await rm(path.join(uploadsDir, entry), { force: true });
396
+ removed.push(`uploads/${entry}`);
397
+ }
398
+ }
399
+ // Sweep orphaned staging-lock tombstones (takeover `.tomb` and release
400
+ // `.release` dirs left behind by a race) once they are older than the
401
+ // default TTL. Tombstones are never the live lock path.
402
+ const cutoff = now.getTime() - DEFAULT_ARTIFACT_REVISION_TTL_MS;
403
+ for (const entry of await readdir(root(input.appData))) {
404
+ if (!/^\.staging-lock\..+\.(?:tomb|release)$/.test(entry))
405
+ continue;
406
+ const stat = await lstat(path.join(root(input.appData), entry)).catch(() => undefined);
407
+ if (!stat?.isDirectory() || stat.mtimeMs > cutoff)
408
+ continue;
409
+ await rm(path.join(root(input.appData), entry), { recursive: true, force: true });
410
+ removed.push(`lock/${entry}`);
411
+ }
412
+ // Sweep orphaned per-revision mutation lock dirs (crashed holder) once they
413
+ // are older than the default TTL. The per-revision lock path is stable
414
+ // (`locks/<revisionId>.lock`); a fresh lock is never swept.
415
+ const locksDir = path.join(root(input.appData), "locks");
416
+ const locksStat = await lstat(locksDir).catch(() => undefined);
417
+ if (locksStat?.isDirectory()) {
418
+ for (const entry of await readdir(locksDir)) {
419
+ if (!/^[a-f0-9]{32}\.lock$/.test(entry))
420
+ continue;
421
+ const lockPath = path.join(locksDir, entry);
422
+ const stat = await lstat(lockPath).catch(() => undefined);
423
+ if (!stat?.isDirectory() || !await isDirLockStale(lockPath, DEFAULT_ARTIFACT_REVISION_TTL_MS, now.getTime()))
424
+ continue;
425
+ await rm(lockPath, { recursive: true, force: true });
426
+ removed.push(`lock/${entry}`);
427
+ }
428
+ }
429
+ return removed;
430
+ }
@@ -0,0 +1,160 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { chmod, mkdir, open, readdir, rm, writeFile, } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { assertSafeAppDataPath, ensureSecureDir, readJsonIfExists, writeSecureJson, } from "./app-data.js";
6
+ export const DEFAULT_CONTEXT_EXPORT_TTL_MS = 60 * 60 * 1000;
7
+ export const DEFAULT_CONTEXT_EXPORT_LEASE_TTL_MS = 5 * 60 * 1000;
8
+ export const CONTEXT_EXPORT_SCHEMA_VERSION = 1;
9
+ const exportWriteChains = new Map();
10
+ function serializeExport(exportId, operation) {
11
+ const previous = exportWriteChains.get(exportId) ?? Promise.resolve();
12
+ const next = previous.then(operation, operation);
13
+ exportWriteChains.set(exportId, next.then(() => undefined, () => undefined));
14
+ return next;
15
+ }
16
+ function exportRoot(appData) {
17
+ const root = appData.contextExports;
18
+ if (!root)
19
+ throw new Error("console context export root unavailable");
20
+ return assertSafeAppDataPath(appData.root, root);
21
+ }
22
+ function exportDir(appData, exportId) {
23
+ if (!/^[a-f0-9]{32}$/.test(exportId))
24
+ throw new Error("invalid context export id");
25
+ return assertSafeAppDataPath(exportRoot(appData), path.join(exportRoot(appData), exportId));
26
+ }
27
+ function metadataPath(appData, exportId) {
28
+ return path.join(exportDir(appData, exportId), "metadata.json");
29
+ }
30
+ export async function createContextExport(input) {
31
+ const now = input.now ?? new Date();
32
+ const ttlMs = input.ttlMs ?? DEFAULT_CONTEXT_EXPORT_TTL_MS;
33
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 1)
34
+ throw new Error("invalid context export TTL");
35
+ const exportId = randomBytes(16).toString("hex");
36
+ const dir = exportDir(input.appData, exportId);
37
+ ensureSecureDir(dir);
38
+ const payloadPath = assertSafeAppDataPath(dir, path.join(dir, "bundle.zip"));
39
+ await writeFile(payloadPath, input.bundle.zipBytes, {
40
+ flag: "wx",
41
+ mode: 0o600,
42
+ });
43
+ await chmod(payloadPath, 0o600).catch(() => undefined);
44
+ const record = {
45
+ schemaVersion: CONTEXT_EXPORT_SCHEMA_VERSION,
46
+ exportId,
47
+ repoFingerprint: input.appData.fingerprint,
48
+ bundleId: input.bundle.manifest.bundleId,
49
+ bundleSha256: input.bundle.manifest.bundleSha256,
50
+ profile: input.bundle.manifest.profile,
51
+ target: input.bundle.manifest.target,
52
+ state: "ready",
53
+ createdAt: now.toISOString(),
54
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
55
+ bytes: input.bundle.zipBytes.byteLength,
56
+ };
57
+ await writeSecureJson(metadataPath(input.appData, exportId), record);
58
+ return record;
59
+ }
60
+ export async function readContextExport(appData, exportId) {
61
+ return readJsonIfExists(metadataPath(appData, exportId));
62
+ }
63
+ export async function acquireContextExportLease(input) {
64
+ return serializeExport(input.exportId, async () => {
65
+ const now = input.now ?? new Date();
66
+ const leaseTtlMs = input.leaseTtlMs ?? DEFAULT_CONTEXT_EXPORT_LEASE_TTL_MS;
67
+ if (!Number.isSafeInteger(leaseTtlMs) || leaseTtlMs < 1) {
68
+ throw new Error("invalid context export lease TTL");
69
+ }
70
+ const record = await readContextExport(input.appData, input.exportId);
71
+ if (!record || record.repoFingerprint !== input.appData.fingerprint) {
72
+ throw new Error("context export not found");
73
+ }
74
+ const expiresAt = Date.parse(record.expiresAt);
75
+ if (record.state !== "ready" ||
76
+ !Number.isFinite(expiresAt) ||
77
+ expiresAt <= now.getTime()) {
78
+ throw new Error("context export expired");
79
+ }
80
+ if (record.lease) {
81
+ const acquiredAt = Date.parse(record.lease.acquiredAt);
82
+ if (Number.isFinite(acquiredAt) &&
83
+ acquiredAt + leaseTtlMs > now.getTime()) {
84
+ throw new Error("context export already leased");
85
+ }
86
+ }
87
+ const payloadPath = path.join(exportDir(input.appData, input.exportId), "bundle.zip");
88
+ const handle = await open(payloadPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
89
+ let bytes;
90
+ try {
91
+ const stat = await handle.stat();
92
+ if (!stat.isFile() || stat.nlink !== 1) {
93
+ throw new Error("context export payload is not an owned regular file");
94
+ }
95
+ bytes = await handle.readFile();
96
+ }
97
+ finally {
98
+ await handle.close();
99
+ }
100
+ const leaseId = randomBytes(16).toString("hex");
101
+ const leased = {
102
+ ...record,
103
+ lease: { leaseId, acquiredAt: now.toISOString() },
104
+ };
105
+ await writeSecureJson(metadataPath(input.appData, input.exportId), leased);
106
+ return { record: leased, leaseId, bytes };
107
+ });
108
+ }
109
+ export async function releaseContextExportLease(input) {
110
+ await serializeExport(input.exportId, async () => {
111
+ const record = await readContextExport(input.appData, input.exportId);
112
+ if (!record?.lease || record.lease.leaseId !== input.leaseId)
113
+ return;
114
+ const { lease: _lease, ...released } = record;
115
+ await writeSecureJson(metadataPath(input.appData, input.exportId), released);
116
+ });
117
+ }
118
+ export async function reconcileContextExports(input) {
119
+ const root = exportRoot(input.appData);
120
+ await mkdir(root, { recursive: true, mode: 0o700 });
121
+ const now = input.now ?? new Date();
122
+ const leaseTtlMs = input.leaseTtlMs ?? DEFAULT_CONTEXT_EXPORT_LEASE_TTL_MS;
123
+ if (!Number.isSafeInteger(leaseTtlMs) || leaseTtlMs < 1) {
124
+ throw new Error("invalid context export lease TTL");
125
+ }
126
+ const removed = [];
127
+ const retainedLeased = [];
128
+ for (const name of await readdir(root)) {
129
+ if (!/^[a-f0-9]{32}$/.test(name))
130
+ continue;
131
+ const record = await readContextExport(input.appData, name).catch(() => undefined);
132
+ let reconciledRecord = record;
133
+ if (record?.lease) {
134
+ const acquiredAt = Date.parse(record.lease.acquiredAt);
135
+ if (Number.isFinite(acquiredAt) &&
136
+ acquiredAt + leaseTtlMs > now.getTime()) {
137
+ retainedLeased.push(name);
138
+ continue;
139
+ }
140
+ const { lease: _lease, ...released } = record;
141
+ reconciledRecord = released;
142
+ }
143
+ const expiresAt = reconciledRecord
144
+ ? Date.parse(reconciledRecord.expiresAt)
145
+ : Number.NaN;
146
+ if (!reconciledRecord ||
147
+ !Number.isFinite(expiresAt) ||
148
+ expiresAt <= now.getTime()) {
149
+ await rm(exportDir(input.appData, name), {
150
+ recursive: true,
151
+ force: true,
152
+ });
153
+ removed.push(name);
154
+ }
155
+ else if (record?.lease) {
156
+ await writeSecureJson(metadataPath(input.appData, name), reconciledRecord);
157
+ }
158
+ }
159
+ return { removed, retainedLeased };
160
+ }