@intentius/chant 0.102.0 → 0.104.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/dist/audit/core.d.ts +2 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/change-set.d.ts +232 -0
- package/dist/change-set.d.ts.map +1 -0
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/change-set.d.ts +11 -0
- package/dist/cli/handlers/change-set.d.ts.map +1 -0
- package/dist/cli/handlers/fan-out.d.ts +33 -0
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +7 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +31 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +3 -2
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +63 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +16 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/capability.d.ts +53 -0
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +31 -0
- package/dist/components/changed-files.d.ts.map +1 -0
- package/dist/components/cli-support.d.ts +9 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +24 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/driver.d.ts +13 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-output.d.ts +6 -0
- package/dist/components/fan-out-output.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +41 -0
- package/dist/components/fan-out-record.d.ts.map +1 -0
- package/dist/components/fan-out-run.d.ts +65 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts +2 -0
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/fan-out.d.ts +6 -0
- package/dist/components/fan-out.d.ts.map +1 -1
- package/dist/components/gated-wave-pipeline.d.ts +50 -0
- package/dist/components/gated-wave-pipeline.d.ts.map +1 -0
- package/dist/components/index.d.ts +1 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/layers.d.ts +25 -0
- package/dist/components/layers.d.ts.map +1 -0
- package/dist/components/pr-pipeline.d.ts +61 -0
- package/dist/components/pr-pipeline.d.ts.map +1 -0
- package/dist/components/pr-run.d.ts +119 -0
- package/dist/components/pr-run.d.ts.map +1 -0
- package/dist/components/promote.d.ts.map +1 -1
- package/dist/components/verbs/shell.d.ts.map +1 -1
- package/dist/components/wave-plan.d.ts +49 -0
- package/dist/components/wave-plan.d.ts.map +1 -0
- package/dist/gated-waves.d.ts +147 -0
- package/dist/gated-waves.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +48 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +7 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +46 -1
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lint/post-synth.d.ts +18 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +126 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/change-set.d.ts +62 -0
- package/dist/op/activities/change-set.d.ts.map +1 -0
- package/dist/op/activities/decide.d.ts +2 -0
- package/dist/op/activities/decide.d.ts.map +1 -1
- package/dist/op/activities/factory.d.ts +137 -0
- package/dist/op/activities/factory.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +4 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/builders.d.ts +11 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/decide-backend.d.ts +5 -1
- package/dist/op/decide-backend.d.ts.map +1 -1
- package/dist/op/decide-read-inputs.d.ts +4 -2
- package/dist/op/decide-read-inputs.d.ts.map +1 -1
- package/dist/op/factory.d.ts +83 -0
- package/dist/op/factory.d.ts.map +1 -0
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/op/types.d.ts +8 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/plan-summary.d.ts +207 -0
- package/dist/plan-summary.d.ts.map +1 -0
- package/dist/pr-forge.d.ts +105 -0
- package/dist/pr-forge.d.ts.map +1 -0
- package/dist/pr-loop.d.ts +178 -0
- package/dist/pr-loop.d.ts.map +1 -0
- package/dist/telemetry-attribution.d.ts +31 -0
- package/dist/telemetry-attribution.d.ts.map +1 -1
- package/dist/terraform/hcl2json.d.ts +45 -0
- package/dist/terraform/hcl2json.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +4 -40
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/vendor/js-yaml-load.d.ts +56 -0
- package/dist/vendor/js-yaml-load.d.ts.map +1 -0
- package/dist/workspace/box-cli.d.ts +12 -0
- package/dist/workspace/box-cli.d.ts.map +1 -0
- package/dist/workspace/box-factory.d.ts +27 -3
- package/dist/workspace/box-factory.d.ts.map +1 -1
- package/dist/workspace/box-listing.d.ts +85 -0
- package/dist/workspace/box-listing.d.ts.map +1 -0
- package/dist/workspace/box-publish.d.ts +147 -0
- package/dist/workspace/box-publish.d.ts.map +1 -0
- package/dist/workspace/broker-protocol.d.ts +190 -0
- package/dist/workspace/broker-protocol.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +2 -2
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts +17 -0
- package/dist/workspace/conformance/__fixtures__/reference-broker.d.ts.map +1 -0
- package/dist/workspace/conformance/broker.d.ts +202 -0
- package/dist/workspace/conformance/broker.d.ts.map +1 -0
- package/dist/workspace/conformance/index.d.ts +2 -1
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/conformance/vitest.d.ts +9 -2
- package/dist/workspace/conformance/vitest.d.ts.map +1 -1
- package/dist/workspace/conformance/writer.d.ts +108 -14
- package/dist/workspace/conformance/writer.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +126 -12
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decision-points.schema.json +11 -4
- package/dist/workspace/declaration.d.ts +65 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +153 -4
- package/dist/workspace/environments.d.ts +36 -10
- package/dist/workspace/environments.d.ts.map +1 -1
- package/dist/workspace/factory-rules.d.ts +180 -0
- package/dist/workspace/factory-rules.d.ts.map +1 -0
- package/dist/workspace/init.d.ts +21 -0
- package/dist/workspace/init.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +1 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-record.d.ts +28 -1
- package/dist/workspace/intent-record.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +102 -5
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/json-edit.d.ts +24 -0
- package/dist/workspace/json-edit.d.ts.map +1 -0
- package/dist/workspace/jsonc.d.ts +11 -0
- package/dist/workspace/jsonc.d.ts.map +1 -1
- package/dist/workspace/kinds.d.ts +48 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/lexicon-symbols.d.ts +29 -0
- package/dist/workspace/lexicon-symbols.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade.d.ts +31 -2
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/point-answer.schema.json +42 -2
- package/dist/workspace/points-cli.d.ts +10 -3
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points.d.ts +66 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +33 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +5 -7
- package/dist/workspace/record-sessions.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +7 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +7 -3
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +66 -6
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +28 -9
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runs-cli.d.ts +138 -6
- package/dist/workspace/runs-cli.d.ts.map +1 -1
- package/dist/workspace/runs.d.ts +132 -11
- package/dist/workspace/runs.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +1 -1
- package/dist/workspace/session-kinds.d.ts.map +1 -1
- package/dist/workspace/squash.d.ts +80 -0
- package/dist/workspace/squash.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +2 -0
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +54 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/symbols.d.ts +16 -5
- package/dist/workspace/symbols.d.ts.map +1 -1
- package/dist/workspace/trust/run-commits.d.ts +46 -0
- package/dist/workspace/trust/run-commits.d.ts.map +1 -0
- package/dist/workspace/trust/run-statement.d.ts +128 -0
- package/dist/workspace/trust/run-statement.d.ts.map +1 -0
- package/dist/workspace/trust/seal.d.ts +6 -1
- package/dist/workspace/trust/seal.d.ts.map +1 -1
- package/dist/workspace/trust/verify-cli.d.ts +1 -1
- package/dist/workspace/trust/verify.d.ts +16 -2
- package/dist/workspace/trust/verify.d.ts.map +1 -1
- package/dist/workspace/wip-cli.d.ts +21 -0
- package/dist/workspace/wip-cli.d.ts.map +1 -0
- package/dist/workspace/wip.d.ts +268 -0
- package/dist/workspace/wip.d.ts.map +1 -0
- package/dist/workspace/work-cli.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/work-size.d.ts +55 -0
- package/dist/workspace/work-size.d.ts.map +1 -0
- package/dist/workspace/workspace-kinds.schema.json +122 -0
- package/dist/workspace/write-lock-cli.d.ts +60 -0
- package/dist/workspace/write-lock-cli.d.ts.map +1 -0
- package/dist/workspace/write-lock.d.ts +152 -0
- package/dist/workspace/write-lock.d.ts.map +1 -0
- package/dist/workspace/write-scope.d.ts +6 -2
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/yaml.d.ts +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/__fixtures__/change-set/lifecycle-plan.floci.json +59 -0
- package/src/__fixtures__/change-set/lifecycle-v1.ts.txt +4 -0
- package/src/__fixtures__/change-set/lifecycle-v2.ts.txt +4 -0
- package/src/__fixtures__/change-set/lifecycle.change-set.golden.json +95 -0
- package/src/__fixtures__/change-set/warden.change-set.golden.json +106 -0
- package/src/__fixtures__/change-set/warden.plan.json +44 -0
- package/src/__fixtures__/plan-summary/normalization-vectors.json +1593 -0
- package/src/__fixtures__/pr-loop/pr-apply-applied.note.golden.md +31 -0
- package/src/__fixtures__/pr-loop/pr-apply-refused.note.golden.md +31 -0
- package/src/__fixtures__/pr-loop/pr-plan.golden.json +127 -0
- package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +37 -0
- package/src/audit/core.test.ts +15 -0
- package/src/audit/core.ts +8 -2
- package/src/audit/discover.test.ts +11 -0
- package/src/audit/discover.ts +7 -2
- package/src/change-set-bundle.test.ts +57 -0
- package/src/change-set.test.ts +215 -0
- package/src/change-set.ts +445 -0
- package/src/cli/commands/audit-opt-in.test.ts +54 -0
- package/src/cli/commands/audit.ts +29 -2
- package/src/cli/commands/build.test.ts +19 -0
- package/src/cli/commands/build.ts +3 -2
- package/src/cli/handlers/build.ts +13 -1
- package/src/cli/handlers/change-set.test.ts +56 -0
- package/src/cli/handlers/change-set.ts +44 -0
- package/src/cli/handlers/fan-out.test.ts +221 -2
- package/src/cli/handlers/fan-out.ts +165 -75
- package/src/cli/handlers/graph.ts +3 -0
- package/src/cli/handlers/operator.test.ts +21 -0
- package/src/cli/handlers/operator.ts +34 -3
- package/src/cli/handlers/pr.ts +373 -0
- package/src/cli/handlers/run.ts +1 -0
- package/src/cli/main.test.ts +20 -0
- package/src/cli/main.ts +185 -23
- package/src/cli/mcp/never-over-mcp.test.ts +101 -0
- package/src/cli/mcp/op-tools.ts +24 -2
- package/src/cli/mcp/workspace-tools.test.ts +1 -1
- package/src/cli/mcp/workspace-tools.ts +34 -6
- package/src/cli/registry.ts +63 -1
- package/src/components/auto-release.ts +1 -1
- package/src/components/capability.ts +55 -0
- package/src/components/changed-files.ts +66 -0
- package/src/components/cli-support.ts +28 -5
- package/src/components/component.schema.json +17 -0
- package/src/components/component.ts +25 -0
- package/src/components/deploy-units.ts +3 -0
- package/src/components/driver.test.ts +52 -0
- package/src/components/driver.ts +31 -13
- package/src/components/fan-out-output.ts +42 -1
- package/src/components/fan-out-record.ts +69 -0
- package/src/components/fan-out-run.test.ts +17 -0
- package/src/components/fan-out-run.ts +304 -1
- package/src/components/fan-out-support.ts +3 -0
- package/src/components/fan-out-waves.test.ts +375 -0
- package/src/components/fan-out.ts +18 -17
- package/src/components/gated-wave-pipeline.ts +82 -0
- package/src/components/index.ts +1 -0
- package/src/components/layers.ts +30 -0
- package/src/components/pr-pipeline.ts +91 -0
- package/src/components/pr-run.test.ts +205 -0
- package/src/components/pr-run.ts +280 -0
- package/src/components/promote.ts +5 -0
- package/src/components/verbs/shell.test.ts +11 -0
- package/src/components/verbs/shell.ts +7 -3
- package/src/components/wave-plan.ts +124 -0
- package/src/discovery/sandbox/bundle.ts +1 -1
- package/src/gated-waves-bundle.test.ts +62 -0
- package/src/gated-waves.test.ts +118 -0
- package/src/gated-waves.ts +240 -0
- package/src/index.ts +1 -0
- package/src/lexicon.ts +51 -0
- package/src/lifecycle/gate-ledger.ts +7 -0
- package/src/lifecycle/git.ts +102 -16
- package/src/lint/post-synth.test.ts +19 -0
- package/src/lint/post-synth.ts +19 -1
- package/src/meta/declared-imports.test.ts +1 -1
- package/src/op/activities/activity-contracts.ts +84 -0
- package/src/op/activities/change-set.ts +114 -0
- package/src/op/activities/decide.ts +3 -0
- package/src/op/activities/factory.ts +675 -0
- package/src/op/activities/index.ts +6 -0
- package/src/op/activity-contract.test.ts +21 -1
- package/src/op/activity-contract.ts +19 -9
- package/src/op/builders.ts +23 -0
- package/src/op/decide-backend.ts +5 -1
- package/src/op/decide-read-inputs.ts +33 -5
- package/src/op/factory.e2e.test.ts +232 -0
- package/src/op/factory.ts +118 -0
- package/src/op/index.ts +9 -1
- package/src/op/op-ir.test.ts +9 -0
- package/src/op/op-ir.ts +4 -0
- package/src/op/op-verb-class.ts +3 -0
- package/src/op/types.ts +8 -0
- package/src/op/work-lease-run.ts +3 -0
- package/src/plan-summary.test.ts +263 -0
- package/src/plan-summary.ts +845 -0
- package/src/pr-forge.test.ts +237 -0
- package/src/pr-forge.ts +332 -0
- package/src/pr-loop-bundle.test.ts +46 -0
- package/src/pr-loop.test.ts +171 -0
- package/src/pr-loop.ts +324 -0
- package/src/telemetry-attribution.test.ts +23 -1
- package/src/telemetry-attribution.ts +51 -0
- package/src/terraform/hcl2json.test.ts +21 -0
- package/src/terraform/hcl2json.ts +81 -0
- package/src/terraform/parse.ts +11 -74
- package/src/vendor/js-yaml-load.test.ts +115 -0
- package/src/vendor/js-yaml-load.ts +1939 -0
- package/src/workspace/__fixtures__/studio-template-84c8b21.json +1 -1
- package/src/workspace/app-kind.test.ts +196 -0
- package/src/workspace/box-cli.ts +113 -0
- package/src/workspace/box-factory.test.ts +4 -1
- package/src/workspace/box-factory.ts +37 -4
- package/src/workspace/box-listing-write.schema.json +204 -0
- package/src/workspace/box-listing.test.ts +234 -0
- package/src/workspace/box-listing.ts +324 -0
- package/src/workspace/box-publish.schema.json +498 -0
- package/src/workspace/box-publish.test.ts +204 -0
- package/src/workspace/box-publish.ts +420 -0
- package/src/workspace/box-ship.test.ts +96 -0
- package/src/workspace/broker-protocol.schema.json +186 -0
- package/src/workspace/broker-protocol.test.ts +122 -0
- package/src/workspace/broker-protocol.ts +317 -0
- package/src/workspace/builder-tiers.test.ts +146 -0
- package/src/workspace/change-set.schema.json +460 -0
- package/src/workspace/chant-migrations.e2e.test.ts +17 -5
- package/src/workspace/checks/boxes.test.ts +3 -0
- package/src/workspace/checks.ts +10 -3
- package/src/workspace/component-environments.test.ts +105 -0
- package/src/workspace/composites.schema.json +23 -4
- package/src/workspace/composites.test.ts +8 -8
- package/src/workspace/composites.ts +13 -2
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +2 -2
- package/src/workspace/conformance/__fixtures__/reference-broker.ts +207 -0
- package/src/workspace/conformance/__writer_fixture__/answers/answer.kind.mjs +7 -2
- package/src/workspace/conformance/__writer_fixture__/answers/answer.schema.json +42 -2
- package/src/workspace/conformance/__writer_fixture__/contracts/contract.kind.mjs +37 -0
- package/src/workspace/conformance/__writer_fixture__/contracts/contract.schema.json +213 -0
- package/src/workspace/conformance/__writer_fixture__/decisions/points.json +96 -0
- package/src/workspace/conformance/__writer_fixture__/drivers/driver.kind.mjs +15 -0
- package/src/workspace/conformance/__writer_fixture__/drivers/driver.schema.json +38 -0
- package/src/workspace/conformance/__writer_fixture__/sessions/session.kind.mjs +14 -8
- package/src/workspace/conformance/__writer_fixture__/sessions/session.schema.json +187 -6
- package/src/workspace/conformance/__writer_fixture__/work/work.schema.json +25 -0
- package/src/workspace/conformance/broker.test.ts +122 -0
- package/src/workspace/conformance/broker.ts +792 -0
- package/src/workspace/conformance/conformance.e2e.test.ts +2 -1
- package/src/workspace/conformance/index.mjs +22 -1
- package/src/workspace/conformance/index.ts +5 -1
- package/src/workspace/conformance/vitest.ts +33 -1
- package/src/workspace/conformance/writer-conformance.e2e.test.ts +49 -3
- package/src/workspace/conformance/writer.test.ts +32 -2
- package/src/workspace/conformance/writer.ts +339 -28
- package/src/workspace/decide.test.ts +208 -4
- package/src/workspace/decide.ts +427 -73
- package/src/workspace/decision-points.schema.json +11 -4
- package/src/workspace/declaration.schema.json +153 -4
- package/src/workspace/declaration.ts +141 -0
- package/src/workspace/environments.ts +66 -14
- package/src/workspace/factory-rules.test.ts +62 -0
- package/src/workspace/factory-rules.ts +218 -0
- package/src/workspace/graph-cli.ts +2 -2
- package/src/workspace/graph.schema.json +67 -0
- package/src/workspace/identity.test.ts +23 -0
- package/src/workspace/init.ts +82 -2
- package/src/workspace/intent-cli.ts +26 -9
- package/src/workspace/intent-patch-id.test.ts +162 -0
- package/src/workspace/intent-record.schema.json +130 -3
- package/src/workspace/intent-record.ts +74 -28
- package/src/workspace/intent-reviews.test.ts +76 -0
- package/src/workspace/intent-squash.test.ts +213 -0
- package/src/workspace/intent-why.test.ts +5 -5
- package/src/workspace/intent.schema.json +234 -4
- package/src/workspace/intent.ts +260 -66
- package/src/workspace/json-edit.test.ts +121 -0
- package/src/workspace/json-edit.ts +142 -0
- package/src/workspace/jsonc.ts +20 -0
- package/src/workspace/kinds.ts +157 -2
- package/src/workspace/lexicon-symbols.test.ts +148 -0
- package/src/workspace/lexicon-symbols.ts +125 -0
- package/src/workspace/lineage-upgrade.test.ts +63 -0
- package/src/workspace/lineage-upgrade.ts +146 -5
- package/src/workspace/plan-summary.schema.json +155 -0
- package/src/workspace/point-answer.schema.json +42 -2
- package/src/workspace/points-cli.ts +42 -16
- package/src/workspace/points-write.schema.json +162 -4
- package/src/workspace/points.schema.json +157 -2
- package/src/workspace/points.test.ts +13 -1
- package/src/workspace/points.ts +118 -12
- package/src/workspace/pr-report.schema.json +95 -0
- package/src/workspace/profiles.test.ts +159 -0
- package/src/workspace/read-contract.test.ts +2 -2
- package/src/workspace/reason-codes.test.ts +13 -1
- package/src/workspace/reason-codes.ts +38 -4
- package/src/workspace/record-sessions.ts +9 -8
- package/src/workspace/records-amend.schema.json +78 -0
- package/src/workspace/records-cli.ts +20 -6
- package/src/workspace/records-close.schema.json +78 -0
- package/src/workspace/records-close.ts +31 -7
- package/src/workspace/records-concurrent.e2e.test.ts +135 -0
- package/src/workspace/records-concurrent.test.ts +145 -0
- package/src/workspace/records-formats.test.ts +46 -10
- package/src/workspace/records-new.schema.json +7 -0
- package/src/workspace/records-review.schema.json +78 -0
- package/src/workspace/records-sessions-write.test.ts +69 -0
- package/src/workspace/records-sessions.test.ts +22 -0
- package/src/workspace/records-write.ts +192 -25
- package/src/workspace/records.schema.json +16 -0
- package/src/workspace/records.ts +50 -9
- package/src/workspace/reference-kinds/app/package.json +10 -0
- package/src/workspace/reference-kinds/app/workspace-kinds.json +45 -0
- package/src/workspace/run-statement.schema.json +622 -0
- package/src/workspace/runs-cli.ts +343 -14
- package/src/workspace/runs-statement.test.ts +239 -0
- package/src/workspace/runs-write.schema.json +416 -5
- package/src/workspace/runs.schema.json +451 -4
- package/src/workspace/runs.test.ts +39 -0
- package/src/workspace/runs.ts +296 -35
- package/src/workspace/session-kinds.ts +3 -3
- package/src/workspace/squash.ts +198 -0
- package/src/workspace/status-contract.test.ts +5 -2
- package/src/workspace/status-gates.ts +3 -1
- package/src/workspace/status.schema.json +126 -3
- package/src/workspace/status.ts +90 -1
- package/src/workspace/symbols.ts +20 -9
- package/src/workspace/trust/run-commits.ts +115 -0
- package/src/workspace/trust/run-statement.ts +226 -0
- package/src/workspace/trust/seal.ts +8 -2
- package/src/workspace/trust/verify-cli.ts +6 -5
- package/src/workspace/trust/verify.ts +39 -6
- package/src/workspace/wip-cli.ts +97 -0
- package/src/workspace/wip-write.schema.json +504 -0
- package/src/workspace/wip.schema.json +334 -0
- package/src/workspace/wip.test.ts +294 -0
- package/src/workspace/wip.ts +753 -0
- package/src/workspace/work-cli.ts +3 -0
- package/src/workspace/work-evidence.schema.json +3 -0
- package/src/workspace/work-size.test.ts +77 -0
- package/src/workspace/work-size.ts +93 -0
- package/src/workspace/workspace-kinds.schema.json +122 -0
- package/src/workspace/write-lock-cli.ts +131 -0
- package/src/workspace/write-lock.schema.json +172 -0
- package/src/workspace/write-lock.test.ts +189 -0
- package/src/workspace/write-lock.ts +474 -0
- package/src/workspace/write-scope.test.ts +16 -1
- package/src/workspace/write-scope.ts +33 -11
- package/src/yaml.ts +31 -21
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working tree's write lock and write journal (#3173, ws-089): where the
|
|
3
|
+
* lock lives, that writes in one process and across a batch serialise, that a
|
|
4
|
+
* dead or expired holder is broken, that a waiter is refused naming the
|
|
5
|
+
* holder, and that the journal names a record's last writer only while the
|
|
6
|
+
* file holds what that write left.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { hostname, tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { afterAll, afterEach, describe, expect, test } from "vitest";
|
|
13
|
+
import { cleanScratch, contract, git, repo, scratchDir } from "./__fixtures__/contract-repo";
|
|
14
|
+
import writeLockSchema from "./write-lock.schema.json";
|
|
15
|
+
import { workspaceLock } from "./write-lock-cli";
|
|
16
|
+
import {
|
|
17
|
+
gitDirOf,
|
|
18
|
+
lastWriteReader,
|
|
19
|
+
noteWrites,
|
|
20
|
+
WRITE_LOCK_ENV,
|
|
21
|
+
WRITE_LOCK_WAIT_ENV,
|
|
22
|
+
withWriteLock,
|
|
23
|
+
withWriteLockSync,
|
|
24
|
+
WriteLockError,
|
|
25
|
+
writeLockHolder,
|
|
26
|
+
writeLockPath,
|
|
27
|
+
} from "./write-lock";
|
|
28
|
+
|
|
29
|
+
afterAll(cleanScratch);
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
delete process.env[WRITE_LOCK_ENV];
|
|
32
|
+
delete process.env[WRITE_LOCK_WAIT_ENV];
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
const lockDoc = contract(writeLockSchema);
|
|
37
|
+
|
|
38
|
+
/** Put a lock in place as another process would have left it. */
|
|
39
|
+
function plant(start: string, over: Record<string, unknown>): string {
|
|
40
|
+
const lock = writeLockPath(start);
|
|
41
|
+
mkdirSync(lock);
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
writeFileSync(
|
|
44
|
+
join(lock, "owner.json"),
|
|
45
|
+
JSON.stringify({ token: "f".repeat(32), pid: process.pid, host: hostname(), verb: "records amend", by: "alice", agent: null, acquiredAt: new Date(now).toISOString(), expiresAt: new Date(now + 60_000).toISOString(), ...over }),
|
|
46
|
+
);
|
|
47
|
+
return lock;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("where the lock lives", () => {
|
|
51
|
+
test("in the working tree's git directory, the linked worktree's own for a worktree, and under tmp outside git", () => {
|
|
52
|
+
const root = repo({ "a.txt": "a\n" }, true);
|
|
53
|
+
expect(gitDirOf(root)).toBe(join(root, ".git"));
|
|
54
|
+
expect(writeLockPath(join(root))).toBe(join(root, ".git", "chant-write.lock"));
|
|
55
|
+
const wt = join(scratchDir(), "wt");
|
|
56
|
+
git(root, "worktree", "add", "-q", wt);
|
|
57
|
+
expect(writeLockPath(wt)).toBe(join(root, ".git", "worktrees", "wt", "chant-write.lock"));
|
|
58
|
+
const bare = scratchDir();
|
|
59
|
+
expect(writeLockPath(bare).startsWith(tmpdir())).toBe(true);
|
|
60
|
+
expect(writeLockPath(bare)).toMatch(/chant-write-[0-9a-f]{16}\.lock$/);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("withWriteLock", () => {
|
|
65
|
+
test("serialises two writes this process runs at once, and lets a write inside a write through", async () => {
|
|
66
|
+
const root = repo({});
|
|
67
|
+
const order: string[] = [];
|
|
68
|
+
const one = withWriteLock(root, { verb: "records amend", by: "alice" }, false, async () => {
|
|
69
|
+
order.push("one in");
|
|
70
|
+
expect(writeLockHolder(root).holder).toMatchObject({ verb: "records amend", by: "alice", pid: process.pid });
|
|
71
|
+
// Re-entrant: work evidence amends inside its own write.
|
|
72
|
+
await withWriteLock(root, { verb: "records amend" }, false, async () => void order.push("nested"));
|
|
73
|
+
await sleep(80);
|
|
74
|
+
order.push("one out");
|
|
75
|
+
});
|
|
76
|
+
await sleep(10);
|
|
77
|
+
const two = withWriteLock(root, { verb: "records amend", by: "bob" }, false, async () => void order.push("two"));
|
|
78
|
+
await Promise.all([one, two]);
|
|
79
|
+
expect(order).toEqual(["one in", "nested", "one out", "two"]);
|
|
80
|
+
expect(existsSync(writeLockPath(root))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a dry run takes no lock", async () => {
|
|
84
|
+
const root = repo({});
|
|
85
|
+
plant(root, {});
|
|
86
|
+
expect(await withWriteLock(root, { verb: "records amend" }, true, async () => "ran")).toBe("ran");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("a waiter is refused with write-lock-timeout naming the holder, and the lock is released after a failure", async () => {
|
|
90
|
+
const root = repo({});
|
|
91
|
+
plant(root, {});
|
|
92
|
+
process.env[WRITE_LOCK_WAIT_ENV] = "100";
|
|
93
|
+
const err = await withWriteLock(root, { verb: "records review" }, false, async () => "no").catch((e: unknown) => e);
|
|
94
|
+
expect(err).toBeInstanceOf(WriteLockError);
|
|
95
|
+
expect((err as WriteLockError).code).toBe("write-lock-timeout");
|
|
96
|
+
expect((err as WriteLockError).message).toMatch(/records amend \(alice\)/);
|
|
97
|
+
expect((err as WriteLockError).holder?.by).toBe("alice");
|
|
98
|
+
|
|
99
|
+
const other = repo({});
|
|
100
|
+
await expect(withWriteLock(other, { verb: "records new" }, false, async () => { throw new Error("boom"); })).rejects.toThrow("boom");
|
|
101
|
+
expect(existsSync(writeLockPath(other))).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("breaks a lock whose process has exited, or that has expired", async () => {
|
|
105
|
+
const dead = repo({});
|
|
106
|
+
plant(dead, { pid: 2 ** 22 + 12345 });
|
|
107
|
+
expect(await withWriteLock(dead, { verb: "records amend" }, false, async () => "took it")).toBe("took it");
|
|
108
|
+
const expired = repo({});
|
|
109
|
+
plant(expired, { pid: null, verb: "batch", expiresAt: new Date(Date.now() - 1000).toISOString() });
|
|
110
|
+
expect(await withWriteLock(expired, { verb: "records amend" }, false, async () => "took it")).toBe("took it");
|
|
111
|
+
expect(writeLockHolder(expired).holder).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the sync form refuses at once while this process's own async write holds the lock", async () => {
|
|
115
|
+
const root = repo({});
|
|
116
|
+
let inner: unknown;
|
|
117
|
+
const outer = withWriteLock(root, { verb: "records amend" }, false, async () => {
|
|
118
|
+
await sleep(30);
|
|
119
|
+
});
|
|
120
|
+
await sleep(5);
|
|
121
|
+
try {
|
|
122
|
+
withWriteLockSync(root, { verb: "box listing set" }, false, () => "no");
|
|
123
|
+
} catch (e) {
|
|
124
|
+
inner = e;
|
|
125
|
+
}
|
|
126
|
+
await outer;
|
|
127
|
+
expect((inner as WriteLockError).code).toBe("write-lock-timeout");
|
|
128
|
+
expect(withWriteLockSync(root, { verb: "box listing set" }, false, () => "yes")).toBe("yes");
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("a batch held across chant calls (lock acquire)", () => {
|
|
133
|
+
test("its token lets each write through while others wait, and a released token is refused", async () => {
|
|
134
|
+
const root = repo({});
|
|
135
|
+
const acquired = await workspaceLock({ cwd: root, verb: "acquire", holder: "hud:alice", ttl: "30s" });
|
|
136
|
+
lockDoc.expectValid(acquired);
|
|
137
|
+
if ("error" in acquired) throw new Error(acquired.error.message);
|
|
138
|
+
expect(acquired.holder).toMatchObject({ verb: "batch", by: "hud:alice", pid: null });
|
|
139
|
+
expect(acquired.token).toMatch(/^[0-9a-f]{32}$/);
|
|
140
|
+
|
|
141
|
+
const status = await workspaceLock({ cwd: root, verb: undefined });
|
|
142
|
+
lockDoc.expectValid(status);
|
|
143
|
+
expect(status).not.toHaveProperty("token");
|
|
144
|
+
expect(status.holder).toMatchObject({ by: "hud:alice" });
|
|
145
|
+
|
|
146
|
+
// Another writer, without the token, waits and is refused.
|
|
147
|
+
process.env[WRITE_LOCK_WAIT_ENV] = "60";
|
|
148
|
+
await expect(withWriteLock(root, { verb: "records amend" }, false, async () => "no")).rejects.toMatchObject({ code: "write-lock-timeout" });
|
|
149
|
+
// The batch's own writes go ahead.
|
|
150
|
+
process.env[WRITE_LOCK_ENV] = acquired.token!;
|
|
151
|
+
expect(await withWriteLock(root, { verb: "records amend" }, false, async () => "batch write")).toBe("batch write");
|
|
152
|
+
expect(writeLockHolder(root).holder?.verb).toBe("batch");
|
|
153
|
+
|
|
154
|
+
const released = await workspaceLock({ cwd: root, verb: "release", token: acquired.token });
|
|
155
|
+
lockDoc.expectValid(released);
|
|
156
|
+
expect(released).toMatchObject({ verb: "release", holder: null });
|
|
157
|
+
await expect(withWriteLock(root, { verb: "records amend" }, false, async () => "no")).rejects.toMatchObject({ code: "write-lock-not-held" });
|
|
158
|
+
const again = await workspaceLock({ cwd: root, verb: "release", token: acquired.token });
|
|
159
|
+
lockDoc.expectValid(again);
|
|
160
|
+
expect(again).toMatchObject({ error: { code: "write-lock-not-held" } });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("usage is refused with write-usage-invalid", async () => {
|
|
164
|
+
const root = repo({});
|
|
165
|
+
for (const req of [{ verb: "acquire" }, { verb: "acquire", holder: "x", ttl: "1h" }, { verb: "release" }, { verb: "steal" }, { verb: undefined, token: "x" }]) {
|
|
166
|
+
const doc = await workspaceLock({ cwd: root, ...req });
|
|
167
|
+
lockDoc.expectValid(doc);
|
|
168
|
+
expect(doc).toMatchObject({ error: { code: "write-usage-invalid" } });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("the write journal", () => {
|
|
174
|
+
test("names the last chant write of a file while the file holds the text it left", () => {
|
|
175
|
+
const root = repo({});
|
|
176
|
+
noteWrites(root, [{ path: "decisions/ws-001.md", text: "one\n" }], { verb: "records amend", by: null, agent: "app-agent" });
|
|
177
|
+
const read = lastWriteReader(root);
|
|
178
|
+
expect(read("decisions/ws-001.md", "one\n")).toMatchObject({ verb: "records amend", by: null, agent: "app-agent" });
|
|
179
|
+
expect(read("decisions/ws-001.md", "edited by hand\n")).toBeNull();
|
|
180
|
+
expect(read("decisions/ws-002.md", "one\n")).toBeNull();
|
|
181
|
+
expect(JSON.parse(readFileSync(join(root, ".git", "chant-writes.json"), "utf-8")).version).toBe(1);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("is not kept outside git", () => {
|
|
185
|
+
const bare = scratchDir();
|
|
186
|
+
noteWrites(bare, [{ path: "a.md", text: "a" }], { verb: "records new" });
|
|
187
|
+
expect(lastWriteReader(bare)("a.md", "a")).toBeNull();
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The working tree's write lock and write journal (#3173, ws-089).
|
|
3
|
+
*
|
|
4
|
+
* The repo is the database (ws-074), and one working tree is written by
|
|
5
|
+
* several principals at once: people through hud, the coding agent, the
|
|
6
|
+
* steward and builders. chant's working-tree writes (records new, amend,
|
|
7
|
+
* review and close, points ask and answer, box listing set) each read the
|
|
8
|
+
* records, build the file they write, validate it and write it. Two of them
|
|
9
|
+
* running at once could each read the same record and the second write would
|
|
10
|
+
* drop the first's change, or two `records new` could hand out the same id.
|
|
11
|
+
* So every such write holds this lock from its first read to its last write:
|
|
12
|
+
* writes to one working tree are serialised, and each one reads what the one
|
|
13
|
+
* before it wrote.
|
|
14
|
+
*
|
|
15
|
+
* The lock is a directory, `chant-write.lock`, in the working tree's git
|
|
16
|
+
* directory (`.git`, or `.git/worktrees/<name>` for a linked worktree, so two
|
|
17
|
+
* worktrees of one repository never wait on each other), made with `mkdir`,
|
|
18
|
+
* which either creates it or fails. `owner.json` inside it says who holds it.
|
|
19
|
+
* Outside git it lives in the system's temporary directory, keyed by the
|
|
20
|
+
* working directory's real path, so nothing is added to the files a person
|
|
21
|
+
* reviews. A holder that died leaves its lock behind: one whose process is
|
|
22
|
+
* gone on this host, or whose `expiresAt` has passed, is broken by the next
|
|
23
|
+
* writer. A writer waits for the lock up to `CHANT_WRITE_LOCK_WAIT_MS`
|
|
24
|
+
* (default {@link DEFAULT_WAIT_MS}) and is then refused with
|
|
25
|
+
* `write-lock-timeout`, naming the holder.
|
|
26
|
+
*
|
|
27
|
+
* A caller can hold the lock across several chant calls, for a batch of
|
|
28
|
+
* writes nobody else interleaves with (`chant workspace lock acquire`, hud#819):
|
|
29
|
+
* each call of the batch runs with `CHANT_WRITE_LOCK=<token>` in its
|
|
30
|
+
* environment, and goes ahead without waiting while the lock is held under
|
|
31
|
+
* that token. A token that no longer holds the lock (released, or broken
|
|
32
|
+
* after it expired) is refused with `write-lock-not-held`, so a batch never
|
|
33
|
+
* runs on unlocked.
|
|
34
|
+
*
|
|
35
|
+
* The journal, `chant-writes.json` beside the lock, notes the last chant
|
|
36
|
+
* write of each record file: the verb, who it named and when, keyed by the
|
|
37
|
+
* file's SHA-256 after the write. The read contract reports it as a record's
|
|
38
|
+
* `lastWrite` while the file still has those bytes, and a conflict refusal
|
|
39
|
+
* names it. It is a cache in the git directory (ws-074's exceptions), never
|
|
40
|
+
* a fact: lost, every record's `lastWrite` is null, and nothing else changes.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
44
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
45
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
46
|
+
import { hostname, tmpdir } from "node:os";
|
|
47
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
48
|
+
|
|
49
|
+
/** The environment variable that names a lock token a batch holds across chant calls. */
|
|
50
|
+
export const WRITE_LOCK_ENV = "CHANT_WRITE_LOCK";
|
|
51
|
+
/** The environment variable that sets how long a write waits for the lock, in milliseconds. */
|
|
52
|
+
export const WRITE_LOCK_WAIT_ENV = "CHANT_WRITE_LOCK_WAIT_MS";
|
|
53
|
+
/** How long a write waits for the lock by default. */
|
|
54
|
+
export const DEFAULT_WAIT_MS = 15_000;
|
|
55
|
+
/** How long one command's lock is good for, should its process be unable to say it died. */
|
|
56
|
+
export const COMMAND_TTL_MS = 2 * 60_000;
|
|
57
|
+
/** The longest a batch may hold the lock with `lock acquire --ttl`. */
|
|
58
|
+
export const MAX_BATCH_TTL_MS = 10 * 60_000;
|
|
59
|
+
/** The default `--ttl` of `lock acquire`. */
|
|
60
|
+
export const DEFAULT_BATCH_TTL_MS = 60_000;
|
|
61
|
+
|
|
62
|
+
/** The lock directory's name in the git directory. */
|
|
63
|
+
export const WRITE_LOCK_NAME = "chant-write.lock";
|
|
64
|
+
/** The journal's name in the git directory. */
|
|
65
|
+
export const WRITE_JOURNAL_NAME = "chant-writes.json";
|
|
66
|
+
|
|
67
|
+
export const WRITE_LOCK_CODES = ["write-lock-timeout", "write-lock-not-held"] as const;
|
|
68
|
+
export type WriteLockCode = (typeof WRITE_LOCK_CODES)[number];
|
|
69
|
+
|
|
70
|
+
/** Who holds the lock, as `owner.json` holds it. */
|
|
71
|
+
export interface WriteLockHolder {
|
|
72
|
+
token: string;
|
|
73
|
+
/** The process holding it, or null for a batch held across calls by `lock acquire`. */
|
|
74
|
+
pid: number | null;
|
|
75
|
+
host: string;
|
|
76
|
+
/** What holds it: a write verb, such as `records amend`, or `batch`. */
|
|
77
|
+
verb: string;
|
|
78
|
+
by: string | null;
|
|
79
|
+
agent: string | null;
|
|
80
|
+
acquiredAt: string;
|
|
81
|
+
expiresAt: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Who is writing: the verb, and the principal and agent session the write names. */
|
|
85
|
+
export interface WriteLockWho {
|
|
86
|
+
verb: string;
|
|
87
|
+
by?: string | null;
|
|
88
|
+
agent?: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export class WriteLockError extends Error {
|
|
92
|
+
constructor(
|
|
93
|
+
readonly code: WriteLockCode,
|
|
94
|
+
message: string,
|
|
95
|
+
readonly holder: WriteLockHolder | null,
|
|
96
|
+
) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.name = "WriteLockError";
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Where ────────────────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The git directory of the working tree `start` is in: `.git`, or the
|
|
106
|
+
* directory a linked worktree's `.git` file names. Null outside git.
|
|
107
|
+
*/
|
|
108
|
+
export function gitDirOf(start: string): string | null {
|
|
109
|
+
let dir = resolve(start);
|
|
110
|
+
for (;;) {
|
|
111
|
+
const dotGit = join(dir, ".git");
|
|
112
|
+
try {
|
|
113
|
+
const st = statSync(dotGit);
|
|
114
|
+
if (st.isDirectory()) return dotGit;
|
|
115
|
+
if (st.isFile()) {
|
|
116
|
+
const m = readFileSync(dotGit, "utf-8").match(/^gitdir:\s*(.+?)\s*$/m);
|
|
117
|
+
if (m) return isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// No .git here: go up.
|
|
121
|
+
}
|
|
122
|
+
const up = dirname(dir);
|
|
123
|
+
if (up === dir) return null;
|
|
124
|
+
dir = up;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function real(p: string): string {
|
|
129
|
+
try {
|
|
130
|
+
return realpathSync(p);
|
|
131
|
+
} catch {
|
|
132
|
+
return resolve(p);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The lock directory for the working tree `start` is in. */
|
|
137
|
+
export function writeLockPath(start: string): string {
|
|
138
|
+
const git = gitDirOf(real(start));
|
|
139
|
+
if (git) return join(git, WRITE_LOCK_NAME);
|
|
140
|
+
return join(tmpdir(), `chant-write-${createHash("sha256").update(real(start)).digest("hex").slice(0, 16)}.lock`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The journal for the working tree `start` is in, or null outside git. */
|
|
144
|
+
export function writeJournalPath(start: string): string | null {
|
|
145
|
+
const git = gitDirOf(real(start));
|
|
146
|
+
return git ? join(git, WRITE_JOURNAL_NAME) : null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── The lock ─────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
const HOST = hostname();
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The locks the running write holds, through every await inside it, so a
|
|
155
|
+
* write inside a write (work evidence's amend) goes ahead and two writes this
|
|
156
|
+
* process runs at once (two requests to chant serve mcp) still wait on each
|
|
157
|
+
* other.
|
|
158
|
+
*/
|
|
159
|
+
const context = new AsyncLocalStorage<ReadonlySet<string>>();
|
|
160
|
+
/** Every lock some write in this process holds now. */
|
|
161
|
+
const heldHere = new Set<string>();
|
|
162
|
+
|
|
163
|
+
function readHolder(lock: string): WriteLockHolder | null {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(readFileSync(join(lock, "owner.json"), "utf-8")) as WriteLockHolder;
|
|
166
|
+
} catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function alive(pid: number): boolean {
|
|
172
|
+
try {
|
|
173
|
+
process.kill(pid, 0);
|
|
174
|
+
return true;
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Why a lock someone else holds can be broken, or null while it holds. */
|
|
181
|
+
function staleness(lock: string, holder: WriteLockHolder | null, now: number): string | null {
|
|
182
|
+
if (holder === null) {
|
|
183
|
+
// Made and not yet given its owner file, or left that way by a crash: give the maker a moment.
|
|
184
|
+
try {
|
|
185
|
+
return now - statSync(lock).mtimeMs > 5_000 ? "it names no holder" : null;
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (Date.parse(holder.expiresAt) < now) return `it expired at ${holder.expiresAt}`;
|
|
191
|
+
if (holder.pid !== null && holder.host === HOST && !alive(holder.pid)) return `its process ${holder.pid} has exited`;
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function describe(h: WriteLockHolder | null): string {
|
|
196
|
+
if (!h) return "a writer that has not said who it is";
|
|
197
|
+
const who = [h.by, h.agent ? `agent ${h.agent}` : null].filter(Boolean).join(", ");
|
|
198
|
+
return `${h.verb}${who ? ` (${who})` : ""}, since ${h.acquiredAt}, until ${h.expiresAt}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Move a stale lock aside and remove it. Puts it back when another writer took the lock in between. */
|
|
202
|
+
function breakLock(lock: string, stale: WriteLockHolder | null): void {
|
|
203
|
+
const aside = `${lock}.broken-${randomBytes(6).toString("hex")}`;
|
|
204
|
+
try {
|
|
205
|
+
renameSync(lock, aside);
|
|
206
|
+
} catch {
|
|
207
|
+
return; // Gone already: someone else broke or released it.
|
|
208
|
+
}
|
|
209
|
+
const moved = readHolder(aside);
|
|
210
|
+
if (moved !== null && stale !== null && moved.token !== stale.token) {
|
|
211
|
+
// A fresh lock was moved aside: give it back if nobody has taken the path since.
|
|
212
|
+
try {
|
|
213
|
+
renameSync(aside, lock);
|
|
214
|
+
return;
|
|
215
|
+
} catch {
|
|
216
|
+
// The path was taken again; the fresh holder's release finds its lock gone, which it tolerates.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
rmSync(aside, { recursive: true, force: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Try once to take the lock. Returns the holder written, or the holder in the way. */
|
|
223
|
+
function tryTake(lock: string, holder: WriteLockHolder, mayBreak = true): { ok: true } | { ok: false; holder: WriteLockHolder | null } {
|
|
224
|
+
try {
|
|
225
|
+
mkdirSync(lock);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
|
|
228
|
+
const current = readHolder(lock);
|
|
229
|
+
if (mayBreak && staleness(lock, current, Date.now()) !== null) {
|
|
230
|
+
breakLock(lock, current);
|
|
231
|
+
return tryTake(lock, holder, false);
|
|
232
|
+
}
|
|
233
|
+
return { ok: false, holder: current };
|
|
234
|
+
}
|
|
235
|
+
const tmp = join(lock, `owner.${process.pid}.tmp`);
|
|
236
|
+
writeFileSync(tmp, `${JSON.stringify(holder, null, 2)}\n`);
|
|
237
|
+
renameSync(tmp, join(lock, "owner.json"));
|
|
238
|
+
return { ok: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function waitMs(): number {
|
|
242
|
+
const raw = process.env[WRITE_LOCK_WAIT_ENV];
|
|
243
|
+
const n = raw === undefined || raw === "" ? NaN : Number(raw);
|
|
244
|
+
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_WAIT_MS;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function newHolder(who: WriteLockWho, ttlMs: number, pid: number | null): WriteLockHolder {
|
|
248
|
+
const now = Date.now();
|
|
249
|
+
return {
|
|
250
|
+
token: randomBytes(16).toString("hex"),
|
|
251
|
+
pid,
|
|
252
|
+
host: HOST,
|
|
253
|
+
verb: who.verb,
|
|
254
|
+
by: who.by ?? null,
|
|
255
|
+
agent: who.agent ?? null,
|
|
256
|
+
acquiredAt: new Date(now).toISOString(),
|
|
257
|
+
expiresAt: new Date(now + ttlMs).toISOString(),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Whether this call needs no lock of its own: the write it runs inside holds
|
|
263
|
+
* it, or the environment names the token of a batch holding it. Throws
|
|
264
|
+
* write-lock-not-held when the token named no longer holds it.
|
|
265
|
+
*/
|
|
266
|
+
function alreadyHeld(lock: string): boolean {
|
|
267
|
+
if (context.getStore()?.has(lock)) return true;
|
|
268
|
+
const token = process.env[WRITE_LOCK_ENV];
|
|
269
|
+
if (token === undefined || token === "") return false;
|
|
270
|
+
const current = readHolder(lock);
|
|
271
|
+
if (current?.token !== token || staleness(lock, current, Date.now()) !== null) {
|
|
272
|
+
throw new WriteLockError(
|
|
273
|
+
"write-lock-not-held",
|
|
274
|
+
`${WRITE_LOCK_ENV} names a lock token that no longer holds this working tree's write lock (${current ? `it is held by ${describe(current)}` : "nobody holds it"}): the batch's lock was released or expired, so take it again with chant workspace lock acquire and re-read what the batch writes`,
|
|
275
|
+
current,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Remove the lock at `lock` when `token` holds it. Returns whether it did. */
|
|
282
|
+
export function releaseLock(lock: string, token: string): boolean {
|
|
283
|
+
const current = readHolder(lock);
|
|
284
|
+
if (current?.token !== token) return false;
|
|
285
|
+
try {
|
|
286
|
+
unlinkSync(join(lock, "owner.json"));
|
|
287
|
+
} catch {
|
|
288
|
+
// Already gone.
|
|
289
|
+
}
|
|
290
|
+
rmSync(lock, { recursive: true, force: true });
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function timeout(lock: string, current: WriteLockHolder | null, ms: number): WriteLockError {
|
|
295
|
+
return new WriteLockError(
|
|
296
|
+
"write-lock-timeout",
|
|
297
|
+
`another write holds this working tree's write lock (${lock}): ${describe(current)}. Waited ${ms}ms; run the write again once it is done, or raise ${WRITE_LOCK_WAIT_ENV}`,
|
|
298
|
+
current,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const pause = (): number => 20 + Math.floor(Math.random() * 40);
|
|
303
|
+
|
|
304
|
+
/** Take the lock at `lock` as `holder`, waiting up to the wait limit. */
|
|
305
|
+
async function takeWaiting(lock: string, holder: WriteLockHolder): Promise<void> {
|
|
306
|
+
const limit = waitMs();
|
|
307
|
+
const begun = Date.now();
|
|
308
|
+
for (;;) {
|
|
309
|
+
const got = tryTake(lock, holder);
|
|
310
|
+
if (got.ok) return;
|
|
311
|
+
if (Date.now() - begun >= limit) throw timeout(lock, got.holder, limit);
|
|
312
|
+
await new Promise((r) => setTimeout(r, pause()));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Run `fn` holding the working tree's write lock, taken for this one write
|
|
318
|
+
* and released when `fn` settles. With `dryRun`, without it, since a dry run
|
|
319
|
+
* writes nothing. Throws a {@link WriteLockError} when the lock can't be had.
|
|
320
|
+
*/
|
|
321
|
+
export async function withWriteLock<T>(start: string, who: WriteLockWho, dryRun: boolean | undefined, fn: () => Promise<T>): Promise<T> {
|
|
322
|
+
if (dryRun) return fn();
|
|
323
|
+
const lock = writeLockPath(start);
|
|
324
|
+
if (alreadyHeld(lock)) return fn();
|
|
325
|
+
const holder = newHolder(who, COMMAND_TTL_MS, process.pid);
|
|
326
|
+
await takeWaiting(lock, holder);
|
|
327
|
+
heldHere.add(lock);
|
|
328
|
+
try {
|
|
329
|
+
return await context.run(new Set([...(context.getStore() ?? []), lock]), fn);
|
|
330
|
+
} finally {
|
|
331
|
+
heldHere.delete(lock);
|
|
332
|
+
releaseLock(lock, holder.token);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* {@link withWriteLock} for a write that is synchronous throughout: it
|
|
338
|
+
* blocks while it waits. When another write in this same process holds the
|
|
339
|
+
* lock, blocking would keep that write from ever finishing, so it is refused
|
|
340
|
+
* with write-lock-timeout at once.
|
|
341
|
+
*/
|
|
342
|
+
export function withWriteLockSync<T>(start: string, who: WriteLockWho, dryRun: boolean | undefined, fn: () => T): T {
|
|
343
|
+
if (dryRun) return fn();
|
|
344
|
+
const lock = writeLockPath(start);
|
|
345
|
+
if (alreadyHeld(lock)) return fn();
|
|
346
|
+
if (heldHere.has(lock)) throw timeout(lock, readHolder(lock), 0);
|
|
347
|
+
const holder = newHolder(who, COMMAND_TTL_MS, process.pid);
|
|
348
|
+
const limit = waitMs();
|
|
349
|
+
const begun = Date.now();
|
|
350
|
+
const cell = new Int32Array(new SharedArrayBuffer(4));
|
|
351
|
+
for (;;) {
|
|
352
|
+
const got = tryTake(lock, holder);
|
|
353
|
+
if (got.ok) break;
|
|
354
|
+
if (Date.now() - begun >= limit) throw timeout(lock, got.holder, limit);
|
|
355
|
+
Atomics.wait(cell, 0, 0, pause());
|
|
356
|
+
}
|
|
357
|
+
heldHere.add(lock);
|
|
358
|
+
try {
|
|
359
|
+
return context.run(new Set([...(context.getStore() ?? []), lock]), fn);
|
|
360
|
+
} finally {
|
|
361
|
+
heldHere.delete(lock);
|
|
362
|
+
releaseLock(lock, holder.token);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Take the lock for a batch held across chant calls (`lock acquire`): no
|
|
368
|
+
* process holds it, so it lasts until it is released or `ttlMs` passes.
|
|
369
|
+
* Waits like a write does.
|
|
370
|
+
*/
|
|
371
|
+
export async function acquireBatchLock(start: string, who: WriteLockWho, ttlMs: number): Promise<{ path: string; holder: WriteLockHolder }> {
|
|
372
|
+
const lock = writeLockPath(start);
|
|
373
|
+
const holder = newHolder({ ...who, verb: "batch" }, ttlMs, null);
|
|
374
|
+
await takeWaiting(lock, holder);
|
|
375
|
+
return { path: lock, holder };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Who holds the working tree's write lock now, or null when nobody does (a stale lock counts as nobody). */
|
|
379
|
+
export function writeLockHolder(start: string): { path: string; holder: WriteLockHolder | null } {
|
|
380
|
+
const lock = writeLockPath(start);
|
|
381
|
+
if (!existsSync(lock)) return { path: lock, holder: null };
|
|
382
|
+
const current = readHolder(lock);
|
|
383
|
+
return { path: lock, holder: staleness(lock, current, Date.now()) === null ? current : null };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── Writing a file ───────────────────────────────────────────────────────────
|
|
387
|
+
|
|
388
|
+
/** Write `text` to `file` through a sibling temporary file, so a reader never sees half of it. */
|
|
389
|
+
export function writeFileAtomic(file: string, text: string): void {
|
|
390
|
+
const tmp = `${file}.chant-${process.pid}-${randomBytes(4).toString("hex")}.tmp`;
|
|
391
|
+
writeFileSync(tmp, text);
|
|
392
|
+
try {
|
|
393
|
+
renameSync(tmp, file);
|
|
394
|
+
} catch (err) {
|
|
395
|
+
rmSync(tmp, { force: true });
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ── The journal ──────────────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
/** The last chant write of a record file, as the read contract reports it. */
|
|
403
|
+
export interface LastWrite {
|
|
404
|
+
/** The write command, such as `records amend` or `points answer`. */
|
|
405
|
+
verb: string;
|
|
406
|
+
/** The principal the write named (--by, or the channel's author), or null when it named none. */
|
|
407
|
+
by: string | null;
|
|
408
|
+
/** The agent session it was made in (CHANT_AGENT), or null. */
|
|
409
|
+
agent: string | null;
|
|
410
|
+
/** When, as an ISO 8601 time. */
|
|
411
|
+
at: string;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
interface Journal {
|
|
415
|
+
version: 1;
|
|
416
|
+
/** By record path from the repository root: the file's SHA-256 after the write, and the write. */
|
|
417
|
+
files: Record<string, LastWrite & { sha256: string }>;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** How many files the journal remembers; the oldest are forgotten first. */
|
|
421
|
+
const JOURNAL_LIMIT = 2_000;
|
|
422
|
+
|
|
423
|
+
export function sha256Text(text: string): string {
|
|
424
|
+
return createHash("sha256").update(text, "utf-8").digest("hex");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function readJournal(file: string): Journal {
|
|
428
|
+
try {
|
|
429
|
+
const j = JSON.parse(readFileSync(file, "utf-8")) as Journal;
|
|
430
|
+
if (j && j.version === 1 && j.files && typeof j.files === "object") return j;
|
|
431
|
+
} catch {
|
|
432
|
+
// Missing or unreadable: start again. It is a cache.
|
|
433
|
+
}
|
|
434
|
+
return { version: 1, files: {} };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Note in the journal that `who` wrote each file, with the text it now
|
|
439
|
+
* holds. Called holding the lock, after the write. Best effort: a journal
|
|
440
|
+
* that can't be written never fails the write it notes.
|
|
441
|
+
*/
|
|
442
|
+
export function noteWrites(start: string, writes: readonly { path: string; text: string }[], who: WriteLockWho): void {
|
|
443
|
+
const file = writeJournalPath(start);
|
|
444
|
+
if (file === null || writes.length === 0) return;
|
|
445
|
+
try {
|
|
446
|
+
const journal = readJournal(file);
|
|
447
|
+
const at = new Date().toISOString();
|
|
448
|
+
for (const w of writes) {
|
|
449
|
+
delete journal.files[w.path];
|
|
450
|
+
journal.files[w.path] = { sha256: sha256Text(w.text), verb: who.verb, by: who.by ?? null, agent: who.agent ?? null, at };
|
|
451
|
+
}
|
|
452
|
+
const keys = Object.keys(journal.files);
|
|
453
|
+
for (const k of keys.slice(0, Math.max(0, keys.length - JOURNAL_LIMIT))) delete journal.files[k];
|
|
454
|
+
writeFileAtomic(file, `${JSON.stringify(journal)}\n`);
|
|
455
|
+
} catch {
|
|
456
|
+
// A cache: never fail the write over it.
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* A reader of the journal for the working tree `start` is in: the last chant
|
|
462
|
+
* write of a record file, while the file still holds the text that write
|
|
463
|
+
* left, and null otherwise (written by something else since, or never by
|
|
464
|
+
* chant here).
|
|
465
|
+
*/
|
|
466
|
+
export function lastWriteReader(start: string): (path: string, text: string | null) => LastWrite | null {
|
|
467
|
+
const file = writeJournalPath(start);
|
|
468
|
+
const journal = file !== null && existsSync(file) ? readJournal(file) : null;
|
|
469
|
+
return (path, text) => {
|
|
470
|
+
const e = journal?.files[path];
|
|
471
|
+
if (!e || text === null || e.sha256 !== sha256Text(text)) return null;
|
|
472
|
+
return { verb: e.verb, by: e.by, agent: e.agent, at: e.at };
|
|
473
|
+
};
|
|
474
|
+
}
|