@intentius/chant 0.90.0 → 0.93.0
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/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +20 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +7 -4
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +42 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +22 -0
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +19 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/symptoms.d.ts +51 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -1
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +4 -2
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +14 -3
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
- package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
- package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
- package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
- package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +203 -2
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/apply.d.ts.map +1 -1
- package/dist/op/activities/change-coverage.d.ts +44 -0
- package/dist/op/activities/change-coverage.d.ts.map +1 -0
- package/dist/op/activities/converge.d.ts +24 -1
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/dist/op/activities/decide.d.ts +107 -0
- package/dist/op/activities/decide.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +32 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/source-release.d.ts +86 -0
- package/dist/op/activities/source-release.d.ts.map +1 -0
- package/dist/op/activities/source-rollback.d.ts +100 -0
- package/dist/op/activities/source-rollback.d.ts.map +1 -0
- package/dist/op/activities/work-evidence.d.ts +42 -0
- package/dist/op/activities/work-evidence.d.ts.map +1 -0
- package/dist/op/activity-contract.d.ts +1 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +16 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +84 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/converge-op.d.ts +31 -4
- package/dist/op/composites/converge-op.d.ts.map +1 -1
- package/dist/op/decide-backend.d.ts +116 -0
- package/dist/op/decide-backend.d.ts.map +1 -0
- package/dist/op/decide-config.d.ts +120 -0
- package/dist/op/decide-config.d.ts.map +1 -0
- package/dist/op/decide-read-inputs.d.ts +40 -0
- package/dist/op/decide-read-inputs.d.ts.map +1 -0
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-wait.d.ts +43 -0
- package/dist/op/gate-wait.d.ts.map +1 -0
- package/dist/op/gate.d.ts +5 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +18 -5
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +48 -4
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -0
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +12 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/op/operator.d.ts +126 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +46 -5
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/source-archive.d.ts +63 -0
- package/dist/op/source-archive.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward-points.d.ts +136 -0
- package/dist/op/steward-points.d.ts.map +1 -0
- package/dist/op/steward-turn.d.ts +53 -0
- package/dist/op/steward-turn.d.ts.map +1 -0
- package/dist/op/steward.d.ts +161 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +58 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
- package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
- package/dist/workspace/answers-ledger.d.ts +76 -0
- package/dist/workspace/answers-ledger.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts +16 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -0
- package/dist/workspace/changes.d.ts +200 -0
- package/dist/workspace/changes.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
- package/dist/workspace/chant-migrations.d.ts +99 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +104 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/diagrams.d.ts +31 -0
- package/dist/workspace/checks/diagrams.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +8 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +13 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +2 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +229 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +103 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +285 -0
- package/dist/workspace/declared-kinds.d.ts +32 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/generated-files.d.ts +7 -0
- package/dist/workspace/generated-files.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/lineage-check.d.ts +3 -0
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +6 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +39 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/point-answer.schema.json +95 -0
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +38 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +23 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +1 -1
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +11 -5
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +42 -6
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +7 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +179 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +68 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +189 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/dist/workspace/work-evidence.d.ts +74 -0
- package/dist/workspace/work-evidence.d.ts.map +1 -0
- package/dist/workspace/work.d.ts +43 -1
- package/dist/workspace/work.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/audit.test.ts +9 -1
- package/src/cli/commands/build.ts +3 -2
- package/src/cli/commands/lint.ts +7 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +16 -2
- package/src/cli/handlers/operator.ts +141 -4
- package/src/cli/handlers/run.test.ts +173 -2
- package/src/cli/handlers/run.ts +174 -3
- package/src/cli/main.test.ts +50 -0
- package/src/cli/main.ts +97 -2
- package/src/cli/mcp/workspace-tools.test.ts +48 -19
- package/src/cli/mcp/workspace-tools.ts +110 -8
- package/src/cli/plugins.ts +11 -3
- package/src/cli/registry.ts +42 -1
- package/src/codegen/pinned-upgrade.ts +2 -1
- package/src/codegen/release-wiring.test.ts +49 -2
- package/src/components/capability-plugin-loader.ts +3 -3
- package/src/components/cli-support.ts +2 -2
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/config.ts +11 -0
- package/src/lexicon-module.ts +56 -1
- package/src/lexicon-package.test.ts +96 -0
- package/src/lifecycle/converge-ledger.ts +20 -0
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/run-ledger.ts +6 -0
- package/src/lifecycle/symptoms.ts +95 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/lint/rules/index.ts +6 -2
- package/src/lint/rules/op/index.ts +17 -4
- package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
- package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
- package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
- package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
- package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
- package/src/lint/rules/sys001-no-literal-key.ts +67 -0
- package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
- package/src/op/activities/activity-contracts.ts +190 -1
- package/src/op/activities/apply.ts +7 -6
- package/src/op/activities/change-coverage.ts +60 -0
- package/src/op/activities/converge.test.ts +55 -1
- package/src/op/activities/converge.ts +208 -29
- package/src/op/activities/decide.live.test.ts +78 -0
- package/src/op/activities/decide.test.ts +300 -0
- package/src/op/activities/decide.ts +267 -0
- package/src/op/activities/index.ts +21 -0
- package/src/op/activities/shell.test.ts +91 -1
- package/src/op/activities/shell.ts +91 -2
- package/src/op/activities/source-release.test.ts +118 -0
- package/src/op/activities/source-release.ts +168 -0
- package/src/op/activities/source-rollback.test.ts +131 -0
- package/src/op/activities/source-rollback.ts +239 -0
- package/src/op/activities/work-evidence.ts +62 -0
- package/src/op/activity-contract-registry.test.ts +2 -0
- package/src/op/activity-contract-registry.ts +2 -2
- package/src/op/activity-contract.test.ts +12 -0
- package/src/op/activity-contract.ts +6 -1
- package/src/op/activity-profiles.ts +29 -0
- package/src/op/activity-registry.test.ts +1 -1
- package/src/op/activity-registry.ts +2 -2
- package/src/op/builders.ts +124 -5
- package/src/op/command-gate.test.ts +192 -0
- package/src/op/composites/converge-op.test.ts +27 -1
- package/src/op/composites/converge-op.ts +69 -15
- package/src/op/decide-backend.test.ts +44 -0
- package/src/op/decide-backend.ts +212 -0
- package/src/op/decide-config.test.ts +52 -0
- package/src/op/decide-config.ts +80 -0
- package/src/op/decide-read-inputs.ts +117 -0
- package/src/op/discover.ts +71 -0
- package/src/op/gate-approval.ts +2 -2
- package/src/op/gate-wait.ts +57 -0
- package/src/op/gate.ts +8 -3
- package/src/op/generate-pipeline.ts +2 -2
- package/src/op/index.ts +34 -6
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +367 -33
- package/src/op/local-output.test.ts +33 -0
- package/src/op/local-output.ts +33 -2
- package/src/op/op-ir.test.ts +10 -0
- package/src/op/op-ir.ts +18 -0
- package/src/op/op-verb-class.ts +1 -0
- package/src/op/operator.ts +360 -10
- package/src/op/runtime.ts +37 -8
- package/src/op/runtimes/local.ts +6 -1
- package/src/op/source-archive.ts +161 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward-points.test.ts +313 -0
- package/src/op/steward-points.ts +194 -0
- package/src/op/steward-turn.ts +79 -0
- package/src/op/steward.test.ts +296 -0
- package/src/op/steward.ts +277 -0
- package/src/op/types.ts +60 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +345 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
- package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
- package/src/workspace/answers-ledger.ts +118 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/changes-cli.ts +56 -0
- package/src/workspace/changes.schema.json +472 -0
- package/src/workspace/changes.test.ts +315 -0
- package/src/workspace/changes.ts +512 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
- package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
- package/src/workspace/chant-migrations.ts +164 -0
- package/src/workspace/check-contract.test.ts +5 -1
- package/src/workspace/check.schema.json +25 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +276 -0
- package/src/workspace/checks/boxes.ts +442 -0
- package/src/workspace/checks/diagrams.test.ts +134 -0
- package/src/workspace/checks/diagrams.ts +108 -0
- package/src/workspace/checks/records.ts +72 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +27 -3
- package/src/workspace/compose-graph.ts +2 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
- package/src/workspace/conformance/index.ts +5 -0
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +709 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +285 -0
- package/src/workspace/declaration.test.ts +58 -0
- package/src/workspace/declaration.ts +241 -0
- package/src/workspace/declared-kinds.test.ts +5 -5
- package/src/workspace/declared-kinds.ts +71 -3
- package/src/workspace/generated-files.ts +14 -0
- package/src/workspace/graph-cli.ts +1 -1
- package/src/workspace/graph.schema.json +9 -1
- package/src/workspace/intent.schema.json +12 -3
- package/src/workspace/intent.ts +3 -10
- package/src/workspace/lineage-check.ts +16 -1
- package/src/workspace/lineage-upgrade-cli.ts +1 -1
- package/src/workspace/lineage-upgrade.test.ts +70 -0
- package/src/workspace/lineage-upgrade.ts +81 -10
- package/src/workspace/ls-contract.test.ts +57 -0
- package/src/workspace/ls.schema.json +46 -1
- package/src/workspace/ls.ts +62 -3
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +284 -0
- package/src/workspace/points-write.schema.json +499 -0
- package/src/workspace/points.schema.json +743 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +24 -1
- package/src/workspace/reason-codes.ts +49 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-amend.schema.json +2 -0
- package/src/workspace/records-cli.ts +28 -5
- package/src/workspace/records-close.schema.json +2 -0
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records-new.schema.json +2 -0
- package/src/workspace/records-review.schema.json +2 -0
- package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
- package/src/workspace/records-sessions-write.test.ts +2 -34
- package/src/workspace/records-write.e2e.test.ts +108 -0
- package/src/workspace/records-write.test.ts +32 -41
- package/src/workspace/records-write.ts +33 -15
- package/src/workspace/records.schema.json +53 -2
- package/src/workspace/records.test.ts +48 -3
- package/src/workspace/records.ts +142 -6
- package/src/workspace/status-contract.test.ts +189 -6
- package/src/workspace/status-gates.ts +9 -4
- package/src/workspace/status-stewards.ts +329 -0
- package/src/workspace/status.schema.json +335 -5
- package/src/workspace/status.ts +142 -6
- package/src/workspace/work-acceptance.test.ts +274 -0
- package/src/workspace/work-cli.e2e.test.ts +180 -0
- package/src/workspace/work-cli.ts +429 -0
- package/src/workspace/work-evidence.schema.json +212 -0
- package/src/workspace/work-evidence.ts +169 -0
- package/src/workspace/work-history.schema.json +349 -0
- package/src/workspace/work-history.test.ts +156 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
- package/src/workspace/work.test.ts +3 -2
- package/src/workspace/work.ts +100 -1
- /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
- /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
- /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release plan store (ws-055, #2733, part of the chud retirement epic
|
|
3
|
+
* #2715): persists a release plan — the work items and evidence a release
|
|
4
|
+
* ships — to the `chant/lifecycle` orphan branch, content-addressed by the
|
|
5
|
+
* plan's own digest.
|
|
6
|
+
*
|
|
7
|
+
* **Why this exists.** ws-055 (docs/design/decisions/ws-055-dev-model-
|
|
8
|
+
* ledgers.md) decided where a development model's plans live: "a release
|
|
9
|
+
* plan is written content-addressed to `_plans/<digest>.json` on
|
|
10
|
+
* chant/lifecycle, and the release ledger record already names that
|
|
11
|
+
* digest." A `ReleaseRecord.digest` (../lifecycle/release-ledger.ts) is
|
|
12
|
+
* ordinarily an artifact digest; for a release a runner (the studio kit
|
|
13
|
+
* today, chud before it) plans work-item by work-item, that same field
|
|
14
|
+
* *is* the plan's own digest — the release ledger needs no new field to
|
|
15
|
+
* point at it, it already names the plan. Reading a release through the
|
|
16
|
+
* read contract (../workspace/status.ts) resolves that digest to the plan
|
|
17
|
+
* exactly the way it already resolves a build archive digest to a
|
|
18
|
+
* `BuildArchiveManifest` (./build-ledger-store.ts) — this module is that
|
|
19
|
+
* store's plan-shaped sibling, same plumbing, same directory-per-kind
|
|
20
|
+
* convention on the one orphan branch.
|
|
21
|
+
*
|
|
22
|
+
* **Storage.** Reuses `writeBlobToPath`/`readBlobFromPath` (./git.ts) —
|
|
23
|
+
* the identical hash-object -> mktree -> commit-tree -> update-ref
|
|
24
|
+
* pipeline `writeSnapshot`/`persistBuildManifest` already use. Plans live
|
|
25
|
+
* under a fixed top-level `_plans/` directory, a peer of `_builds/` and the
|
|
26
|
+
* per-env directories, never nested inside one — a plan is not env-scoped,
|
|
27
|
+
* it is named by its own content. Per #2524 D7 / #2538, `writeBlobToPath`'s
|
|
28
|
+
* `ledgerDir` already folds a workspace member's own `_members/<member>/`
|
|
29
|
+
* prefix in ahead of `_plans/`, so a member's plans live at
|
|
30
|
+
* `_members/<member>/_plans/<digest>.json` the same way its build records
|
|
31
|
+
* live at `_members/<member>/_builds/` — this module does nothing itself
|
|
32
|
+
* to earn that; it falls out of reusing the shared plumbing.
|
|
33
|
+
*
|
|
34
|
+
* **Content-addressed, keyed by the plan's own `digest`.** One file per
|
|
35
|
+
* plan, `_plans/<digest-with-":"->"_">.json` (`:` is not usable in a git
|
|
36
|
+
* tree entry name the way `readBlobFromPath`'s `<ref>:<path>` spelling
|
|
37
|
+
* reads it back, the same reason `./build-ledger-store.ts` substitutes it
|
|
38
|
+
* for `_builds/`). The digest is computed by whoever plans the release —
|
|
39
|
+
* the release Op, or a runner like the studio kit — not by chant; this
|
|
40
|
+
* module only trusts and stores it, exactly as `persistBuildManifest`
|
|
41
|
+
* trusts a manifest's own `manifestDigest`.
|
|
42
|
+
*
|
|
43
|
+
* **A plan already written is never rewritten (#2733 acceptance).**
|
|
44
|
+
* Unlike `persistBuildManifest`, which writes unconditionally every call
|
|
45
|
+
* (safe only because identical content produces an identical blob and,
|
|
46
|
+
* usually, an identical tree — but still a new, redundant commit on the
|
|
47
|
+
* branch each time), `persistReleasePlan` reads the path first and returns
|
|
48
|
+
* without writing when a plan is already stored under that digest. A plan
|
|
49
|
+
* is immutable by construction (it is named by a hash of its own content),
|
|
50
|
+
* so there is never a reason to overwrite one — only ever a reason to skip
|
|
51
|
+
* the write.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { sortedJsonReplacer } from "../utils";
|
|
55
|
+
import { writeBlobToPath, readBlobFromPath, RefCASConflictError } from "./git";
|
|
56
|
+
|
|
57
|
+
/** Fixed top-level directory on the `chant/lifecycle` orphan branch that holds every persisted release plan — a peer of `_builds/` and the per-env directories, never nested inside one (see module doc for why). */
|
|
58
|
+
const PLANS_DIR = "_plans";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A release plan (ws-055): the work items and evidence a release ships.
|
|
62
|
+
* chant does not own this shape — it is a runner's own record, the way a
|
|
63
|
+
* record kind's schema is the kind's own (../workspace/records.ts) — only
|
|
64
|
+
* `digest` is required, since it is the storage key. Every other field is
|
|
65
|
+
* read back and returned exactly as written.
|
|
66
|
+
*/
|
|
67
|
+
export interface ReleasePlan {
|
|
68
|
+
/**
|
|
69
|
+
* Content-addressed digest of this plan (`sha256:...`) — the same digest
|
|
70
|
+
* the release ledger record names (`ReleaseRecord.digest`,
|
|
71
|
+
* ../lifecycle/release-ledger.ts), so a record leads to its plan.
|
|
72
|
+
* Computed by the caller before persisting; this module trusts it rather
|
|
73
|
+
* than recomputing it, the same way `persistBuildManifest` trusts a
|
|
74
|
+
* manifest's own `manifestDigest`.
|
|
75
|
+
*/
|
|
76
|
+
digest: string;
|
|
77
|
+
[key: string]: unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Thrown by `persistReleasePlan` when the plan carries no usable `digest` — a plan can't be content-addressed without one. */
|
|
81
|
+
export class InvalidReleasePlanError extends Error {
|
|
82
|
+
constructor(message: string) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.name = "InvalidReleasePlanError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Turn a `sha256:...`-style digest into a filesystem/git-tree-safe filename stem. */
|
|
89
|
+
function digestToFilenameStem(digest: string): string {
|
|
90
|
+
return digest.replace(/:/g, "_");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function planFilename(digest: string): string {
|
|
94
|
+
return `${digestToFilenameStem(digest)}.json`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Persist a release plan to the orphan branch, keyed by its own `digest`.
|
|
99
|
+
* Does not push to the remote — call `pushLifecycle` (./git.ts) afterward,
|
|
100
|
+
* the same two-step (`write` then `push`) shape `appendReleaseRecord`/
|
|
101
|
+
* `persistBuildManifest` use, so a caller persisting a plan alongside the
|
|
102
|
+
* release record it names can batch both into one push.
|
|
103
|
+
*
|
|
104
|
+
* Never rewrites a plan already stored under `plan.digest` (#2733
|
|
105
|
+
* acceptance) — reads the path first, and returns `{ commit: null, written:
|
|
106
|
+
* false }` without writing when it is already there. A `RefCASConflictError`
|
|
107
|
+
* from a genuine race (another writer persisted the same digest's plan
|
|
108
|
+
* between this call's read and its write) is treated the same way: the
|
|
109
|
+
* plan is content-addressed, so whatever is already at that path under this
|
|
110
|
+
* digest is authoritative, never overwritten.
|
|
111
|
+
*/
|
|
112
|
+
export async function persistReleasePlan(
|
|
113
|
+
plan: ReleasePlan,
|
|
114
|
+
opts?: { cwd?: string },
|
|
115
|
+
): Promise<{ commit: string | null; written: boolean }> {
|
|
116
|
+
if (typeof plan.digest !== "string" || plan.digest.length === 0) {
|
|
117
|
+
throw new InvalidReleasePlanError("release plan is missing its own \"digest\" field — a release plan is content-addressed by its own digest");
|
|
118
|
+
}
|
|
119
|
+
const filename = planFilename(plan.digest);
|
|
120
|
+
const existing = await readBlobFromPath(PLANS_DIR, filename, opts);
|
|
121
|
+
if (existing !== null) return { commit: null, written: false };
|
|
122
|
+
|
|
123
|
+
const json = JSON.stringify(plan, sortedJsonReplacer);
|
|
124
|
+
try {
|
|
125
|
+
const commit = await writeBlobToPath(PLANS_DIR, filename, json, "Release plan", { ...opts, expectPriorPathSha: null });
|
|
126
|
+
return { commit, written: true };
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (err instanceof RefCASConflictError) return { commit: null, written: false };
|
|
129
|
+
throw err;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Read a persisted release plan back by its own digest — the same digest a
|
|
135
|
+
* `ReleaseRecord.digest` names. Returns `null` when no plan was ever
|
|
136
|
+
* persisted under that digest (never throws — most releases carry no plan
|
|
137
|
+
* at all, and reading one for status is a normal, expected miss), and when
|
|
138
|
+
* the stored blob fails to parse as JSON.
|
|
139
|
+
*
|
|
140
|
+
* `prefix` lets a multi-member reader (../workspace/status.ts) resolve a
|
|
141
|
+
* specific member's plan directory directly, the same way
|
|
142
|
+
* `readReleaseLedgerLines` is handed an already-member-prefixed path there
|
|
143
|
+
* — `cwd` alone can't do it, since one status read walks every member from
|
|
144
|
+
* one process without changing directory per member.
|
|
145
|
+
*/
|
|
146
|
+
export async function readReleasePlan(
|
|
147
|
+
digest: string,
|
|
148
|
+
opts?: { cwd?: string; prefix?: string },
|
|
149
|
+
): Promise<ReleasePlan | null> {
|
|
150
|
+
const dir = `${opts?.prefix ?? ""}${PLANS_DIR}`;
|
|
151
|
+
const content = await readBlobFromPath(dir, planFilename(digest), opts);
|
|
152
|
+
if (!content) return null;
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(content) as ReleasePlan;
|
|
155
|
+
} catch {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -85,6 +85,8 @@ export function buildRunRecord(
|
|
|
85
85
|
status: OpRunRecord["status"];
|
|
86
86
|
id?: string;
|
|
87
87
|
gate?: OpRunRecord["gate"];
|
|
88
|
+
point?: OpRunRecord["point"];
|
|
89
|
+
steward?: string;
|
|
88
90
|
},
|
|
89
91
|
): OpRunRecordInput {
|
|
90
92
|
const phases: OpRunPhaseRecord[] = [];
|
|
@@ -106,6 +108,8 @@ export function buildRunRecord(
|
|
|
106
108
|
...(record.approval ? { approval: record.approval } : {}),
|
|
107
109
|
...(record.error !== undefined ? { error: record.error } : {}),
|
|
108
110
|
...(record.refusal !== undefined ? { refusal: record.refusal } : {}),
|
|
111
|
+
...(record.point ? { point: record.point } : {}),
|
|
112
|
+
...(record.gate ? { gate: record.gate } : {}),
|
|
109
113
|
});
|
|
110
114
|
if (record.outcome) outcomes[record.outcome.name] = record.outcome.value;
|
|
111
115
|
}
|
|
@@ -122,6 +126,8 @@ export function buildRunRecord(
|
|
|
122
126
|
outcomes,
|
|
123
127
|
phases,
|
|
124
128
|
...(times.gate ? { gate: times.gate } : {}),
|
|
129
|
+
...(times.point ? { point: times.point } : {}),
|
|
130
|
+
...(times.steward ? { steward: times.steward } : {}),
|
|
125
131
|
};
|
|
126
132
|
}
|
|
127
133
|
|
|
@@ -124,3 +124,98 @@ export const CONVERGE_SYMPTOM_FIELDS: ReadonlySet<string> = new Set([
|
|
|
124
124
|
"unobservedReasons",
|
|
125
125
|
"totalCount",
|
|
126
126
|
]);
|
|
127
|
+
|
|
128
|
+
// ── Resources an observer step reports (#2778) ──────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One resource's verdict, as an observer step reports it: `in-sync` when it
|
|
132
|
+
* is what its declaration says, `drifted` when it isn't, and `unknown` when
|
|
133
|
+
* the observer could not tell.
|
|
134
|
+
*/
|
|
135
|
+
export type ResourceStatus = "in-sync" | "drifted" | "unknown";
|
|
136
|
+
|
|
137
|
+
/** One resource an observer step looked at. */
|
|
138
|
+
export interface ObservedResource {
|
|
139
|
+
name: string;
|
|
140
|
+
status: ResourceStatus;
|
|
141
|
+
/** Why the observer gave that verdict, in one line. */
|
|
142
|
+
detail?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* What a `ConvergeOp`'s observer step returns (#2778), for resources no
|
|
147
|
+
* lexicon declares or observes: the processes a supervisor runs in a box,
|
|
148
|
+
* for example. `ConvergeOp({ observe })` evaluates its rules once per
|
|
149
|
+
* resource, against a {@link ResourceSymptom}.
|
|
150
|
+
*/
|
|
151
|
+
export interface ResourceObservation {
|
|
152
|
+
resources: ObservedResource[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The record a `ConvergeOp` with an observer step evaluates each rule
|
|
157
|
+
* against, once per observed resource (#2778). A rule reads `status` (and
|
|
158
|
+
* may read `resource` to single one out); a `run()` it fires is dispatched
|
|
159
|
+
* for that resource, which the dispatched Op reads from
|
|
160
|
+
* `CHANT_CONVERGE_RESOURCE`.
|
|
161
|
+
*/
|
|
162
|
+
export interface ResourceSymptom {
|
|
163
|
+
env: string;
|
|
164
|
+
/** The resource's name, as the observer reported it. */
|
|
165
|
+
resource: string;
|
|
166
|
+
status: ResourceStatus;
|
|
167
|
+
/** The observer's one-line reason, or "". */
|
|
168
|
+
detail: string;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The environment variable a dispatched Op reads the resource from, when a
|
|
173
|
+
* ConvergeOp with an observer step fired its rule for one resource (#2778).
|
|
174
|
+
*/
|
|
175
|
+
export const CONVERGE_RESOURCE_ENV = "CHANT_CONVERGE_RESOURCE";
|
|
176
|
+
|
|
177
|
+
/** Every field a {@link ResourceSymptom} produces: OPS014's whitelist for a ConvergeOp with an observer step. */
|
|
178
|
+
export const RESOURCE_SYMPTOM_FIELDS: ReadonlySet<string> = new Set(["env", "resource", "status", "detail"]);
|
|
179
|
+
|
|
180
|
+
const RESOURCE_STATUSES: ReadonlySet<string> = new Set(["in-sync", "drifted", "unknown"]);
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Read an observer step's result as a {@link ResourceObservation}. The
|
|
184
|
+
* result is the observation itself, or a `shell()` step's result whose
|
|
185
|
+
* stdout is the observation as JSON. Throws, naming what is wrong, on
|
|
186
|
+
* anything else: a tick must not converge from a reading it can't parse.
|
|
187
|
+
*/
|
|
188
|
+
export function parseResourceObservation(value: unknown): ResourceObservation {
|
|
189
|
+
let candidate: unknown = value;
|
|
190
|
+
if (candidate && typeof candidate === "object" && !("resources" in candidate) && typeof (candidate as { stdout?: unknown }).stdout === "string") {
|
|
191
|
+
const stdout = (candidate as { stdout: string }).stdout;
|
|
192
|
+
try {
|
|
193
|
+
candidate = JSON.parse(stdout);
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`the observer step's stdout is not JSON: ${stdout.slice(0, 120)}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const resources = (candidate as { resources?: unknown } | null)?.resources;
|
|
199
|
+
if (!Array.isArray(resources)) {
|
|
200
|
+
throw new Error("the observer step returned no `resources` array: it must return { resources: [{ name, status }] }, or print it as JSON");
|
|
201
|
+
}
|
|
202
|
+
const seen = new Set<string>();
|
|
203
|
+
const out: ObservedResource[] = [];
|
|
204
|
+
for (const [i, r] of resources.entries()) {
|
|
205
|
+
const entry = r as { name?: unknown; status?: unknown; detail?: unknown } | null;
|
|
206
|
+
if (!entry || typeof entry.name !== "string" || entry.name === "") {
|
|
207
|
+
throw new Error(`the observer step's resources[${i}] has no name`);
|
|
208
|
+
}
|
|
209
|
+
if (typeof entry.status !== "string" || !RESOURCE_STATUSES.has(entry.status)) {
|
|
210
|
+
throw new Error(`the observer step's resource "${entry.name}" has status ${JSON.stringify(entry.status)}: it must be in-sync, drifted or unknown`);
|
|
211
|
+
}
|
|
212
|
+
if (seen.has(entry.name)) throw new Error(`the observer step reported resource "${entry.name}" twice`);
|
|
213
|
+
seen.add(entry.name);
|
|
214
|
+
out.push({
|
|
215
|
+
name: entry.name,
|
|
216
|
+
status: entry.status as ResourceStatus,
|
|
217
|
+
...(typeof entry.detail === "string" && entry.detail !== "" ? { detail: entry.detail } : {}),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
return { resources: out };
|
|
221
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #2732 — the work lease: the operator lease under `work/<id>`, with a
|
|
3
|
+
* `_leases/<id>.jsonl` history, coordinating workers in one clone and across
|
|
4
|
+
* clones through a bare remote.
|
|
5
|
+
*/
|
|
6
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
7
|
+
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
claimWorkLease,
|
|
13
|
+
listWorkLeases,
|
|
14
|
+
readLeaseHistory,
|
|
15
|
+
releaseWorkLease,
|
|
16
|
+
renewWorkLease,
|
|
17
|
+
workLeaseRef,
|
|
18
|
+
leaseHistoryPath,
|
|
19
|
+
} from "./work-lease";
|
|
20
|
+
import { readRefSha } from "./git";
|
|
21
|
+
|
|
22
|
+
const dirs: string[] = [];
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
for (const d of dirs) rmSync(d, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function tmp(prefix: string): string {
|
|
28
|
+
const d = realpathSync(mkdtempSync(join(tmpdir(), prefix)));
|
|
29
|
+
dirs.push(d);
|
|
30
|
+
return d;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function git(cwd: string, ...args: string[]): string {
|
|
34
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configure(dir: string): void {
|
|
38
|
+
git(dir, "config", "user.email", "test@chant.dev");
|
|
39
|
+
git(dir, "config", "user.name", "Test");
|
|
40
|
+
git(dir, "config", "commit.gpgsign", "false");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function initRepo(): string {
|
|
44
|
+
const dir = tmp("chant-work-lease-");
|
|
45
|
+
git(dir, "init", "-q", "-b", "main");
|
|
46
|
+
configure(dir);
|
|
47
|
+
writeFileSync(join(dir, "README.md"), "# t\n");
|
|
48
|
+
git(dir, "add", "README.md");
|
|
49
|
+
git(dir, "commit", "-q", "-m", "init");
|
|
50
|
+
return dir;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const at = (iso: string) => () => new Date(iso);
|
|
54
|
+
|
|
55
|
+
describe("a work lease in one clone", () => {
|
|
56
|
+
test("a claim takes a free item, and a second claim is refused with the holder named, the same holder's included", async () => {
|
|
57
|
+
const cwd = initRepo();
|
|
58
|
+
const first = await claimWorkLease("W-001", "alice", { cwd, ttlMs: 60_000 });
|
|
59
|
+
expect(first.ok).toBe(true);
|
|
60
|
+
if (!first.ok) return;
|
|
61
|
+
expect(first.lease).toMatchObject({ item: "W-001", holder: "alice" });
|
|
62
|
+
expect(await readRefSha(workLeaseRef("W-001"), { cwd })).not.toBeNull();
|
|
63
|
+
|
|
64
|
+
const second = await claimWorkLease("W-001", "bob", { cwd });
|
|
65
|
+
expect(second).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "alice", token: first.lease.token } });
|
|
66
|
+
if (!second.ok) expect(second.message).toContain("alice");
|
|
67
|
+
|
|
68
|
+
const again = await claimWorkLease("W-001", "alice", { cwd });
|
|
69
|
+
expect(again).toMatchObject({ ok: false, reason: "lease-held" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a renew moves the expiry and keeps the token; an expired lease can be claimed, with a new token", async () => {
|
|
73
|
+
const cwd = initRepo();
|
|
74
|
+
const claim = await claimWorkLease("W-002", "alice", { cwd, ttlMs: 60_000, now: at("2026-09-25T10:00:00.000Z") });
|
|
75
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
76
|
+
expect(claim.lease.expiresAt).toBe("2026-09-25T10:01:00.000Z");
|
|
77
|
+
|
|
78
|
+
const renew = await renewWorkLease("W-002", "alice", { cwd, ttlMs: 60_000, token: claim.lease.token, now: at("2026-09-25T10:00:30.000Z") });
|
|
79
|
+
if (!renew.ok) throw new Error(renew.message);
|
|
80
|
+
expect(renew.lease.token).toBe(claim.lease.token);
|
|
81
|
+
expect(renew.lease.acquiredAt).toBe(claim.lease.acquiredAt);
|
|
82
|
+
expect(renew.lease.expiresAt).toBe("2026-09-25T10:01:30.000Z");
|
|
83
|
+
|
|
84
|
+
// Another holder can't renew it, and a stale token is refused.
|
|
85
|
+
expect(await renewWorkLease("W-002", "bob", { cwd, now: at("2026-09-25T10:00:40.000Z") })).toMatchObject({ ok: false, reason: "lease-held" });
|
|
86
|
+
expect(await renewWorkLease("W-002", "alice", { cwd, token: "stale", now: at("2026-09-25T10:00:40.000Z") })).toMatchObject({ ok: false, reason: "lease-token-mismatch" });
|
|
87
|
+
|
|
88
|
+
// Past its expiry: renew is refused, and a claim by anyone mints a new token.
|
|
89
|
+
const late = at("2026-09-25T10:02:00.000Z");
|
|
90
|
+
expect(await renewWorkLease("W-002", "alice", { cwd, now: late })).toMatchObject({ ok: false, reason: "lease-not-held" });
|
|
91
|
+
const reclaim = await claimWorkLease("W-002", "bob", { cwd, ttlMs: 60_000, now: late });
|
|
92
|
+
if (!reclaim.ok) throw new Error(reclaim.message);
|
|
93
|
+
expect(reclaim.lease.holder).toBe("bob");
|
|
94
|
+
expect(reclaim.lease.token).not.toBe(claim.lease.token);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("release: the holder gives a live lease back; someone else can't, but anyone closes out an expired one", async () => {
|
|
98
|
+
const cwd = initRepo();
|
|
99
|
+
const t0 = "2026-09-25T10:00:00.000Z";
|
|
100
|
+
const claim = await claimWorkLease("W-003", "alice", { cwd, ttlMs: 60_000, now: at(t0) });
|
|
101
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
102
|
+
expect(await releaseWorkLease("W-003", "bob", { cwd, now: at(t0) })).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "alice" } });
|
|
103
|
+
const released = await releaseWorkLease("W-003", "alice", { cwd, outcome: "done", now: at(t0) });
|
|
104
|
+
expect(released.ok).toBe(true);
|
|
105
|
+
expect(await readRefSha(workLeaseRef("W-003"), { cwd })).toBeNull();
|
|
106
|
+
expect(await releaseWorkLease("W-003", "alice", { cwd })).toMatchObject({ ok: false, reason: "lease-not-held" });
|
|
107
|
+
|
|
108
|
+
const again = await claimWorkLease("W-003", "carol", { cwd, ttlMs: 1_000, now: at(t0) });
|
|
109
|
+
if (!again.ok) throw new Error(again.message);
|
|
110
|
+
const closed = await releaseWorkLease("W-003", "dispatcher", { cwd, outcome: "expired", now: at("2026-09-25T11:00:00.000Z") });
|
|
111
|
+
expect(closed).toMatchObject({ ok: true, lease: { holder: "carol" } });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("the history has one line per claim, renew and release, on chant/lifecycle", async () => {
|
|
115
|
+
const cwd = initRepo();
|
|
116
|
+
const claim = await claimWorkLease("W-004", "alice", { cwd, note: "starting" });
|
|
117
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
118
|
+
expect(claim.history.path).toBe(leaseHistoryPath("W-004"));
|
|
119
|
+
expect(claim.history.pushed).toBe(false);
|
|
120
|
+
await renewWorkLease("W-004", "alice", { cwd });
|
|
121
|
+
await renewWorkLease("W-004", "alice", { cwd });
|
|
122
|
+
await releaseWorkLease("W-004", "alice", { cwd, outcome: "done" });
|
|
123
|
+
|
|
124
|
+
const { records, malformed } = await readLeaseHistory("W-004", { cwd });
|
|
125
|
+
expect(malformed).toBe(0);
|
|
126
|
+
expect(records.map((r) => r.event)).toEqual(["claim", "renew", "renew", "release"]);
|
|
127
|
+
expect(new Set(records.map((r) => r.token)).size).toBe(1);
|
|
128
|
+
expect(records[0]).toMatchObject({ version: 1, item: "W-004", holder: "alice", by: "alice", note: "starting" });
|
|
129
|
+
expect(records[3]).toMatchObject({ outcome: "done" });
|
|
130
|
+
expect(git(cwd, "show", `chant/lifecycle:_leases/W-004.jsonl`).split("\n")).toHaveLength(4);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("listWorkLeases reports active and expired leases and leaves released ones out", async () => {
|
|
134
|
+
const cwd = initRepo();
|
|
135
|
+
const t0 = "2026-09-25T10:00:00.000Z";
|
|
136
|
+
await claimWorkLease("W-010", "alice", { cwd, ttlMs: 60_000, now: at(t0) });
|
|
137
|
+
await claimWorkLease("W-011", "bob", { cwd, ttlMs: 1_000, now: at(t0) });
|
|
138
|
+
await claimWorkLease("W-012", "carol", { cwd, ttlMs: 60_000, now: at(t0) });
|
|
139
|
+
await releaseWorkLease("W-012", "carol", { cwd, now: at(t0) });
|
|
140
|
+
const leases = await listWorkLeases({ cwd, memberPrefix: "", now: new Date("2026-09-25T10:00:30.000Z") });
|
|
141
|
+
expect(leases.map((l) => [l.item, l.holder, l.state])).toEqual([
|
|
142
|
+
["W-010", "alice", "active"],
|
|
143
|
+
["W-011", "bob", "expired"],
|
|
144
|
+
]);
|
|
145
|
+
expect(leases[0].ref).toBe("refs/chant/lease/work/W-010");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("an id that can't be one ref segment is refused", async () => {
|
|
149
|
+
const cwd = initRepo();
|
|
150
|
+
await expect(claimWorkLease("../x", "alice", { cwd })).rejects.toThrow(/can't key a work lease/);
|
|
151
|
+
await expect(claimWorkLease("a/b", "alice", { cwd })).rejects.toThrow(/can't key a work lease/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("a workspace member's leases and history sit under _members/<member>/ (D7)", async () => {
|
|
155
|
+
const cwd = initRepo();
|
|
156
|
+
writeFileSync(join(cwd, "chant.workspace.json"), JSON.stringify({ name: "acme", schema: 1, members: [{ name: "api", dir: "api", kind: "other", because: "t" }] }));
|
|
157
|
+
execFileSync("mkdir", ["-p", join(cwd, "api")]);
|
|
158
|
+
const member = join(cwd, "api");
|
|
159
|
+
const claim = await claimWorkLease("W-020", "alice", { cwd: member });
|
|
160
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
161
|
+
expect(await readRefSha("refs/chant/lease/_members/api/work/W-020", { cwd })).not.toBeNull();
|
|
162
|
+
expect(await readRefSha("refs/chant/lease/work/W-020", { cwd })).toBeNull();
|
|
163
|
+
expect(claim.history.path).toBe("_members/api/_leases/W-020.jsonl");
|
|
164
|
+
expect(git(cwd, "show", "chant/lifecycle:_members/api/_leases/W-020.jsonl")).toContain('"event":"claim"');
|
|
165
|
+
expect((await listWorkLeases({ cwd, memberPrefix: "_members/api/" })).map((l) => l.item)).toEqual(["W-020"]);
|
|
166
|
+
expect(await listWorkLeases({ cwd, memberPrefix: "" })).toEqual([]);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe("a work lease across clones, through a bare remote", () => {
|
|
171
|
+
function setup(): { remote: string; a: string; b: string } {
|
|
172
|
+
const root = tmp("chant-work-lease-remote-");
|
|
173
|
+
const remote = join(root, "remote.git");
|
|
174
|
+
git(root, "init", "-q", "--bare", "-b", "main", remote);
|
|
175
|
+
const a = join(root, "a");
|
|
176
|
+
git(root, "clone", "-q", remote, a);
|
|
177
|
+
configure(a);
|
|
178
|
+
writeFileSync(join(a, "README.md"), "# t\n");
|
|
179
|
+
git(a, "add", "README.md");
|
|
180
|
+
git(a, "commit", "-q", "-m", "init");
|
|
181
|
+
git(a, "push", "-q", "origin", "main");
|
|
182
|
+
const b = join(root, "b");
|
|
183
|
+
git(root, "clone", "-q", remote, b);
|
|
184
|
+
configure(b);
|
|
185
|
+
return { remote, a, b };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
test("A claims; B is refused with A named; A renews and releases on the remote; then B claims and A is refused", async () => {
|
|
189
|
+
const { remote, a, b } = setup();
|
|
190
|
+
const claim = await claimWorkLease("W-001", "worker-a", { cwd: a, ttlMs: 60_000 });
|
|
191
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
192
|
+
expect(git(remote, "rev-parse", workLeaseRef("W-001"))).toBe(await readRefSha(workLeaseRef("W-001"), { cwd: a }));
|
|
193
|
+
|
|
194
|
+
const refused = await claimWorkLease("W-001", "worker-b", { cwd: b });
|
|
195
|
+
expect(refused).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "worker-a", token: claim.lease.token } });
|
|
196
|
+
|
|
197
|
+
// A renew reaches the remote: the push expects the value A last pushed, not "absent".
|
|
198
|
+
const renew = await renewWorkLease("W-001", "worker-a", { cwd: a, ttlMs: 120_000 });
|
|
199
|
+
if (!renew.ok) throw new Error(renew.message);
|
|
200
|
+
expect(git(remote, "rev-parse", workLeaseRef("W-001"))).toBe(await readRefSha(workLeaseRef("W-001"), { cwd: a }));
|
|
201
|
+
const seen = await claimWorkLease("W-001", "worker-b", { cwd: b });
|
|
202
|
+
expect(seen).toMatchObject({ ok: false, heldBy: { expiresAt: renew.lease.expiresAt } });
|
|
203
|
+
// B's read-only listing now shows the remote's lease as last fetched.
|
|
204
|
+
expect((await listWorkLeases({ cwd: b, memberPrefix: "" })).map((l) => l.holder)).toEqual(["worker-a"]);
|
|
205
|
+
|
|
206
|
+
const released = await releaseWorkLease("W-001", "worker-a", { cwd: a, outcome: "done" });
|
|
207
|
+
expect(released.ok).toBe(true);
|
|
208
|
+
expect(() => git(remote, "rev-parse", "--verify", workLeaseRef("W-001"))).toThrow();
|
|
209
|
+
|
|
210
|
+
const taken = await claimWorkLease("W-001", "worker-b", { cwd: b, ttlMs: 60_000 });
|
|
211
|
+
if (!taken.ok) throw new Error(taken.message);
|
|
212
|
+
expect(taken.lease.token).not.toBe(claim.lease.token);
|
|
213
|
+
expect(await claimWorkLease("W-001", "worker-a", { cwd: a })).toMatchObject({ ok: false, reason: "lease-held", heldBy: { holder: "worker-b" } });
|
|
214
|
+
|
|
215
|
+
// Both clones' histories reached the remote's chant/lifecycle.
|
|
216
|
+
expect(claim.history.pushed).toBe(true);
|
|
217
|
+
const lines = git(remote, "show", "chant/lifecycle:_leases/W-001.jsonl").split("\n").map((l) => JSON.parse(l).event);
|
|
218
|
+
expect(lines).toEqual(["claim", "renew", "release", "claim"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("a claim the remote refuses is undone locally: two clones can't both hold one item", async () => {
|
|
222
|
+
const { remote, a, b } = setup();
|
|
223
|
+
// B fetches from an empty remote, so it can't see A's claim, but pushes to the real one.
|
|
224
|
+
const blind = join(tmp("chant-work-lease-blind-"), "blind.git");
|
|
225
|
+
git(a, "init", "-q", "--bare", "-b", "main", blind);
|
|
226
|
+
git(b, "remote", "set-url", "origin", blind);
|
|
227
|
+
git(b, "remote", "set-url", "--push", "origin", remote);
|
|
228
|
+
|
|
229
|
+
const claim = await claimWorkLease("W-005", "worker-a", { cwd: a });
|
|
230
|
+
if (!claim.ok) throw new Error(claim.message);
|
|
231
|
+
const lost = await claimWorkLease("W-005", "worker-b", { cwd: b });
|
|
232
|
+
expect(lost).toMatchObject({ ok: false, reason: "lease-push-rejected" });
|
|
233
|
+
expect(await readRefSha(workLeaseRef("W-005"), { cwd: b })).toBeNull();
|
|
234
|
+
expect(git(remote, "rev-parse", workLeaseRef("W-005"))).toBe(await readRefSha(workLeaseRef("W-005"), { cwd: a }));
|
|
235
|
+
});
|
|
236
|
+
});
|