@intentius/chant 0.46.0 → 0.50.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/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +30 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +9 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- 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 +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +12 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +13 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +20 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +137 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +6 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +41 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +140 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +18 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +49 -2
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +29 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +40 -4
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +13 -1
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.test.ts +41 -0
- package/src/build.ts +39 -6
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +268 -1
- package/src/cli/commands/audit.ts +87 -18
- package/src/cli/commands/build.test.ts +245 -0
- package/src/cli/commands/build.ts +210 -21
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +12 -3
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +96 -15
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +323 -3
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/plugins.ts +4 -2
- package/src/cli/registry.ts +14 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/auto-release.ts +6 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +13 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/composite.ts +31 -2
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +95 -6
- package/src/fold/subset.ts +66 -2
- package/src/index.ts +4 -0
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +151 -5
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +6 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +60 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +35 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +7 -0
- package/src/lint/rules/index.ts +7 -1
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +198 -6
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +40 -3
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +212 -29
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/op.test.ts +25 -2
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/receipt-store.ts +211 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +51 -2
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +30 -0
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { withTestDir } from "@intentius/chant-test-utils";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
acquireLease,
|
|
9
|
+
releaseLease,
|
|
10
|
+
readLease,
|
|
11
|
+
stillHoldsLease,
|
|
12
|
+
currentHolderId,
|
|
13
|
+
leaseRef,
|
|
14
|
+
LEASE_REF_PREFIX,
|
|
15
|
+
} from "./lease";
|
|
16
|
+
import { writeBlob, updateRefCAS, readRefSha, pushRef, StaleLockError, RefCASConflictError } from "./git";
|
|
17
|
+
|
|
18
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
19
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
20
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function initRepo(dir: string): Promise<void> {
|
|
24
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
25
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
26
|
+
git(["config", "user.name", "Test"], dir);
|
|
27
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
28
|
+
git(["add", "README.md"], dir);
|
|
29
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("lifecycle/lease", () => {
|
|
33
|
+
test("leaseRef namespaces under refs/chant/lease/, distinct from the lifecycle branch", () => {
|
|
34
|
+
expect(leaseRef("fountain-converge")).toBe(`${LEASE_REF_PREFIX}fountain-converge`);
|
|
35
|
+
expect(leaseRef("fountain-converge")).not.toMatch(/^refs\/heads\//);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("currentHolderId is stable-shaped (host:pid:random) and unique per call", () => {
|
|
39
|
+
const a = currentHolderId();
|
|
40
|
+
const b = currentHolderId();
|
|
41
|
+
expect(a).toMatch(/^.+:\d+:[0-9a-f]{8}$/);
|
|
42
|
+
expect(a).not.toBe(b);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("readLease on a never-acquired op returns no record", async () => {
|
|
46
|
+
await withTestDir(async (dir) => {
|
|
47
|
+
await initRepo(dir);
|
|
48
|
+
const { sha, record } = await readLease("no-such-op", { cwd: dir });
|
|
49
|
+
expect(sha).toBeNull();
|
|
50
|
+
expect(record).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("acquireLease — single-writer fencing (#1485)", () => {
|
|
55
|
+
test("first acquire succeeds and mints a fresh token", async () => {
|
|
56
|
+
await withTestDir(async (dir) => {
|
|
57
|
+
await initRepo(dir);
|
|
58
|
+
const result = await acquireLease("fountain-converge", "holder-a", { cwd: dir });
|
|
59
|
+
expect(result.acquired).toBe(true);
|
|
60
|
+
expect(result.lease?.holder).toBe("holder-a");
|
|
61
|
+
expect(result.lease?.token).toMatch(/^[0-9a-f-]{36}$/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("a second holder cannot acquire a live, unexpired lease — the CAS loser stops, not queues", async () => {
|
|
66
|
+
await withTestDir(async (dir) => {
|
|
67
|
+
await initRepo(dir);
|
|
68
|
+
const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
69
|
+
expect(first.acquired).toBe(true);
|
|
70
|
+
|
|
71
|
+
const second = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 60_000 });
|
|
72
|
+
expect(second.acquired).toBe(false);
|
|
73
|
+
expect(second.heldBy?.holder).toBe("holder-a");
|
|
74
|
+
expect(second.heldBy?.token).toBe(first.lease?.token);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the same holder renewing keeps its token but pushes out expiresAt", async () => {
|
|
79
|
+
await withTestDir(async (dir) => {
|
|
80
|
+
await initRepo(dir);
|
|
81
|
+
const t0 = new Date("2026-01-01T00:00:00.000Z");
|
|
82
|
+
const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000, now: () => t0 });
|
|
83
|
+
|
|
84
|
+
const t1 = new Date("2026-01-01T00:00:30.000Z");
|
|
85
|
+
const renewed = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000, now: () => t1 });
|
|
86
|
+
|
|
87
|
+
expect(renewed.acquired).toBe(true);
|
|
88
|
+
expect(renewed.lease?.token).toBe(first.lease?.token);
|
|
89
|
+
expect(renewed.lease?.acquiredAt).toBe(first.lease?.acquiredAt);
|
|
90
|
+
expect(new Date(renewed.lease!.expiresAt).getTime()).toBeGreaterThan(new Date(first.lease!.expiresAt).getTime());
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a new holder can acquire once the previous lease has expired, and mints a new token", async () => {
|
|
95
|
+
await withTestDir(async (dir) => {
|
|
96
|
+
await initRepo(dir);
|
|
97
|
+
const t0 = new Date("2026-01-01T00:00:00.000Z");
|
|
98
|
+
const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 1_000, now: () => t0 });
|
|
99
|
+
expect(first.acquired).toBe(true);
|
|
100
|
+
|
|
101
|
+
// holder-a crashes and never renews; holder-b tries after the TTL passes.
|
|
102
|
+
const tExpired = new Date("2026-01-01T00:00:05.000Z");
|
|
103
|
+
const second = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 1_000, now: () => tExpired });
|
|
104
|
+
|
|
105
|
+
expect(second.acquired).toBe(true);
|
|
106
|
+
expect(second.lease?.holder).toBe("holder-b");
|
|
107
|
+
expect(second.lease?.token).not.toBe(first.lease?.token);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── #1959 finding 2 ──────────────────────────────────────────────────
|
|
112
|
+
//
|
|
113
|
+
// Before this fix, `updateRefCAS` turned ANY nonzero git exit —
|
|
114
|
+
// including a stale `.lock` file left by a killed process — into
|
|
115
|
+
// RefCASConflictError, and `acquireLease` folded that straight into
|
|
116
|
+
// "someone else holds it" (`heldBy`). That's wrong: a stale lock isn't
|
|
117
|
+
// contention, it's wreckage from the exact crash this feature must
|
|
118
|
+
// recover from, and TTL expiry never fixes it (the ref update itself
|
|
119
|
+
// can't land while the lock file sits there) — so a project would get
|
|
120
|
+
// permanently, silently stuck acquiring that op's lease.
|
|
121
|
+
test("a stale .lock file surfaces as StaleLockError, NOT as \"lease held by someone else\" (#1959 finding 2)", async () => {
|
|
122
|
+
await withTestDir(async (dir) => {
|
|
123
|
+
await initRepo(dir);
|
|
124
|
+
// Acquire once for real, so the ref exists.
|
|
125
|
+
const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
126
|
+
expect(first.acquired).toBe(true);
|
|
127
|
+
|
|
128
|
+
// Simulate holder-a's own process getting killed mid-renewal: git's
|
|
129
|
+
// lockfile-then-rename never completed, so the `.lock` file it
|
|
130
|
+
// created is still there.
|
|
131
|
+
mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
|
|
132
|
+
writeFileSync(join(dir, ".git", "refs", "chant", "lease", "fountain-converge.lock"), "");
|
|
133
|
+
|
|
134
|
+
// The SAME holder trying to renew must actually attempt the write
|
|
135
|
+
// (it owns the lease, so acquireLease doesn't short-circuit before
|
|
136
|
+
// touching git) — and hit the lock file. It must not read this as
|
|
137
|
+
// "held by someone else": it must throw a diagnosable
|
|
138
|
+
// StaleLockError, distinct from the ordinary
|
|
139
|
+
// `{ acquired: false, heldBy }` contention outcome.
|
|
140
|
+
const err = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 }).catch((e) => e);
|
|
141
|
+
expect(err).toBeInstanceOf(StaleLockError);
|
|
142
|
+
expect(err).not.toBeInstanceOf(RefCASConflictError);
|
|
143
|
+
expect((err as StaleLockError).message).toContain("fountain-converge.lock");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a stale .lock file also blocks a FRESH holder once the previous lease has expired — surfaced as StaleLockError, not misread as expiry succeeding or as contention (#1959 finding 2)", async () => {
|
|
148
|
+
await withTestDir(async (dir) => {
|
|
149
|
+
await initRepo(dir);
|
|
150
|
+
const t0 = new Date("2026-01-01T00:00:00.000Z");
|
|
151
|
+
const first = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 1_000, now: () => t0 });
|
|
152
|
+
expect(first.acquired).toBe(true);
|
|
153
|
+
|
|
154
|
+
mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
|
|
155
|
+
writeFileSync(join(dir, ".git", "refs", "chant", "lease", "fountain-converge.lock"), "");
|
|
156
|
+
|
|
157
|
+
// holder-a's lease has since expired — a normal crash-recovery
|
|
158
|
+
// acquire should be able to proceed to a CAS write here (nothing is
|
|
159
|
+
// "held" any more), but the same leftover lock file still blocks
|
|
160
|
+
// the write itself.
|
|
161
|
+
const tExpired = new Date("2026-01-01T00:00:05.000Z");
|
|
162
|
+
const err = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 1_000, now: () => tExpired }).catch(
|
|
163
|
+
(e) => e,
|
|
164
|
+
);
|
|
165
|
+
expect(err).toBeInstanceOf(StaleLockError);
|
|
166
|
+
expect(err).not.toBeInstanceOf(RefCASConflictError);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("stillHoldsLease — the fencing check a tick uses before trusting its own work", () => {
|
|
172
|
+
test("true while the same holder+token is still live", async () => {
|
|
173
|
+
await withTestDir(async (dir) => {
|
|
174
|
+
await initRepo(dir);
|
|
175
|
+
const acquired = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
176
|
+
expect(await stillHoldsLease("fountain-converge", "holder-a", acquired.lease!.token, { cwd: dir })).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("false once another holder has taken over (stale token refused)", async () => {
|
|
181
|
+
await withTestDir(async (dir) => {
|
|
182
|
+
await initRepo(dir);
|
|
183
|
+
const t0 = new Date("2026-01-01T00:00:00.000Z");
|
|
184
|
+
const a = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 1_000, now: () => t0 });
|
|
185
|
+
|
|
186
|
+
const tExpired = new Date("2026-01-01T00:00:05.000Z");
|
|
187
|
+
await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 1_000, now: () => tExpired });
|
|
188
|
+
|
|
189
|
+
// holder-a's tick, still carrying its now-stale token, checks in after
|
|
190
|
+
// holder-b has already reclaimed the lease.
|
|
191
|
+
expect(await stillHoldsLease("fountain-converge", "holder-a", a.lease!.token, { cwd: dir })).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("false for an op with no lease at all", async () => {
|
|
196
|
+
await withTestDir(async (dir) => {
|
|
197
|
+
await initRepo(dir);
|
|
198
|
+
expect(await stillHoldsLease("no-such-op", "holder-a", "any-token", { cwd: dir })).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("releaseLease", () => {
|
|
204
|
+
test("releases when holder+token match, and the lease becomes acquirable again immediately", async () => {
|
|
205
|
+
await withTestDir(async (dir) => {
|
|
206
|
+
await initRepo(dir);
|
|
207
|
+
const acquired = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
208
|
+
expect(await releaseLease("fountain-converge", "holder-a", acquired.lease!.token, { cwd: dir })).toBe(true);
|
|
209
|
+
|
|
210
|
+
const { record } = await readLease("fountain-converge", { cwd: dir });
|
|
211
|
+
expect(record).toBeUndefined();
|
|
212
|
+
|
|
213
|
+
const second = await acquireLease("fountain-converge", "holder-b", { cwd: dir, ttlMs: 60_000 });
|
|
214
|
+
expect(second.acquired).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("refuses to release a lease held by someone else, or with a stale token — never drops another holder's lease", async () => {
|
|
219
|
+
await withTestDir(async (dir) => {
|
|
220
|
+
await initRepo(dir);
|
|
221
|
+
const acquired = await acquireLease("fountain-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
222
|
+
|
|
223
|
+
expect(await releaseLease("fountain-converge", "holder-b", acquired.lease!.token, { cwd: dir })).toBe(false);
|
|
224
|
+
expect(await releaseLease("fountain-converge", "holder-a", "wrong-token", { cwd: dir })).toBe(false);
|
|
225
|
+
|
|
226
|
+
const { record } = await readLease("fountain-converge", { cwd: dir });
|
|
227
|
+
expect(record?.holder).toBe("holder-a");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("releasing a non-existent lease is a harmless false, not a throw", async () => {
|
|
232
|
+
await withTestDir(async (dir) => {
|
|
233
|
+
await initRepo(dir);
|
|
234
|
+
expect(await releaseLease("no-such-op", "holder-a", "t", { cwd: dir })).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── Cross-machine contention (#1485 acceptance criterion: "two operators, one remote") ──
|
|
240
|
+
|
|
241
|
+
describe("two operators, one remote — the lease arbitrates across clones, not just within one", () => {
|
|
242
|
+
async function setupClonePair(): Promise<{ clonePath: string; remotePath: string; cleanup: () => Promise<void> }> {
|
|
243
|
+
const remotePath = join(tmpdir(), `chant-lease-remote-${Date.now()}-${Math.random()}`);
|
|
244
|
+
const clonePath = join(tmpdir(), `chant-lease-clone-${Date.now()}-${Math.random()}`);
|
|
245
|
+
const { mkdir, rm } = await import("node:fs/promises");
|
|
246
|
+
await mkdir(remotePath, { recursive: true });
|
|
247
|
+
git(["init", "-q", "--bare", "-b", "main"], remotePath);
|
|
248
|
+
git(["clone", "-q", remotePath, clonePath], tmpdir());
|
|
249
|
+
git(["config", "user.email", "test@chant.dev"], clonePath);
|
|
250
|
+
git(["config", "user.name", "Test"], clonePath);
|
|
251
|
+
writeFileSync(join(clonePath, "README.md"), "fixture\n");
|
|
252
|
+
git(["add", "README.md"], clonePath);
|
|
253
|
+
git(["commit", "-q", "-m", "init"], clonePath);
|
|
254
|
+
git(["push", "-q", "origin", "main"], clonePath);
|
|
255
|
+
return {
|
|
256
|
+
clonePath,
|
|
257
|
+
remotePath,
|
|
258
|
+
cleanup: async () => {
|
|
259
|
+
await rm(remotePath, { recursive: true, force: true });
|
|
260
|
+
await rm(clonePath, { recursive: true, force: true });
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
test("operator A acquires and pushes; operator B (a second clone) sees it as held and loses the race", async () => {
|
|
266
|
+
const { clonePath: cloneA, remotePath, cleanup } = await setupClonePair();
|
|
267
|
+
const cloneB = join(tmpdir(), `chant-lease-clone-b-${Date.now()}-${Math.random()}`);
|
|
268
|
+
try {
|
|
269
|
+
git(["clone", "-q", remotePath, cloneB], tmpdir());
|
|
270
|
+
git(["config", "user.email", "test@chant.dev"], cloneB);
|
|
271
|
+
git(["config", "user.name", "Test"], cloneB);
|
|
272
|
+
|
|
273
|
+
const a = await acquireLease("fountain-converge", "operator-a", { cwd: cloneA, ttlMs: 60_000 });
|
|
274
|
+
expect(a.acquired).toBe(true);
|
|
275
|
+
|
|
276
|
+
// Operator B, on a different clone of the same remote, tries next —
|
|
277
|
+
// acquireLease fetches the ref first, so A's push is visible.
|
|
278
|
+
const b = await acquireLease("fountain-converge", "operator-b", { cwd: cloneB, ttlMs: 60_000 });
|
|
279
|
+
expect(b.acquired).toBe(false);
|
|
280
|
+
expect(b.heldBy?.holder).toBe("operator-a");
|
|
281
|
+
} finally {
|
|
282
|
+
await cleanup();
|
|
283
|
+
const { rm } = await import("node:fs/promises");
|
|
284
|
+
await rm(cloneB, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── #1959 finding 3 ──────────────────────────────────────────────────
|
|
289
|
+
//
|
|
290
|
+
// `readLease` used to fetch the remote lease ref directly into the same
|
|
291
|
+
// local ref name (`+ref:ref`) before every read — including the read
|
|
292
|
+
// `chant operator status` does. That force-overwrites the local ref, so
|
|
293
|
+
// a concurrent `status` call landing in the exact acquire→push window
|
|
294
|
+
// (the local CAS write has landed, but the push to remote hasn't yet)
|
|
295
|
+
// would revert the just-acquired local lease back to whatever the
|
|
296
|
+
// remote still had — silently, in the SAME clone, with no other process
|
|
297
|
+
// racing at all. `readLease` now fetches into a side tracking ref
|
|
298
|
+
// instead, so the canonical local ref is never touched by a read.
|
|
299
|
+
test("a concurrent read in the SAME clone, during the acquire→push window, must not clobber the just-acquired local lease (#1959 finding 3)", async () => {
|
|
300
|
+
const { clonePath, remotePath, cleanup } = await setupClonePair();
|
|
301
|
+
try {
|
|
302
|
+
// An older, now-stale lease already sits on the remote (some earlier holder, long expired).
|
|
303
|
+
const stale = {
|
|
304
|
+
op: "fountain-converge",
|
|
305
|
+
holder: "old-holder",
|
|
306
|
+
token: "old-token",
|
|
307
|
+
acquiredAt: "2020-01-01T00:00:00.000Z",
|
|
308
|
+
expiresAt: "2020-01-01T00:05:00.000Z",
|
|
309
|
+
};
|
|
310
|
+
const staleBlobSha = await writeBlob(JSON.stringify(stale), { cwd: clonePath });
|
|
311
|
+
await updateRefCAS(leaseRef("fountain-converge"), staleBlobSha, null, { cwd: clonePath });
|
|
312
|
+
expect(await pushRef(leaseRef("fountain-converge"), { cwd: clonePath })).toBe(true);
|
|
313
|
+
|
|
314
|
+
// Simulate the local half of a fresh acquire that has landed the
|
|
315
|
+
// local CAS write but has NOT pushed yet — the exact acquire→push
|
|
316
|
+
// window this finding is about (acquireLease itself always pushes
|
|
317
|
+
// right after its own CAS write; this reproduces the in-between
|
|
318
|
+
// moment deterministically rather than racing real concurrency).
|
|
319
|
+
const fresh = {
|
|
320
|
+
op: "fountain-converge",
|
|
321
|
+
holder: "new-holder",
|
|
322
|
+
token: "new-token",
|
|
323
|
+
acquiredAt: new Date().toISOString(),
|
|
324
|
+
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
325
|
+
};
|
|
326
|
+
const freshBlobSha = await writeBlob(JSON.stringify(fresh), { cwd: clonePath });
|
|
327
|
+
await updateRefCAS(leaseRef("fountain-converge"), freshBlobSha, staleBlobSha, { cwd: clonePath });
|
|
328
|
+
|
|
329
|
+
// A concurrent `chant operator status` in the SAME clone reads the lease now.
|
|
330
|
+
const { record } = await readLease("fountain-converge", { cwd: clonePath });
|
|
331
|
+
expect(record?.holder).toBe("new-holder");
|
|
332
|
+
expect(record?.token).toBe("new-token");
|
|
333
|
+
|
|
334
|
+
// And the canonical local ref itself must still hold the
|
|
335
|
+
// freshly-acquired value — not force-reverted to the stale remote
|
|
336
|
+
// one readLease's own fetch pulled down.
|
|
337
|
+
expect(await readRefSha(leaseRef("fountain-converge"), { cwd: clonePath })).toBe(freshBlobSha);
|
|
338
|
+
} finally {
|
|
339
|
+
await cleanup();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator lease (#1485, epic #1487) — single-writer coordination for a
|
|
3
|
+
* `ConvergeOp`'s tick loop, entirely over git. No new state store: the lease
|
|
4
|
+
* lives on a dedicated ref namespace (`refs/chant/lease/<op>`), separate
|
|
5
|
+
* from the `chant/lifecycle` orphan branch's own commit history, whose
|
|
6
|
+
* value IS a lease-record blob — no tree, no commit (./git.ts's
|
|
7
|
+
* `writeBlob`/`readBlobBySha`). Acquiring or renewing a lease is one
|
|
8
|
+
* `updateRefCAS` call (./git.ts): the caller reads the ref's current SHA,
|
|
9
|
+
* and the write only lands if the ref still points there. No `flock` file
|
|
10
|
+
* either — `git update-ref` is already an atomic local mutex (its own
|
|
11
|
+
* lockfile-then-rename), so a second local process racing the same acquire
|
|
12
|
+
* loses outright without any second locking mechanism to reason about.
|
|
13
|
+
*
|
|
14
|
+
* Cross-machine contention is settled by pushing/fetching this one ref
|
|
15
|
+
* through the project's remote (best-effort — see `acquireLease`). A
|
|
16
|
+
* project with no remote is single-machine by construction: the local CAS
|
|
17
|
+
* above is then the *entire* coordination story, which is exactly what
|
|
18
|
+
* issue #1485's open question 5 asks be stated loudly — `chant operator`'s
|
|
19
|
+
* own docs page says so explicitly; nothing here silently upgrades a
|
|
20
|
+
* remote-less lease to team-visible durability.
|
|
21
|
+
*
|
|
22
|
+
* The record's `token` is the fencing token the issue asks for: a fresh
|
|
23
|
+
* value is minted only when the lease actually changes hands (first
|
|
24
|
+
* acquire, or a re-acquire after the previous holder's lease expired or was
|
|
25
|
+
* released) — never on a same-holder renewal, so an in-flight tick's token
|
|
26
|
+
* stays valid across the operator loop's own heartbeats. `stillHoldsLease`
|
|
27
|
+
* is what a caller uses, right before trusting a finished tick's own work,
|
|
28
|
+
* to notice its token has since changed (the lease was stolen mid-tick,
|
|
29
|
+
* e.g. the process stalled past its TTL) — see `../op/operator.ts`'s tick
|
|
30
|
+
* loop for how that's handled: never a hard failure, since a converge tick
|
|
31
|
+
* is idempotent by design (it re-observes and re-derives everything), so a
|
|
32
|
+
* late, fenced-out write is redundant, not corrupting.
|
|
33
|
+
*/
|
|
34
|
+
import { hostname } from "node:os";
|
|
35
|
+
import { randomUUID } from "node:crypto";
|
|
36
|
+
import { readRefSha, updateRefCAS, deleteRefCAS, writeBlob, readBlobBySha, pushRef, fetchRefInto, RefCASConflictError } from "./git";
|
|
37
|
+
|
|
38
|
+
export const LEASE_REF_PREFIX = "refs/chant/lease/";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Side namespace `readLease` fetches remote lease state into (#1959 finding
|
|
42
|
+
* 3), rather than into `refs/chant/lease/<op>` itself. That ref is the CAS
|
|
43
|
+
* write path's alone (`acquireLease`/`releaseLease`, both via
|
|
44
|
+
* `updateRefCAS`/`deleteRefCAS`); a read path force-fetching directly into
|
|
45
|
+
* it would risk clobbering a just-acquired, not-yet-pushed local lease with
|
|
46
|
+
* the still-stale remote value — the exact race a concurrent `chant operator
|
|
47
|
+
* status` in the same clone could hit during the acquire→push window. See
|
|
48
|
+
* `readLease`'s doc for how the two are reconciled without that risk.
|
|
49
|
+
*/
|
|
50
|
+
export const LEASE_REMOTE_TRACKING_PREFIX = "refs/chant/lease-remote/";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Default lease TTL — long enough that a normal tick (observe, classify,
|
|
54
|
+
* a budget-bounded number of dispatches) finishes well inside it; short
|
|
55
|
+
* enough that a crashed operator's environment resumes converging soon
|
|
56
|
+
* after, without a human intervening. The operator loop renews well before
|
|
57
|
+
* this elapses (every round it still owns the lease for), so under normal
|
|
58
|
+
* operation the TTL is never actually reached.
|
|
59
|
+
*/
|
|
60
|
+
export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
|
|
61
|
+
|
|
62
|
+
export function leaseRef(opName: string): string {
|
|
63
|
+
return `${LEASE_REF_PREFIX}${opName}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function leaseRemoteTrackingRef(opName: string): string {
|
|
67
|
+
return `${LEASE_REMOTE_TRACKING_PREFIX}${opName}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Sort key for "which of two lease records is more current" — a plain
|
|
72
|
+
* string comparison works because `acquiredAt`/`expiresAt` are always
|
|
73
|
+
* `Date.prototype.toISOString()` output (fixed-width, UTC), which sorts
|
|
74
|
+
* lexicographically in time order. Compares `acquiredAt` first (a genuine
|
|
75
|
+
* handoff to a new holder always mints a strictly later one; see
|
|
76
|
+
* `acquireLease`), falling back to `expiresAt` to break a tie between two
|
|
77
|
+
* renewals of the *same* holder/token, which share `acquiredAt` by design.
|
|
78
|
+
* `undefined` sorts before every real record.
|
|
79
|
+
*/
|
|
80
|
+
function leaseFreshnessKey(record?: LeaseRecord): string {
|
|
81
|
+
return record ? `${record.acquiredAt} ${record.expiresAt}` : "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One lease's live state — the entire durable record; there is no history, only the current holder (see this module's doc on why no separate ledger). */
|
|
85
|
+
export interface LeaseRecord {
|
|
86
|
+
op: string;
|
|
87
|
+
/** `<hostname>:<pid>:<random>` — a diagnostic identity, not itself the fencing mechanism (`token` is). */
|
|
88
|
+
holder: string;
|
|
89
|
+
/** Fencing token — new only when the lease actually changes hands; see module doc. */
|
|
90
|
+
token: string;
|
|
91
|
+
acquiredAt: string;
|
|
92
|
+
expiresAt: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A stable-enough identity for "who holds this lease", for logs and `chant operator status` — hostname:pid, plus a short random suffix so two processes started in the same pid-reuse window never read as the same holder. */
|
|
96
|
+
export function currentHolderId(): string {
|
|
97
|
+
return `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function isExpired(record: LeaseRecord, now: Date): boolean {
|
|
101
|
+
return new Date(record.expiresAt).getTime() <= now.getTime();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseLease(raw: string): LeaseRecord | undefined {
|
|
105
|
+
try {
|
|
106
|
+
const v = JSON.parse(raw) as Partial<LeaseRecord>;
|
|
107
|
+
if (
|
|
108
|
+
typeof v.op === "string" &&
|
|
109
|
+
typeof v.holder === "string" &&
|
|
110
|
+
typeof v.token === "string" &&
|
|
111
|
+
typeof v.acquiredAt === "string" &&
|
|
112
|
+
typeof v.expiresAt === "string"
|
|
113
|
+
) {
|
|
114
|
+
return v as LeaseRecord;
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
} catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ReadLeaseResult {
|
|
123
|
+
/** The ref's current SHA (the CAS anchor for the next write), or `null` if no lease has ever been written. */
|
|
124
|
+
sha: string | null;
|
|
125
|
+
record?: LeaseRecord;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Read the live lease for `opName`, fetching the remote ref first (best-
|
|
130
|
+
* effort) so a lease held by another machine is visible before deciding
|
|
131
|
+
* whether to acquire.
|
|
132
|
+
*
|
|
133
|
+
* The fetch lands in a side tracking ref (`refs/chant/lease-remote/<op>`),
|
|
134
|
+
* never directly into `refs/chant/lease/<op>` itself (#1959 finding 3) — the
|
|
135
|
+
* canonical local ref is written *only* by the CAS path
|
|
136
|
+
* (`acquireLease`/`releaseLease`), so a read (this function is called
|
|
137
|
+
* before every `acquireLease`, and directly by `chant operator status`) can
|
|
138
|
+
* never force it back to a stale remote value out from under a concurrent
|
|
139
|
+
* local acquirer. The returned `record` is whichever of the local/remote
|
|
140
|
+
* views is more current by `leaseFreshnessKey` (ties keep local): this
|
|
141
|
+
* still gives full cross-machine visibility — a genuinely newer remote
|
|
142
|
+
* holder wins — while a just-acquired, not-yet-pushed local lease (freshest
|
|
143
|
+
* by construction) always survives a same-clone concurrent read. `sha`
|
|
144
|
+
* — the CAS anchor a subsequent `acquireLease`/`releaseLease` writes
|
|
145
|
+
* against — is always the local ref's own actual value; only the local
|
|
146
|
+
* canonical ref is ever a valid basis for a `updateRefCAS`/`deleteRefCAS`
|
|
147
|
+
* call against it, regardless of what the comparison decided about `record`.
|
|
148
|
+
*/
|
|
149
|
+
export async function readLease(opName: string, opts?: { cwd?: string }): Promise<ReadLeaseResult> {
|
|
150
|
+
const ref = leaseRef(opName);
|
|
151
|
+
const trackingRef = leaseRemoteTrackingRef(opName);
|
|
152
|
+
await fetchRefInto(ref, trackingRef, opts).catch(() => undefined);
|
|
153
|
+
|
|
154
|
+
const sha = await readRefSha(ref, opts);
|
|
155
|
+
const localRecord = sha ? parseLease((await readBlobBySha(sha, opts)) ?? "") : undefined;
|
|
156
|
+
|
|
157
|
+
const remoteSha = await readRefSha(trackingRef, opts);
|
|
158
|
+
const remoteRecord = remoteSha ? parseLease((await readBlobBySha(remoteSha, opts)) ?? "") : undefined;
|
|
159
|
+
|
|
160
|
+
const record = leaseFreshnessKey(remoteRecord) > leaseFreshnessKey(localRecord) ? remoteRecord : localRecord;
|
|
161
|
+
return { sha, record };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface AcquireLeaseResult {
|
|
165
|
+
acquired: boolean;
|
|
166
|
+
lease?: LeaseRecord;
|
|
167
|
+
/** Present when not acquired: the lease record currently held by someone else. */
|
|
168
|
+
heldBy?: LeaseRecord;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Acquire or renew the lease for `opName` as `holder`. Succeeds when the ref
|
|
173
|
+
* doesn't exist yet, is expired, or is already held by `holder` (a renewal:
|
|
174
|
+
* same token, pushed-out expiry). Fails — returns `acquired: false`, without
|
|
175
|
+
* throwing — when it's live-held by someone else, or when a concurrent CAS
|
|
176
|
+
* write is lost to a race that happened between this call's read and its
|
|
177
|
+
* write ({@link RefCASConflictError}); both read identically to a caller
|
|
178
|
+
* deciding whether to tick this round ("someone else has it right now,
|
|
179
|
+
* skip").
|
|
180
|
+
*
|
|
181
|
+
* Deliberately does NOT swallow a `StaleLockError` (./git.ts) into that same
|
|
182
|
+
* "someone else has it" outcome (#1959 finding 2): a leftover `.lock` file
|
|
183
|
+
* from a killed process is not contention, it's wreckage, and treating it as
|
|
184
|
+
* "held by someone else" would make `chant operator` back off forever
|
|
185
|
+
* against a lease nobody can ever actually acquire again without manual
|
|
186
|
+
* intervention. It propagates instead, so the caller (`../op/operator.ts`'s
|
|
187
|
+
* `runOperatorRound`) can surface it as its own distinct, diagnosable event
|
|
188
|
+
* rather than a silent, permanent skip.
|
|
189
|
+
*/
|
|
190
|
+
export async function acquireLease(
|
|
191
|
+
opName: string,
|
|
192
|
+
holder: string,
|
|
193
|
+
opts?: { cwd?: string; ttlMs?: number; now?: () => Date },
|
|
194
|
+
): Promise<AcquireLeaseResult> {
|
|
195
|
+
const ttlMs = opts?.ttlMs ?? DEFAULT_LEASE_TTL_MS;
|
|
196
|
+
const now = opts?.now?.() ?? new Date();
|
|
197
|
+
|
|
198
|
+
const { sha, record: current } = await readLease(opName, opts);
|
|
199
|
+
const expired = !current || isExpired(current, now);
|
|
200
|
+
const ownedByUs = current?.holder === holder;
|
|
201
|
+
|
|
202
|
+
if (current && !expired && !ownedByUs) {
|
|
203
|
+
return { acquired: false, heldBy: current };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const renewing = !!current && ownedByUs && !expired;
|
|
207
|
+
const record: LeaseRecord = {
|
|
208
|
+
op: opName,
|
|
209
|
+
holder,
|
|
210
|
+
token: renewing ? current.token : randomUUID(),
|
|
211
|
+
acquiredAt: renewing ? current.acquiredAt : now.toISOString(),
|
|
212
|
+
expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const blobSha = await writeBlob(JSON.stringify(record), opts);
|
|
216
|
+
try {
|
|
217
|
+
await updateRefCAS(leaseRef(opName), blobSha, sha, opts);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof RefCASConflictError) {
|
|
220
|
+
const retry = await readLease(opName, opts);
|
|
221
|
+
return { acquired: false, heldBy: retry.record };
|
|
222
|
+
}
|
|
223
|
+
// A StaleLockError (or any other non-CAS failure) is NOT "someone else
|
|
224
|
+
// has it" — propagate it as its own distinct error rather than folding
|
|
225
|
+
// it into `heldBy`, per this function's doc.
|
|
226
|
+
throw err;
|
|
227
|
+
}
|
|
228
|
+
await pushRef(leaseRef(opName), opts).catch(() => undefined);
|
|
229
|
+
return { acquired: true, lease: record };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Release the lease, but only when `holder`/`token` still match the live
|
|
234
|
+
* value — releasing a lease this caller no longer actually holds would
|
|
235
|
+
* silently drop someone else's. Best-effort courtesy: a lease nobody
|
|
236
|
+
* releases is reclaimed anyway once its TTL passes, so a failed release
|
|
237
|
+
* (returns `false`, never throws) is not itself a correctness problem.
|
|
238
|
+
*/
|
|
239
|
+
export async function releaseLease(
|
|
240
|
+
opName: string,
|
|
241
|
+
holder: string,
|
|
242
|
+
token: string,
|
|
243
|
+
opts?: { cwd?: string },
|
|
244
|
+
): Promise<boolean> {
|
|
245
|
+
const { sha, record } = await readLease(opName, opts);
|
|
246
|
+
if (!sha || !record || record.holder !== holder || record.token !== token) return false;
|
|
247
|
+
try {
|
|
248
|
+
await deleteRefCAS(leaseRef(opName), sha, opts);
|
|
249
|
+
} catch {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
await pushRef(leaseRef(opName), opts).catch(() => undefined);
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Does `holder`/`token` still match the live lease? The fencing check a
|
|
258
|
+
* tick uses right before trusting its own work as authoritative (see
|
|
259
|
+
* `../op/operator.ts`). Fetches first, so a lease stolen by another machine
|
|
260
|
+
* is detected, not just a stale local read.
|
|
261
|
+
*/
|
|
262
|
+
export async function stillHoldsLease(
|
|
263
|
+
opName: string,
|
|
264
|
+
holder: string,
|
|
265
|
+
token: string,
|
|
266
|
+
opts?: { cwd?: string },
|
|
267
|
+
): Promise<boolean> {
|
|
268
|
+
const { record } = await readLease(opName, opts);
|
|
269
|
+
return record?.holder === holder && record?.token === token;
|
|
270
|
+
}
|
|
@@ -97,3 +97,49 @@ describe("acceptDeviations", () => {
|
|
|
97
97
|
expect(countAccepted(null)).toBe(0);
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
describe("acceptDeviations refuses effect receipts (#1833)", () => {
|
|
102
|
+
const now = "2026-08-24T00:00:00.000Z";
|
|
103
|
+
|
|
104
|
+
test("refuses a deviation whose type is the core receipt entityType", () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
acceptDeviations(emptyBaseline("prod"), "chant", [
|
|
107
|
+
{ entity: "seededReceipt", type: "Chant::EffectReceipt", path: "value", value: "gone" },
|
|
108
|
+
], { now }),
|
|
109
|
+
).toThrow(/effect receipt "seededReceipt"/);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("refuses a deviation on an entity the caller recognized as a receipt (materialized row)", () => {
|
|
113
|
+
expect(() =>
|
|
114
|
+
acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
115
|
+
{ entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
|
|
116
|
+
], { now, receipts: new Set(["migratedReceipt"]) }),
|
|
117
|
+
).toThrow(/effect receipt "migratedReceipt"/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("the refusal names the receipt and the effect step as sole writer, and records nothing", () => {
|
|
121
|
+
const before = emptyBaseline("prod");
|
|
122
|
+
let error: Error | undefined;
|
|
123
|
+
try {
|
|
124
|
+
acceptDeviations(before, "aws", [
|
|
125
|
+
{ entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
|
|
126
|
+
{ entity: "migratedReceipt", type: "AWS::SSM::Parameter", path: "Value", value: "stale" },
|
|
127
|
+
], { now, receipts: new Set(["migratedReceipt"]) });
|
|
128
|
+
} catch (e) {
|
|
129
|
+
error = e as Error;
|
|
130
|
+
}
|
|
131
|
+
expect(error).toBeDefined();
|
|
132
|
+
expect(error!.message).toContain('effect receipt "migratedReceipt"');
|
|
133
|
+
expect(error!.message).toContain("only writer");
|
|
134
|
+
expect(error!.message).toContain("defuse the effect");
|
|
135
|
+
// The whole acceptance aborts — the non-receipt row was not recorded either.
|
|
136
|
+
expect(before.lexicons).toEqual({});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("passes: non-receipt deviations accept as before, receipts set present", () => {
|
|
140
|
+
const b = acceptDeviations(emptyBaseline("prod"), "aws", [
|
|
141
|
+
{ entity: "Role", type: "AWS::IAM::Role", path: "MaxSessionDuration", value: 7200 },
|
|
142
|
+
], { now, receipts: new Set(["migratedReceipt"]) });
|
|
143
|
+
expect(baselineForLexicon(b, "aws").Role.accepted).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
});
|