@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,346 @@
|
|
|
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 { join } from "node:path";
|
|
6
|
+
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
7
|
+
import {
|
|
8
|
+
discoverConvergeOps,
|
|
9
|
+
runOperatorRound,
|
|
10
|
+
runOperatorForever,
|
|
11
|
+
formatRoundLine,
|
|
12
|
+
DEFAULT_OPERATOR_INTERVAL_MS,
|
|
13
|
+
} from "./operator";
|
|
14
|
+
import { readLease } from "../lifecycle/lease";
|
|
15
|
+
import { appendConvergeRecord, readConvergeLedger } from "../lifecycle/converge-ledger";
|
|
16
|
+
|
|
17
|
+
const PROFILES: Record<string, ActivityProfile> = {};
|
|
18
|
+
|
|
19
|
+
function git(args: string[], cwd: string): { stdout: string; exitCode: number } {
|
|
20
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
21
|
+
return { stdout: r.stdout ?? "", exitCode: r.status ?? -1 };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function initRepo(dir: string): Promise<void> {
|
|
25
|
+
git(["init", "-q", "-b", "main"], dir);
|
|
26
|
+
git(["config", "user.email", "test@chant.dev"], dir);
|
|
27
|
+
git(["config", "user.name", "Test"], dir);
|
|
28
|
+
writeFileSync(join(dir, "README.md"), "fixture\n");
|
|
29
|
+
git(["add", "README.md"], dir);
|
|
30
|
+
git(["commit", "-q", "-m", "init"], dir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Write a minimal fixture ConvergeOp — the same `{ props: OpConfig }` shape
|
|
35
|
+
* `discoverOps` reads from a real `*.op.ts` file's default export
|
|
36
|
+
* (`../op/discover.ts`), hand-built here rather than going through the real
|
|
37
|
+
* `ConvergeOp()` composite (lexicons/temporal) so this suite tests the
|
|
38
|
+
* operator's own discovery/lease/tick-loop logic without depending on that
|
|
39
|
+
* lexicon at all. One "Converge" phase calling a single fake activity —
|
|
40
|
+
* `runOpLocally` doesn't care what the phases are named or how many there
|
|
41
|
+
* are, only that they run through the local executor.
|
|
42
|
+
*/
|
|
43
|
+
function writeFixtureConvergeOp(dir: string, opName: string, env: string): void {
|
|
44
|
+
const opsDir = join(dir, "ops");
|
|
45
|
+
mkdirSync(opsDir, { recursive: true });
|
|
46
|
+
writeFileSync(
|
|
47
|
+
join(opsDir, `${opName}.op.ts`),
|
|
48
|
+
`export default {
|
|
49
|
+
props: {
|
|
50
|
+
name: ${JSON.stringify(opName)},
|
|
51
|
+
overview: "fixture converge op",
|
|
52
|
+
phases: [{ name: "Converge", steps: [{ kind: "activity", fn: "fakeTick", args: {} }] }],
|
|
53
|
+
searchAttributes: { Converge: "true", Env: ${JSON.stringify(env)}, Dial: "observe" },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function writeFixtureNonConvergeOp(dir: string, opName: string): void {
|
|
61
|
+
const opsDir = join(dir, "ops");
|
|
62
|
+
mkdirSync(opsDir, { recursive: true });
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(opsDir, `${opName}.op.ts`),
|
|
65
|
+
`export default {
|
|
66
|
+
props: {
|
|
67
|
+
name: ${JSON.stringify(opName)},
|
|
68
|
+
overview: "fixture ordinary op",
|
|
69
|
+
phases: [{ name: "Deploy", steps: [{ kind: "activity", fn: "fakeTick", args: {} }] }],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A fake `convergeTick`-shaped activity that appends a real converge-ledger record — so a round's ledger effect is observable through the public reader, the same as the real activity's I/O shape. */
|
|
77
|
+
function fakeTickActivities(dir: string, env: string, opName: string, opts?: { throws?: boolean }): Map<string, ActivityFn> {
|
|
78
|
+
return new Map<string, ActivityFn>([
|
|
79
|
+
[
|
|
80
|
+
"fakeTick",
|
|
81
|
+
async () => {
|
|
82
|
+
if (opts?.throws) throw new Error("boom: simulated tick failure");
|
|
83
|
+
await appendConvergeRecord(
|
|
84
|
+
{
|
|
85
|
+
op: opName,
|
|
86
|
+
env,
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
firedRuleIds: [],
|
|
89
|
+
outcomes: [],
|
|
90
|
+
summary: { drifted: 0, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0 },
|
|
91
|
+
log: `converge(${env}): drifted=0 remediated=0 reported=0 skipped-budget=0 skipped-flap=0 unobserved=0 adopted=0`,
|
|
92
|
+
},
|
|
93
|
+
{ cwd: dir },
|
|
94
|
+
);
|
|
95
|
+
return { ok: true };
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe("discoverConvergeOps", () => {
|
|
102
|
+
test("finds only ops with searchAttributes.Converge === 'true'", async () => {
|
|
103
|
+
await withTestDir(async (dir) => {
|
|
104
|
+
await initRepo(dir);
|
|
105
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
106
|
+
writeFixtureNonConvergeOp(dir, "fountain-apply");
|
|
107
|
+
|
|
108
|
+
const { ops, errors } = await discoverConvergeOps({ cwd: dir });
|
|
109
|
+
expect(errors).toEqual([]);
|
|
110
|
+
expect(ops.map((d) => d.config.name)).toEqual(["staging-converge"]);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("--env filters to ConvergeOps declaring that env", async () => {
|
|
115
|
+
await withTestDir(async (dir) => {
|
|
116
|
+
await initRepo(dir);
|
|
117
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
118
|
+
writeFixtureConvergeOp(dir, "prod-converge", "prod");
|
|
119
|
+
|
|
120
|
+
const { ops } = await discoverConvergeOps({ cwd: dir, env: "prod" });
|
|
121
|
+
expect(ops.map((d) => d.config.name)).toEqual(["prod-converge"]);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("no env filter discovers every ConvergeOp, sorted by name", async () => {
|
|
126
|
+
await withTestDir(async (dir) => {
|
|
127
|
+
await initRepo(dir);
|
|
128
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
129
|
+
writeFixtureConvergeOp(dir, "prod-converge", "prod");
|
|
130
|
+
|
|
131
|
+
const { ops } = await discoverConvergeOps({ cwd: dir });
|
|
132
|
+
expect(ops.map((d) => d.config.name)).toEqual(["prod-converge", "staging-converge"]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("runOperatorRound — lease + tick execution over a fixture ConvergeOp (offline)", () => {
|
|
138
|
+
test("acquires the lease, ticks, and records the tick as a ledger fact", async () => {
|
|
139
|
+
await withTestDir(async (dir) => {
|
|
140
|
+
await initRepo(dir);
|
|
141
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
142
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
143
|
+
|
|
144
|
+
const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
|
|
145
|
+
expect(events).toEqual([{ kind: "ticked", op: "staging-converge", env: "staging", result: expect.objectContaining({ ok: true }) }]);
|
|
146
|
+
|
|
147
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
148
|
+
expect(records).toHaveLength(1);
|
|
149
|
+
expect(records[0].op).toBe("staging-converge");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a second operator process (different holder) sees the lease held and skips — never queues", async () => {
|
|
154
|
+
await withTestDir(async (dir) => {
|
|
155
|
+
await initRepo(dir);
|
|
156
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
157
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
158
|
+
|
|
159
|
+
// holder-a acquires and is (simulated as) still ticking — hold the
|
|
160
|
+
// lease without releasing, by not letting a full round complete for it.
|
|
161
|
+
const { acquireLease } = await import("../lifecycle/lease");
|
|
162
|
+
await acquireLease("staging-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
163
|
+
|
|
164
|
+
const events = await runOperatorRound({ cwd: dir, holder: "holder-b", activities, profiles: PROFILES });
|
|
165
|
+
expect(events).toEqual([{ kind: "skipped-lease-held", op: "staging-converge", env: "staging", heldBy: "holder-a" }]);
|
|
166
|
+
|
|
167
|
+
// Nothing ticked — no ledger record.
|
|
168
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
169
|
+
expect(records).toHaveLength(0);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a stale lease .lock file reports lease-error for that op — never a thrown exception out of the round, never misread as lease-held (#1959 finding 2)", async () => {
|
|
174
|
+
await withTestDir(async (dir) => {
|
|
175
|
+
await initRepo(dir);
|
|
176
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
177
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
178
|
+
|
|
179
|
+
// A previous `chant operator` acquired the lease, then was killed
|
|
180
|
+
// mid-renewal — its `.lock` file is still sitting there, blocking
|
|
181
|
+
// every future write to this op's lease ref.
|
|
182
|
+
const { acquireLease } = await import("../lifecycle/lease");
|
|
183
|
+
await acquireLease("staging-converge", "holder-a", { cwd: dir, ttlMs: 60_000 });
|
|
184
|
+
mkdirSync(join(dir, ".git", "refs", "chant", "lease"), { recursive: true });
|
|
185
|
+
writeFileSync(join(dir, ".git", "refs", "chant", "lease", "staging-converge.lock"), "");
|
|
186
|
+
|
|
187
|
+
// holder-a itself tries to renew next round — it owns the lease, so
|
|
188
|
+
// acquireLease attempts the write and hits the lock file.
|
|
189
|
+
const events = await runOperatorRound({ cwd: dir, holder: "holder-a", activities, profiles: PROFILES });
|
|
190
|
+
expect(events).toHaveLength(1);
|
|
191
|
+
expect(events[0]).toMatchObject({ kind: "lease-error", op: "staging-converge", env: "staging" });
|
|
192
|
+
expect((events[0] as { error: string }).error).toContain("staging-converge.lock");
|
|
193
|
+
|
|
194
|
+
// Nothing ticked.
|
|
195
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
196
|
+
expect(records).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a failing tick reports tick-failed, not a thrown exception out of the round", async () => {
|
|
201
|
+
await withTestDir(async (dir) => {
|
|
202
|
+
await initRepo(dir);
|
|
203
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
204
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge", { throws: true });
|
|
205
|
+
|
|
206
|
+
const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
|
|
207
|
+
expect(events).toHaveLength(1);
|
|
208
|
+
expect(events[0].kind).toBe("tick-failed");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("ticks every discovered ConvergeOp across environments in one round", async () => {
|
|
213
|
+
await withTestDir(async (dir) => {
|
|
214
|
+
await initRepo(dir);
|
|
215
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
216
|
+
writeFixtureConvergeOp(dir, "prod-converge", "prod");
|
|
217
|
+
const activities = new Map<string, ActivityFn>([
|
|
218
|
+
...fakeTickActivities(dir, "staging", "staging-converge"),
|
|
219
|
+
]);
|
|
220
|
+
// Both fixtures call the same "fakeTick" fn name; one shared fake that
|
|
221
|
+
// reads env/op from the running Op's own config isn't available to an
|
|
222
|
+
// ActivityFn (it only sees step args), so this test only asserts round
|
|
223
|
+
// shape/coverage, not each op's own ledger content — that's covered by
|
|
224
|
+
// the single-op test above.
|
|
225
|
+
activities.set("fakeTick", async () => ({ ok: true }));
|
|
226
|
+
|
|
227
|
+
const events = await runOperatorRound({ cwd: dir, holder: "op-a", activities, profiles: PROFILES });
|
|
228
|
+
expect(events.map((e) => e.op).sort()).toEqual(["prod-converge", "staging-converge"]);
|
|
229
|
+
expect(events.every((e) => e.kind === "ticked")).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("crash recovery — re-tick after a lease expires with no manual repair (#1485)", () => {
|
|
235
|
+
test("holder A ticks, 'crashes' (never renews); after the TTL passes, holder B re-acquires and re-ticks — the ledger shows continuity, not a gap", async () => {
|
|
236
|
+
await withTestDir(async (dir) => {
|
|
237
|
+
await initRepo(dir);
|
|
238
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
239
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
240
|
+
|
|
241
|
+
const t0 = new Date("2026-01-01T00:00:00.000Z");
|
|
242
|
+
const round1 = await runOperatorRound({
|
|
243
|
+
cwd: dir, holder: "operator-a", leaseTtlMs: 1_000, activities, profiles: PROFILES, now: () => t0,
|
|
244
|
+
});
|
|
245
|
+
expect(round1[0].kind).toBe("ticked");
|
|
246
|
+
|
|
247
|
+
// operator-a crashes: no release, no renewal. A restarted process (a
|
|
248
|
+
// new holder id, matching `currentHolderId()`'s per-process identity)
|
|
249
|
+
// tries again after the lease's TTL has passed.
|
|
250
|
+
const tAfterCrash = new Date("2026-01-01T00:00:05.000Z");
|
|
251
|
+
const round2 = await runOperatorRound({
|
|
252
|
+
cwd: dir, holder: "operator-a-restarted", leaseTtlMs: 1_000, activities, profiles: PROFILES, now: () => tAfterCrash,
|
|
253
|
+
});
|
|
254
|
+
expect(round2[0].kind).toBe("ticked");
|
|
255
|
+
|
|
256
|
+
const { records } = await readConvergeLedger("staging", { cwd: dir });
|
|
257
|
+
expect(records).toHaveLength(2);
|
|
258
|
+
|
|
259
|
+
const { record: lease } = await readLease("staging-converge", { cwd: dir });
|
|
260
|
+
expect(lease?.holder).toBe("operator-a-restarted");
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("re-acquiring before the TTL passes still fails — a live operator isn't preempted by a phantom crash", async () => {
|
|
265
|
+
await withTestDir(async (dir) => {
|
|
266
|
+
await initRepo(dir);
|
|
267
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
268
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
269
|
+
|
|
270
|
+
const t0 = new Date("2026-01-01T00:00:00.000Z");
|
|
271
|
+
await runOperatorRound({ cwd: dir, holder: "operator-a", leaseTtlMs: 60_000, activities, profiles: PROFILES, now: () => t0 });
|
|
272
|
+
|
|
273
|
+
const tSoonAfter = new Date("2026-01-01T00:00:05.000Z");
|
|
274
|
+
const events = await runOperatorRound({
|
|
275
|
+
cwd: dir, holder: "operator-b", leaseTtlMs: 60_000, activities, profiles: PROFILES, now: () => tSoonAfter,
|
|
276
|
+
});
|
|
277
|
+
expect(events[0].kind).toBe("skipped-lease-held");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("runOperatorForever — interval/timer behavior", () => {
|
|
283
|
+
// `sleepAbortable` (operator.ts) is driven by real `setTimeout`, but each
|
|
284
|
+
// round underneath it makes several *real* git subprocess calls (lease
|
|
285
|
+
// read/acquire, ledger append) — genuine process I/O that fake timers
|
|
286
|
+
// (`vi.useFakeTimers`) can't fast-forward through, only JS-scheduled
|
|
287
|
+
// callbacks. So this exercises the actual interval/abort behavior over
|
|
288
|
+
// real (small) wall-clock time instead: a short `intervalMs`, polling for
|
|
289
|
+
// each expected round count with a generous bound, never a bare sleep.
|
|
290
|
+
async function waitForRoundCount(rounds: unknown[], n: number, maxWaitMs = 5_000): Promise<void> {
|
|
291
|
+
const deadline = Date.now() + maxWaitMs;
|
|
292
|
+
while (rounds.length < n && Date.now() < deadline) {
|
|
293
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
294
|
+
}
|
|
295
|
+
expect(rounds.length).toBeGreaterThanOrEqual(n);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
test("runs a round immediately, then again every intervalMs, stopping on abort", async () => {
|
|
299
|
+
await withTestDir(async (dir) => {
|
|
300
|
+
await initRepo(dir);
|
|
301
|
+
writeFixtureConvergeOp(dir, "staging-converge", "staging");
|
|
302
|
+
const activities = fakeTickActivities(dir, "staging", "staging-converge");
|
|
303
|
+
|
|
304
|
+
const controller = new AbortController();
|
|
305
|
+
const rounds: number[] = [];
|
|
306
|
+
const loop = runOperatorForever({
|
|
307
|
+
cwd: dir,
|
|
308
|
+
holder: "op-a",
|
|
309
|
+
intervalMs: 40,
|
|
310
|
+
activities,
|
|
311
|
+
profiles: PROFILES,
|
|
312
|
+
signal: controller.signal,
|
|
313
|
+
onRound: () => rounds.push(Date.now()),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
await waitForRoundCount(rounds, 1); // the immediate first round
|
|
317
|
+
await waitForRoundCount(rounds, 3); // at least two more, ~40ms apart
|
|
318
|
+
|
|
319
|
+
controller.abort();
|
|
320
|
+
const countAtAbort = rounds.length;
|
|
321
|
+
await new Promise((r) => setTimeout(r, 120)); // well past a couple more intervals, if any fired
|
|
322
|
+
expect(rounds.length).toBe(countAtAbort); // no further round after abort
|
|
323
|
+
|
|
324
|
+
await loop;
|
|
325
|
+
});
|
|
326
|
+
}, 10_000);
|
|
327
|
+
|
|
328
|
+
test("default interval is used when none is given", () => {
|
|
329
|
+
expect(DEFAULT_OPERATOR_INTERVAL_MS).toBeGreaterThan(0);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("formatRoundLine", () => {
|
|
334
|
+
test("renders each event kind as one line", () => {
|
|
335
|
+
expect(formatRoundLine({ kind: "ticked", op: "x", env: "staging", result: { op: "x", records: [], totalMs: 1, ok: true } }))
|
|
336
|
+
.toContain("ticked=1 ok=true");
|
|
337
|
+
expect(formatRoundLine({ kind: "skipped-lease-held", op: "x", env: "staging", heldBy: "holder-a" }))
|
|
338
|
+
.toContain("lease-held:holder-a");
|
|
339
|
+
expect(formatRoundLine({ kind: "tick-failed", op: "x", env: "staging", error: "boom" }))
|
|
340
|
+
.toContain('error="boom"');
|
|
341
|
+
expect(formatRoundLine({ kind: "fenced", op: "x", env: "staging" }))
|
|
342
|
+
.toContain("fenced=1");
|
|
343
|
+
expect(formatRoundLine({ kind: "lease-error", op: "x", env: "staging", error: "stale lock at .../x.lock" }))
|
|
344
|
+
.toContain("stale lock at .../x.lock");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant operator` (#1485, epic #1487) — native durable ticks without
|
|
3
|
+
* Temporal. A deliberately small daemon: a timer, a lease
|
|
4
|
+
* (../lifecycle/lease.ts), and the existing local executor
|
|
5
|
+
* (./local-executor.ts) in a loop. No new service, no new state store — the
|
|
6
|
+
* lease lives on a git ref, tick state lives on the `chant/lifecycle`
|
|
7
|
+
* orphan branch via the same `convergeTick` activity `chant run <op>`
|
|
8
|
+
* already calls for a single one-shot tick.
|
|
9
|
+
*
|
|
10
|
+
* One round: discover every `ConvergeOp` (searchAttributes `Converge:
|
|
11
|
+
* "true"`, optionally filtered to one env), and for each one, try to
|
|
12
|
+
* acquire (or renew) its lease. Win it — run one tick via `runOpLocally` on
|
|
13
|
+
* the Op's own Observe → Converge phases, the identical path `chant run
|
|
14
|
+
* <name>` takes for a single tick (issue: "chant run fountain-converge —
|
|
15
|
+
* one tick, by hand — unchanged, and also the test story"). Lose it — skip
|
|
16
|
+
* and report, never queue (issue: "a tick that finds the lease held skips
|
|
17
|
+
* and reports ... never queues").
|
|
18
|
+
*
|
|
19
|
+
* Crash recovery needs no special code: a killed operator simply stops
|
|
20
|
+
* renewing its lease, which expires on its own TTL; the next round (from
|
|
21
|
+
* this process restarted, a different machine's operator, or a bare `chant
|
|
22
|
+
* run <name>` invoked by cron) re-acquires and re-ticks. A converge tick
|
|
23
|
+
* re-observes and re-derives everything every time (ConvergeOp's own
|
|
24
|
+
* design, #1484) — re-ticking after a crash and ticking on a normal
|
|
25
|
+
* schedule are the same act, not two different code paths.
|
|
26
|
+
*
|
|
27
|
+
* One crash *does* need a distinct report, though (#1959 finding 2): if the
|
|
28
|
+
* killed operator's own `git update-ref` was interrupted mid-write, it can
|
|
29
|
+
* leave a stale `.lock` file behind that blocks every future acquire
|
|
30
|
+
* attempt for that op's lease until someone removes it — TTL expiry doesn't
|
|
31
|
+
* help, since the ref update itself can't land. `acquireLease` surfaces
|
|
32
|
+
* that case as a `StaleLockError` rather than ordinary "lease held by
|
|
33
|
+
* someone else" contention, and a round reports it per-op as
|
|
34
|
+
* `{ kind: "lease-error" }` (never aborting the whole round) so `chant
|
|
35
|
+
* operator`'s log names the fix instead of quietly skipping that op
|
|
36
|
+
* forever.
|
|
37
|
+
*/
|
|
38
|
+
import type { ActivityFn, ActivityProfile } from "./activity-registry";
|
|
39
|
+
import { discoverOps, type DiscoveredOp } from "./discover";
|
|
40
|
+
import { runOpLocally, OpRunFailure, LocalGateUnsupportedError, type OpRunResult } from "./local-executor";
|
|
41
|
+
import { acquireLease, stillHoldsLease, currentHolderId, DEFAULT_LEASE_TTL_MS, type LeaseRecord, type AcquireLeaseResult } from "../lifecycle/lease";
|
|
42
|
+
import { StaleLockError } from "../lifecycle/git";
|
|
43
|
+
|
|
44
|
+
/** Poll interval between rounds — the operator's own cadence, distinct from a `ConvergeOp`'s Temporal `schedule` cron (that field drives the durable path's `TemporalSchedule`, not anything the local executor can read back at discovery time; see this module's doc). Chosen short enough to converge promptly, long enough not to hammer `chant lifecycle plan --live` every few seconds. */
|
|
45
|
+
export const DEFAULT_OPERATOR_INTERVAL_MS = 60_000;
|
|
46
|
+
|
|
47
|
+
/** One ConvergeOp's own `searchAttributes.Env` (`../../lexicons/temporal/src/composites/converge-op.ts` always sets it). `undefined` for a non-ConvergeOp or a hand-built one missing it — filtered out by `isConvergeOp` before this is trusted. */
|
|
48
|
+
function envOf(config: DiscoveredOp["config"]): string | undefined {
|
|
49
|
+
return config.searchAttributes?.Env;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isConvergeOp(config: DiscoveredOp["config"]): boolean {
|
|
53
|
+
return config.searchAttributes?.Converge === "true";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Discover every `ConvergeOp` in the project (`searchAttributes.Converge === "true"`), optionally filtered to one environment. Ops that don't declare an `Env` search attribute are excluded from an `--env`-filtered discovery (there's nothing to match), but included when no `env` filter is given. */
|
|
57
|
+
export async function discoverConvergeOps(
|
|
58
|
+
opts?: { cwd?: string; env?: string },
|
|
59
|
+
): Promise<{ ops: DiscoveredOp[]; errors: string[] }> {
|
|
60
|
+
const { ops, errors } = await discoverOps({ cwd: opts?.cwd });
|
|
61
|
+
const converge = [...ops.values()].filter((d) => isConvergeOp(d.config));
|
|
62
|
+
const filtered = opts?.env ? converge.filter((d) => envOf(d.config) === opts.env) : converge;
|
|
63
|
+
return { ops: filtered.sort((a, b) => a.config.name.localeCompare(b.config.name)), errors };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One discovered ConvergeOp's outcome for one round — what `chant operator`'s one-line-per-tick log (and its tests) key off of. */
|
|
67
|
+
export type OperatorTickEvent =
|
|
68
|
+
| { kind: "ticked"; op: string; env: string; result: OpRunResult }
|
|
69
|
+
| { kind: "skipped-lease-held"; op: string; env: string; heldBy?: string }
|
|
70
|
+
| { kind: "tick-failed"; op: string; env: string; error: string }
|
|
71
|
+
/** The lease was lost between acquiring it and the tick finishing (e.g. this process stalled past its TTL and another operator reclaimed it) — the tick's own ledger record (written inside `convergeTick`) still landed, since a converge tick is idempotent by design; this event exists purely so `chant operator`'s log and the ledger-independent test surface can see the fencing violation happened. Never a hard failure. */
|
|
72
|
+
| { kind: "fenced"; op: string; env: string }
|
|
73
|
+
/**
|
|
74
|
+
* `acquireLease` itself threw — a `StaleLockError` (#1959 finding 2: a
|
|
75
|
+
* previous `chant operator` was killed mid-write and left a `.lock` file
|
|
76
|
+
* behind, so this op's lease can no longer be acquired or renewed by
|
|
77
|
+
* anyone until the file is removed) or any other unexpected failure. A
|
|
78
|
+
* distinct outcome from `skipped-lease-held` on purpose — this is not
|
|
79
|
+
* ordinary contention with a live holder, it's wreckage that needs a
|
|
80
|
+
* human, and reading it as "someone else has it" would make the operator
|
|
81
|
+
* back off silently forever instead of surfacing the fix. One op's lease
|
|
82
|
+
* error never aborts the round for every other op.
|
|
83
|
+
*/
|
|
84
|
+
| { kind: "lease-error"; op: string; env: string; error: string };
|
|
85
|
+
|
|
86
|
+
export interface OperatorRoundOptions {
|
|
87
|
+
cwd?: string;
|
|
88
|
+
/** Restrict ticking to ConvergeOps for this environment. Omit to tick every discovered ConvergeOp. */
|
|
89
|
+
env?: string;
|
|
90
|
+
/** @default DEFAULT_LEASE_TTL_MS */
|
|
91
|
+
leaseTtlMs?: number;
|
|
92
|
+
/** This process's lease identity. @default currentHolderId() — a fresh identity per process, deliberately: two ticks from what could plausibly be two different processes should behave like two different holders even in a test that reuses one process, unless the caller wants to simulate "same process, later round" by passing a stable value. */
|
|
93
|
+
holder?: string;
|
|
94
|
+
/** Injected activity implementations — the CLI loads the real ones the same way `chant run` does (`loadActivities`); tests inject fakes so a round never shells out. */
|
|
95
|
+
activities: Map<string, ActivityFn>;
|
|
96
|
+
profiles: Record<string, ActivityProfile>;
|
|
97
|
+
now?: () => Date;
|
|
98
|
+
signal?: AbortSignal;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Run exactly one round: for every discovered ConvergeOp (filtered per
|
|
103
|
+
* `opts.env`), try its lease; tick the ones this holder wins. Returns one
|
|
104
|
+
* event per discovered op, in discovery order — deterministic, so tests can
|
|
105
|
+
* assert on it directly without depending on log output.
|
|
106
|
+
*/
|
|
107
|
+
export async function runOperatorRound(opts: OperatorRoundOptions): Promise<OperatorTickEvent[]> {
|
|
108
|
+
const holder = opts.holder ?? currentHolderId();
|
|
109
|
+
const { ops } = await discoverConvergeOps({ cwd: opts.cwd, env: opts.env });
|
|
110
|
+
const events: OperatorTickEvent[] = [];
|
|
111
|
+
|
|
112
|
+
for (const { config } of ops) {
|
|
113
|
+
const env = envOf(config) ?? "unknown";
|
|
114
|
+
|
|
115
|
+
let acquired: AcquireLeaseResult;
|
|
116
|
+
try {
|
|
117
|
+
acquired = await acquireLease(config.name, holder, {
|
|
118
|
+
cwd: opts.cwd,
|
|
119
|
+
ttlMs: opts.leaseTtlMs ?? DEFAULT_LEASE_TTL_MS,
|
|
120
|
+
now: opts.now,
|
|
121
|
+
});
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Not lease contention — the acquire attempt itself failed (most
|
|
124
|
+
// likely a StaleLockError, ./lifecycle/git.ts's diagnosable
|
|
125
|
+
// stand-in for "a previous operator was killed mid-write"). Report it
|
|
126
|
+
// for this op and move on to the next; never abort the whole round.
|
|
127
|
+
const message =
|
|
128
|
+
err instanceof StaleLockError ? err.message : err instanceof Error ? err.message : String(err);
|
|
129
|
+
events.push({ kind: "lease-error", op: config.name, env, error: message });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!acquired.acquired) {
|
|
134
|
+
events.push({ kind: "skipped-lease-held", op: config.name, env, heldBy: acquired.heldBy?.holder });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const lease = acquired.lease as LeaseRecord;
|
|
139
|
+
try {
|
|
140
|
+
const result = await runOpLocally(config, opts.activities, opts.profiles, opts.signal);
|
|
141
|
+
const held = await stillHoldsLease(config.name, holder, lease.token, { cwd: opts.cwd });
|
|
142
|
+
events.push(held ? { kind: "ticked", op: config.name, env, result } : { kind: "fenced", op: config.name, env });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
const message =
|
|
145
|
+
err instanceof OpRunFailure
|
|
146
|
+
? `Op "${config.name}" failed — see its ledger record for step-level detail`
|
|
147
|
+
: err instanceof LocalGateUnsupportedError
|
|
148
|
+
? err.message
|
|
149
|
+
: err instanceof Error
|
|
150
|
+
? err.message
|
|
151
|
+
: String(err);
|
|
152
|
+
events.push({ kind: "tick-failed", op: config.name, env, error: message });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return events;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Abortable sleep — resolves early (without throwing) if `signal` fires mid-wait, so the operator loop can stop promptly on Ctrl-C rather than finishing out a long interval. */
|
|
160
|
+
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
if (signal?.aborted) return resolve();
|
|
163
|
+
const timer = setTimeout(() => {
|
|
164
|
+
signal?.removeEventListener("abort", onAbort);
|
|
165
|
+
resolve();
|
|
166
|
+
}, ms);
|
|
167
|
+
const onAbort = () => {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
resolve();
|
|
170
|
+
};
|
|
171
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface OperatorLoopOptions extends OperatorRoundOptions {
|
|
176
|
+
/** @default DEFAULT_OPERATOR_INTERVAL_MS */
|
|
177
|
+
intervalMs?: number;
|
|
178
|
+
/** Called after every round completes — the CLI's one-line-per-tick log lives here, not inside the loop itself, so a test can drive rounds without capturing stdout. */
|
|
179
|
+
onRound?: (events: OperatorTickEvent[]) => void;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Run rounds forever, sleeping `intervalMs` between them, until
|
|
184
|
+
* `opts.signal` aborts. The daemon is a convenience, never a requirement
|
|
185
|
+
* (issue: "if it dies, nothing breaks and nothing is lost") — this function
|
|
186
|
+
* is exactly `while (!aborted) { round(); sleep(); }`, nothing durable lives
|
|
187
|
+
* in its own memory that a restart would need to recover.
|
|
188
|
+
*/
|
|
189
|
+
export async function runOperatorForever(opts: OperatorLoopOptions): Promise<void> {
|
|
190
|
+
const holder = opts.holder ?? currentHolderId();
|
|
191
|
+
while (!opts.signal?.aborted) {
|
|
192
|
+
const events = await runOperatorRound({ ...opts, holder });
|
|
193
|
+
opts.onRound?.(events);
|
|
194
|
+
if (opts.signal?.aborted) break;
|
|
195
|
+
await sleepAbortable(opts.intervalMs ?? DEFAULT_OPERATOR_INTERVAL_MS, opts.signal);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Render one round's events as `chant operator`'s log line shape — one line per ticked/skipped/failed op, reusing each ticked op's own `convergeTick` log line (`OpRunResult`'s outcome attribute doesn't carry it, so this reads the tick's step record's `Remediated`... actually the human-readable summary comes from the ledger, not the run result — see `../cli/handlers/operator.ts` for where `chant operator status` reads it back). This formats what's available from the round itself: which op, which env, and whether it ticked, skipped, or failed. */
|
|
200
|
+
export function formatRoundLine(event: OperatorTickEvent): string {
|
|
201
|
+
switch (event.kind) {
|
|
202
|
+
case "ticked":
|
|
203
|
+
return `operator: ${event.op}@${event.env} ticked=1 ok=${event.result.ok}`;
|
|
204
|
+
case "skipped-lease-held":
|
|
205
|
+
return `operator: ${event.op}@${event.env} skipped=1(lease-held${event.heldBy ? `:${event.heldBy}` : ""})`;
|
|
206
|
+
case "tick-failed":
|
|
207
|
+
return `operator: ${event.op}@${event.env} failed=1 error="${event.error}"`;
|
|
208
|
+
case "fenced":
|
|
209
|
+
return `operator: ${event.op}@${event.env} fenced=1(lease lost mid-tick — ledger record still written)`;
|
|
210
|
+
case "lease-error":
|
|
211
|
+
return `operator: ${event.op}@${event.env} error=1(lease acquire failed — ${event.error})`;
|
|
212
|
+
}
|
|
213
|
+
}
|