@tea-agent/loop-agent 0.41.1-beta.0 → 0.42.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -207
- package/dist/adapters/context-transfer/optional-pi-handoff.js +31 -0
- package/dist/adapters/context-transfer/pi-session.js +61 -0
- package/dist/application/dag/generate-task-dag.js +19 -75
- package/dist/application/task-lifecycle/advance.js +5 -24
- package/dist/application/task-lifecycle/observe.js +17 -171
- package/dist/application/task-lifecycle/plan-transitions.js +7 -42
- package/dist/application/task-lifecycle/recommendations.js +2 -13
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +12 -7
- package/dist/cli/program.js +23 -6
- package/dist/commands/client-recovery.js +0 -3
- package/dist/commands/dag-artifact.js +284 -0
- package/dist/commands/dag-context.js +184 -0
- package/dist/commands/dag-rerun.js +206 -1
- package/dist/commands/init.js +1 -27
- package/dist/commands/task-advance.js +0 -19
- package/dist/executors/dag-pi-executor.js +266 -3792
- package/dist/executors/pi-executor.js +4 -15
- package/dist/executors/pi-sdk-executor.js +3 -0
- package/dist/executors/shell-executor.js +217 -945
- package/dist/executors/shell-write-guard.js +0 -7
- package/dist/{worker → infrastructure}/console/app-data.js +4 -0
- package/dist/infrastructure/console/artifact-revision-store.js +430 -0
- package/dist/infrastructure/console/context-export-store.js +160 -0
- package/dist/infrastructure/console/dir-lock.js +132 -0
- package/dist/infrastructure/console/operation-store.js +197 -0
- package/dist/infrastructure/harness/artifact-store.js +10 -1
- package/dist/infrastructure/harness/atomic-write.js +12 -2
- package/dist/shared/context-transfer/artifact-revision.js +172 -0
- package/dist/shared/context-transfer.js +418 -0
- package/dist/shared/dag-failure-category.js +0 -12
- package/dist/shared/openspec-spec.js +4 -70
- package/dist/shared/operator/capabilities.js +14 -7
- package/dist/shared/operator/safe-run-summary.js +1 -0
- package/dist/shared/path-safety.js +93 -0
- package/dist/shared/preview.js +28 -4
- package/dist/task/config-types.js +9 -35
- package/dist/task/contract/adopt.js +0 -4
- package/dist/task/contract/import-revision.js +0 -4
- package/dist/task/contract/project.js +0 -3
- package/dist/task/contract/schema.js +1 -2
- package/dist/task/frontend-project-capability.js +20 -203
- package/dist/task/runtime.js +2 -5
- package/dist/task/source-prepare/build-draft.js +3 -3
- package/dist/task/source-prepare/fragment-inventory.js +9 -15
- package/dist/task/source-prepare/prepare.js +1 -89
- package/dist/task/source-prepare/semantic-intake.js +2 -6
- package/dist/task/source-references.js +1 -22
- package/dist/task/task-demand-routing.js +3 -0
- package/dist/worker/console/chat/chat-event-store.js +2 -2
- package/dist/worker/console/chat/pi-runtime.js +3 -2
- package/dist/worker/console/chat/resource-preferences-store.js +1 -1
- package/dist/worker/console/chat/routes.js +13 -4
- package/dist/worker/console/chat/semantic-activity.js +12 -11
- package/dist/worker/console/chat/session-stats.js +96 -0
- package/dist/worker/console/chat/session-store.js +1 -1
- package/dist/worker/console/chat/turn-process.js +28 -4
- package/dist/worker/console/chat/user-questions.js +1 -1
- package/dist/worker/console/console-update-runtime.js +1 -1
- package/dist/worker/console/context-transfer-diagnostics.js +198 -0
- package/dist/worker/console/dag-confirmation.js +1 -1
- package/dist/worker/console/dag-execution-receipt.js +1 -1
- package/dist/worker/console/doctor.js +1 -1
- package/dist/worker/console/draft-store.js +1 -1
- package/dist/worker/console/human-gate-token.js +1 -1
- package/dist/worker/console/index.js +3 -6
- package/dist/worker/console/interview/assessment.js +1 -1
- package/dist/worker/console/interview/session.js +1 -1
- package/dist/worker/console/operation-runner.js +45 -4
- package/dist/worker/console/operation-sse.js +1 -1
- package/dist/worker/console/operation-wait.js +1 -1
- package/dist/worker/console/operator-actions.js +107 -168
- package/dist/worker/console/operator-surface-health.js +1 -1
- package/dist/worker/console/operator-user-error.js +169 -0
- package/dist/worker/console/pi-plugins.js +60 -1
- package/dist/worker/console/routes.js +637 -8
- package/dist/worker/console/security.js +35 -0
- package/dist/worker/console/server.js +6 -6
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-Bho4we4c.js → abnfDiagram-N423BO3Z-DZZ8m3PO.js} +1 -1
- package/dist/worker/console/static/assets/{arc-TBDkTeK0.js → arc-D6PvaVd-.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BF_fZrn2.js → architectureDiagram-T3A2C74G-B_OTOiI8.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-CCnPgUe2.js → blockDiagram-VBNYF7ZC-Bv6rqHBg.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-BCYZJLAT.js → c4Diagram-5PPSVZJV-B8eHr0oz.js} +1 -1
- package/dist/worker/console/static/assets/channel-BU5gOilw.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-D8K6adhU.js → chunk-2GRJ4B5K-DYwR0im2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-DkT1Q78b.js → chunk-2Q5K7J3B-D2WPGqXt.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DuhvpIr7.js → chunk-5RXB4S5H-CQISJ_I7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-BnK6dC49.js → chunk-5VM5RSS4-C0o2Du1e.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DPz__z7r.js → chunk-6Q2QTUOP-4f8kr-U7.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-0kkppM2j.js → chunk-GF5L2VYU-D_OWvXzX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-BB41iK5t.js → chunk-JWPE2WC7-CasdPz5X.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CiVrxKHU.js → chunk-KBJHAD2P-Cj8lRrla.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-C2h9Xje1.js → chunk-RYQCIY6F-CUvD1FSd.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-3iOSHdbM.js → chunk-XXDRQBXY-CqLw_eqB.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-BN3A7tkk.js → cose-bilkent-JH36ORCC-ku-WqJPx.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-CTuBy0Or.js → cynefin-VYW2F7L2-Bq_PYDhl.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-wb6tUlMN.js → cynefinDiagram-MW4NZA55-BiGZV641.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-GP1Ivx8B.js → dagre-VZM6K2ZE-C4JRlglF.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-ejrcK6L0.js → diagram-7IWD3JNH-B0dNeWYG.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-YFkCCvUU.js → diagram-B4RE2ZJO-Cj35YvYM.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-B4rrvIaL.js → diagram-LBJQPF4R-uzQoJ2-8.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-C-YIZcUf.js → diagram-Q27KOJAE-D1a-Buoz.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPF5wcL1.js → diagram-UB23O5K3-XjRrLRSs.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-CU5SzXjO.js → ebnfDiagram-BXEA7PRR-Da_O24cW.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-CpL414DE.js → erDiagram-JOGREHBK-BLJ8jrYU.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-MrhLzNau.js → flowDiagram-UKHOOZJN-B9GrLjM0.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-Bg4qFuUo.js → ganttDiagram-PKOTCBZU-CeJ0TqiK.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-ColxSXLR.js → gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js} +1 -1
- package/dist/worker/console/static/assets/index-H9rFJiGL.css +1 -0
- package/dist/worker/console/static/assets/index-xwu9GxEc.js +451 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-Czvtm6gc.js → infoDiagram-6WML65LV-DB26i3d8.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-B7Xp8HyJ.js → ishikawaDiagram-WSZJBQD7-BOGRyeZT.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DWYZcvph.js → journeyDiagram-NVQOT4AX-CZRoBO_6.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-KTLZRUjx.js → kanban-definition-27J2QSJJ-qlWhJyoB.js} +1 -1
- package/dist/worker/console/static/assets/{linear-_Exn7bfl.js → linear-CfUiDDB3.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-Cxg8kX8V.js → mermaid.core-BQe6fpqj.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-oKrlo2Bt.js → mindmap-definition-FAOFIHXS-zFzWHw64.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-XAeteiNd.js → pegDiagram-VL7TDLO6-DFGUqjZO.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BTf1K-lp.js → pieDiagram-7S7Q4E2Y-BB5i0l8Z.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-C0IlY0Jf.js → quadrantDiagram-CIZ2JOQS-obbVZ_X5.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BVbKtZhW.js → railroadDiagram-AXF67PYL-BwdaNzc1.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-C5e4JFcr.js → requirementDiagram-LRYGKXZP-CkTJaugh.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-Cz30hc29.js → sankeyDiagram-W5VNT64P-BwJ-hIgq.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DS33n6d0.js → sequenceDiagram-SI44F4Z6-BzSgmm7w.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-46w7v-9T.js → sizeCapture-X5ZJPWSS-Bn3oj5Q1.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-pbcD5SsZ.js → stateDiagram-OKZ733FA-dxhGtL8K.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DuivYBSj.js → swimlanes-SLNWSIFB-BLpEInya.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-Cb9VZVIl.js → timeline-definition-Z64GVDOM-ChJGYcXy.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-D2-mfOgR.js → vennDiagram-T6HMQDX7-DK-qjRer.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DeS2aC76.js → wardleyDiagram-T6FBY63Y-3qeaWAg-.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-WGsrFjxH.js → xychartDiagram-ELKLHX3M-CootlyP9.js} +1 -1
- package/dist/worker/console/static/index.html +7 -2
- package/dist/worker/console/static-src/app/useOperatorActions.js +3 -2
- package/dist/worker/console/static-src/app/useRunProgress.js +9 -2
- package/dist/worker/console/static-src/operator-chat/dag-progress-link.js +22 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +2 -0
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +6 -0
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +28 -3
- package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +106 -0
- package/dist/worker/console/static-src/shell/console-update-reload.js +25 -0
- package/dist/worker/console/workspace-context.js +28 -112
- package/dist/worker/console/workspace-registry.js +2 -2
- package/dist/worker/continuation/worker-continuation.js +278 -0
- package/dist/worker/observability/read-model.js +4 -0
- package/dist/worker/observe/health.js +1 -1
- package/dist/worker/observe/node-transparency.js +572 -0
- package/dist/worker/observe/routes.js +51 -1
- package/dist/worker/observe/static/api.js +69 -5
- package/dist/worker/observe/static/dag-context-reason-labels.d.ts +9 -0
- package/dist/worker/observe/static/dag-context-reason-labels.js +120 -0
- package/dist/worker/observe/static/dag-node-purpose.js +0 -5
- package/dist/worker/observe/static/state.js +3 -1
- package/dist/worker/observe/static/styles.css +1037 -126
- package/dist/worker/observe/static/views/dag-inspector.js +1217 -65
- package/dist/worker/observe/static/views/session-timeline.js +121 -25
- package/dist/worker/outcomes/adapters.js +19 -0
- package/dist/worker/outcomes/types.js +1 -0
- package/dist/worker/pool/attempt-lease.js +97 -66
- package/dist/worker/pool/begin-attempt-with-lease.js +1 -0
- package/dist/worker/pool/reconcile.js +46 -1
- package/dist/worker/pool/run-store.js +2 -0
- package/dist/worker/pool/state-projection.js +2 -0
- package/dist/worker/task-spec/workflow-routing.js +8 -3
- package/dist/workflows/dag/artifact-bindings.js +149 -0
- package/dist/workflows/dag/artifact-revision-schema-registry.js +31 -0
- package/dist/workflows/dag/backend-test-result-contract.js +1 -1
- package/dist/workflows/dag/budget-enforcement.js +10 -0
- package/dist/workflows/dag/context-receipt.js +305 -0
- package/dist/workflows/dag/context-transfer/context-bundle.js +423 -0
- package/dist/workflows/dag/context-transfer/operator-actions.js +61 -0
- package/dist/workflows/dag/context-transfer/renderers.js +93 -0
- package/dist/workflows/dag/contract-validator-registrations.js +2 -1
- package/dist/workflows/dag/frontend-implementation-contract.js +162 -932
- package/dist/workflows/dag/frontend-plan-render.js +1 -2
- package/dist/workflows/dag/frontend-prewrite-gate.js +349 -255
- package/dist/workflows/dag/frontend-recovery-plan.js +10 -17
- package/dist/workflows/dag/frontend-recovery-run.js +20 -33
- package/dist/workflows/dag/frontend-repair.js +432 -1
- package/dist/workflows/dag/frontend-review-context.js +15 -261
- package/dist/workflows/dag/frontend-verification-trace.js +24 -249
- package/dist/workflows/dag/frontend-worktree-diff.js +17 -250
- package/dist/workflows/dag/frontend-writer-rollback.js +32 -0
- package/dist/workflows/dag/init-hybrid.js +640 -685
- package/dist/workflows/dag/interrupt-request.js +0 -7
- package/dist/workflows/dag/node-execution.js +116 -759
- package/dist/workflows/dag/path-safety.js +1 -0
- package/dist/workflows/dag/prompt.js +30 -38
- package/dist/workflows/dag/report.js +1 -37
- package/dist/workflows/dag/rerun-feedback.js +1 -256
- package/dist/workflows/dag/rerun-plan.js +557 -11
- package/dist/workflows/dag/rerun-run.js +601 -38
- package/dist/workflows/dag/rerun-task.js +0 -29
- package/dist/workflows/dag/retry-policy.js +104 -214
- package/dist/workflows/dag/runner.js +142 -430
- package/dist/workflows/dag/scheduler.js +16 -114
- package/dist/workflows/dag/skill-snapshot.js +17 -0
- package/dist/workflows/dag/types.js +195 -246
- package/dist/workflows/dag/validate.js +12 -28
- package/docs/architecture/runtime-boundaries.md +6 -6
- package/docs/architecture/worker-and-feature.md +1 -1
- package/docs/init-surface.manifest.json +12 -30
- package/docs/skills/vetted-skill-registry.md +2 -4
- package/docs/templates/README.md +0 -2
- package/docs/templates/agent-dag-report.schema.json +2 -8
- package/docs/templates/agent-dag.schema.json +33 -1
- package/docs/templates/frontend-implementation-contract.schema.json +1 -4
- package/docs/templates/product-line/task.yaml +1 -1
- package/harness.json +3 -3
- package/package.json +3 -1
- package/skills/frontend-bounded-implement/SKILL.md +14 -15
- package/skills/frontend-design-review/SKILL.md +41 -22
- package/skills/frontend-implementation/SKILL.md +52 -0
- package/skills/frontend-implementation/references/code-standards.md +33 -0
- package/skills/frontend-implementation/references/design-spec.md +56 -0
- package/skills/frontend-implementation/references/node-contracts.md +31 -0
- package/skills/frontend-review/SKILL.md +15 -20
- package/skills/frontend-review/references/review-findings.md +7 -6
- package/skills/frontend-verification/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +6 -1
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/dist/commands/dag-follow-up.js +0 -138
- package/dist/executors/pi-read-budget-policy.js +0 -239
- package/dist/worker/console/frontend-human-decision-adapter.js +0 -19
- package/dist/worker/console/frontend-split-operation-adapter.js +0 -20
- package/dist/worker/console/operation-store.js +0 -389
- package/dist/worker/console/static/assets/channel-bnXW0U3C.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-rZpq_twR.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-rZpq_twR.js +0 -1
- package/dist/worker/console/static/assets/index-D3CC4eUz.css +0 -1
- package/dist/worker/console/static/assets/index-DLt4ZFvD.js +0 -437
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-zBMYiUyi.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-d_rms-5i.js +0 -8
- package/dist/worker/console/static-src/active-run-badge.js +0 -17
- package/dist/worker/materialize/frontend-split-task-materializer.js +0 -72
- package/dist/workflows/dag/dag-retry-schema.js +0 -138
- package/dist/workflows/dag/frontend-closeout.js +0 -221
- package/dist/workflows/dag/frontend-design-policy.js +0 -400
- package/dist/workflows/dag/frontend-human-decision.js +0 -182
- package/dist/workflows/dag/frontend-provider-capability-matrix.js +0 -159
- package/dist/workflows/dag/frontend-read-budgets.js +0 -12
- package/dist/workflows/dag/frontend-recovery-capsule.js +0 -455
- package/dist/workflows/dag/frontend-recovery-controller.js +0 -202
- package/dist/workflows/dag/frontend-recovery-lineage.js +0 -178
- package/dist/workflows/dag/frontend-review-findings.js +0 -270
- package/dist/workflows/dag/frontend-shadow-dual-write.js +0 -914
- package/dist/workflows/dag/frontend-shape-capsule-store.js +0 -191
- package/dist/workflows/dag/frontend-shape-facts.js +0 -409
- package/dist/workflows/dag/frontend-shape.js +0 -427
- package/dist/workflows/dag/frontend-source-fidelity-ledger.js +0 -108
- package/dist/workflows/dag/frontend-split-application-service.js +0 -203
- package/dist/workflows/dag/frontend-split-orchestrator.js +0 -899
- package/dist/workflows/dag/frontend-typed-event-store.js +0 -452
- package/dist/workflows/dag/frontend-typed-event-transaction.js +0 -180
- package/dist/workflows/dag/frontend-writer-admission.js +0 -285
- package/dist/workflows/dag/frontend-writer-status.js +0 -256
- package/dist/workflows/dag/recovery-lease.js +0 -80
- package/docs/templates/frontend-implementation-dag.json +0 -89
- package/docs/templates/spec-registry.schema.json +0 -45
- package/skills/frontend-bounded-implement/references/code-standards.md +0 -19
- package/skills/frontend-contract/SKILL.md +0 -23
- package/skills/frontend-contract/references/contract-protocol.md +0 -34
- package/skills/frontend-plan/SKILL.md +0 -26
- package/skills/frontend-plan/references/decision-contract.md +0 -37
- package/skills/frontend-plan/references/design-decisions.md +0 -17
- package/skills/frontend-scout/SKILL.md +0 -25
- package/skills/frontend-scout/references/design-evidence.md +0 -16
- package/skills/frontend-scout/references/scout-evidence.md +0 -23
- /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
|
+
}
|