From ca00f1a7b3dad93d163ba582806fb44dbe321e8c Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:25:49 -0600 Subject: [PATCH 1/2] chore(op,lexicons): the comments and fixtures describe the run mechanism, not the engine that is gone Sub-issue 4 of #2191, the vocabulary half. Every line here named a mechanism chant no longer has, so each one is rewritten against what the local executor actually does. "Search attribute" becomes the run outcome or the outcome attribute: an `outcomeAttribute` on a step is captured onto that step's `StepRecord` and folded onto the run ledger record, which is what a reader filters runs by. "Workflow", where it meant the Op or the run, becomes the Op or the run. `OpConfig.name` names the Op's output directory; the workflow function name it also claimed to name was deleted in 257ca0cb. "The worker's cwd" becomes the chant process's cwd, and the one "replay-safe" argument becomes the reason it was standing in for. TMP014 becomes OPS014, which is the rule that ships; TMP010 as a live peer becomes the shared validator in `op/cron.ts`. The six lexicon step-builder headers stop contrasting themselves with a temporal barrel and say why the builders live in the lexicon: core cannot import a lexicon's `*Args` types. The vitest and no-egress timeout comments drop the webpack-bundling claim. `forgejo-preview-e2e` says the two packages it actually packs. The COMP004 docs heading becomes the rule's own description, with a note that the id and the file name deliberately stay. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0148a4dPTErCBpmNy7J7933f --- .../content/docs/lint-rules/composition.mdx | 7 ++++- lexicons/aws/src/codegen/docs.ts | 6 ++-- lexicons/aws/src/op/builders.ts | 8 ++--- lexicons/azure/src/deep-observe.ts | 3 +- lexicons/azure/src/op/builders.ts | 8 +++-- .../cedar/src/dogwood/replay-activity.test.ts | 2 +- lexicons/cedar/src/dogwood/replay-activity.ts | 2 +- lexicons/cedar/src/dogwood/replay-op.ts | 5 ++-- .../components/run-agent-conformance.test.ts | 29 ++++++++++--------- lexicons/gcp/src/op/builders.ts | 8 +++-- lexicons/k3d/src/op/builders.ts | 8 +++-- lexicons/k3s/src/op/builders.ts | 8 +++-- .../k8s/src/composites/composites.test.ts | 4 +-- lexicons/k8s/src/composites/operator-stack.ts | 19 ++++++------ lexicons/k8s/src/op/builders.ts | 5 ++-- .../src/composites/terraform-adopt-op.ts | 2 +- .../terraform-apply-op.acceptance.test.ts | 2 +- .../src/composites/terraform-apply-op.test.ts | 2 +- .../src/composites/terraform-apply-op.ts | 4 +-- .../src/composites/terraform-watch-op.test.ts | 2 +- .../src/composites/terraform-watch-op.ts | 6 ++-- lexicons/terraform/src/op/adoption.ts | 2 +- .../terraform/src/skills/chant-terraform.md | 2 +- packages/core/src/cli/commands/build.test.ts | 21 ++++++++------ packages/core/src/cli/commands/build.ts | 16 +++++----- packages/core/src/components/auto-release.ts | 7 +++-- packages/core/src/components/cli-support.ts | 3 +- .../core/src/components/component.schema.json | 2 +- .../pilots/pilots-lint-clean.test.ts | 11 +++---- .../comp004/pass/orders-table.component.ts | 2 +- .../comp004-gate-needs-durable-runtime.ts | 7 +++++ .../op/ops014-converge-rule-refusals.test.ts | 2 +- packages/core/src/op/activities/apply.ts | 8 ++--- .../core/src/op/activities/converge.test.ts | 2 +- packages/core/src/op/activities/converge.ts | 18 ++++++------ .../core/src/op/activities/env-teardown.ts | 8 ++--- packages/core/src/op/activities/lifecycle.ts | 4 +-- packages/core/src/op/activities/reconcile.ts | 4 +-- packages/core/src/op/builders.ts | 8 ++--- packages/core/src/op/composites/apply-op.ts | 6 ++-- .../core/src/op/composites/composites.test.ts | 14 ++++----- .../src/op/composites/converge-op.test.ts | 2 +- .../core/src/op/composites/converge-op.ts | 14 ++++----- .../op/composites/lexicon-upgrade-op.test.ts | 2 +- .../src/op/composites/lexicon-upgrade-op.ts | 12 ++++---- .../src/op/composites/pipeline-audit-op.ts | 4 +-- .../core/src/op/composites/reconcile-op.ts | 4 +-- packages/core/src/op/composites/watch-op.ts | 18 ++++++------ .../src/op/composites/workflow-audit-op.ts | 7 +++-- packages/core/src/op/cron.test.ts | 4 +-- packages/core/src/op/cron.ts | 5 ++-- packages/core/src/op/local-executor.ts | 4 +-- packages/core/src/op/op-ir.ts | 4 +-- packages/core/src/op/op-verb-class.ts | 2 +- packages/core/src/op/op.test.ts | 2 +- packages/core/src/op/receipt-store.ts | 2 +- packages/core/src/op/step-output-ref.ts | 6 ++-- packages/core/src/op/types.ts | 7 +++-- test/forgejo-preview-e2e.sh | 4 +-- test/forgejo-preview-e2e/README.md | 2 +- test/no-egress.test.ts | 7 ++--- vitest.config.ts | 9 +++--- 62 files changed, 220 insertions(+), 188 deletions(-) diff --git a/docs/src/content/docs/lint-rules/composition.mdx b/docs/src/content/docs/lint-rules/composition.mdx index 58f890529..1965b6139 100644 --- a/docs/src/content/docs/lint-rules/composition.mdx +++ b/docs/src/content/docs/lint-rules/composition.mdx @@ -137,7 +137,12 @@ The capability interface and `component.schema.json` are out of scope for this r --- -## COMP004: Gate Needs Durable Runtime +## COMP004: Gate Ends The Run Pending Approval + +The rule id stays `COMP004`, and so does its file name +(`comp004-gate-needs-durable-runtime.ts`). Renaming either would churn every +`chant-disable` comment and every import for no gain, so the heading above is +the rule's own description and the file name underneath it is historical. Flags a `gate` step anywhere in a component's composition. A gate ends the run pending `chant approve `: the driver decides it against the gate ledger, and one nobody has approved stops the run there, with no later phase running. Which runtime hosts the run is chosen per run via `chant run --on `, never declared in the component itself. diff --git a/lexicons/aws/src/codegen/docs.ts b/lexicons/aws/src/codegen/docs.ts index 69c17fcac..6aaa98207 100644 --- a/lexicons/aws/src/codegen/docs.ts +++ b/lexicons/aws/src/codegen/docs.ts @@ -113,9 +113,9 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise { expect(report.steps[0].fn).toBe("dogwoodReplayReport"); }); - test("the divergence count rides out as a search attribute", () => { + test("the divergence count rides out as a run outcome", () => { const { op } = PolicyReplayOp({ name: "policy-replay", tracePath: "trace/t.log" }); expect(phasesOf(op)[1].steps[0].outcomeAttribute).toEqual({ name: "Divergences", from: "findings" }); }); diff --git a/lexicons/cedar/src/dogwood/replay-activity.ts b/lexicons/cedar/src/dogwood/replay-activity.ts index ee2b7d3f5..2f0920f1c 100644 --- a/lexicons/cedar/src/dogwood/replay-activity.ts +++ b/lexicons/cedar/src/dogwood/replay-activity.ts @@ -94,7 +94,7 @@ export interface PolicyReplayReport { /** Every decision point, in stream order. */ readonly verdicts: readonly DogwoodVerdict[]; readonly divergences: readonly ReplayDivergence[]; - /** Divergence count — the number a search attribute or a gate reads. */ + /** Divergence count — the number an outcome attribute or a gate reads. */ readonly findings: number; /** Trace weaknesses found by `auditTrace`, when typed events were supplied. */ readonly traceIssues: readonly TraceIssue[]; diff --git a/lexicons/cedar/src/dogwood/replay-op.ts b/lexicons/cedar/src/dogwood/replay-op.ts index c81da8e33..469a3df3a 100644 --- a/lexicons/cedar/src/dogwood/replay-op.ts +++ b/lexicons/cedar/src/dogwood/replay-op.ts @@ -209,8 +209,9 @@ export function PolicyReplayOp(config: PolicyReplayOpConfig): PolicyReplayOpReso ...(config.binary ? { binary: config.binary } : {}), reportPath, }), - // The divergence count as a workflow search attribute, so "show me the - // replays that found something" is one filter rather than a log read. + // The divergence count as the run's `Divergences` outcome on the run + // ledger, so the replays that found something are readable from the + // ledger rather than out of a log. outcomeAttribute: { name: "Divergences", from: "findings" }, }, ]), diff --git a/lexicons/fly/src/components/run-agent-conformance.test.ts b/lexicons/fly/src/components/run-agent-conformance.test.ts index 16a9b587c..c2709c860 100644 --- a/lexicons/fly/src/components/run-agent-conformance.test.ts +++ b/lexicons/fly/src/components/run-agent-conformance.test.ts @@ -20,10 +20,12 @@ * to the pre-run checkpoint, through `driver.ts`'s `rollbackExecuted`, not * the Op-level `onFailure` path `examples/sprites-agent-task` already * demonstrates. - * 2. **The durable-identity channel (#1944's scope addition, from #1949's - * review).** On a durable runtime, `run()` and `rollback()` execute as - * separate steps, each rebuilding `input` fresh — the in-process - * `WeakMap` `run-agent`'s capability keeps never gets a hit there. + * 2. **The rollback identity channel (#1944's scope addition, from #1949's + * review).** A runtime that splits a run across process boundaries — a + * generated CI pipeline running each component as its own job, say — + * rebuilds `input` from JSON for every call, so `run()` and `rollback()` + * never see the same object. The in-process `WeakMap` `run-agent`'s + * capability keeps never gets a hit there. * `Capability.rollback` grew an optional third `output` parameter for * exactly this (`../../../packages/core/src/components/capability.ts`), * and `run-agent`'s own rollback prefers it. This file proves the @@ -171,17 +173,16 @@ describe("run-agent — saga-unwind restore through the component driver (#1944) }); }); -describe("run-agent — durable identity channel (#1944, scope addition from #1949's review)", () => { - test("rollback restores via output.spriteId/checkpointId even when called with a freshly-rebuilt input object (no WeakMap hit) — the durable step-boundary shape", async () => { - // On a durable runtime, a rollback step resolves its own fresh - // `resolvedInput` from JSON every call — never the same object run() was - // called with. This test reproduces that exact shape directly against the - // real capability, without needing a worker: build input, run(), then - // rollback() with a DIFFERENT (shallow- - // cloned) input object, passing run()'s own output as the third - // parameter — the durable identity channel. +describe("run-agent — rollback identity channel (#1944, scope addition from #1949's review)", () => { + test("rollback restores via output.spriteId/checkpointId even when called with a freshly-rebuilt input object (no WeakMap hit) — the cross-process step shape", async () => { + // A runtime that splits a run across process boundaries resolves each + // step's `resolvedInput` from JSON afresh — never the same object run() + // was called with. This test reproduces that exact shape directly against + // the real capability, in one process: build input, run(), then + // rollback() with a DIFFERENT (shallow-cloned) input object, passing + // run()'s own output as the third parameter — the identity channel. const capability = createFlyRunAgentCapability(); - const spriteName = `durable-identity-${Date.now()}`; + const spriteName = `rollback-identity-${Date.now()}`; const sprites = createFlySpriteActivities(); await sprites.create({ name: spriteName }); diff --git a/lexicons/gcp/src/op/builders.ts b/lexicons/gcp/src/op/builders.ts index 43d6e1e96..1146e3970 100644 --- a/lexicons/gcp/src/op/builders.ts +++ b/lexicons/gcp/src/op/builders.ts @@ -1,9 +1,11 @@ /** * Typed step-builder wrappers for this lexicon's activities (chant #1288 * Stage 2). See `lexicons/k8s/src/op/builders.ts`'s module doc for why these - * live beside their `*Args` interfaces rather than in core or the temporal - * barrel. `opts`'s type in each wrapper below IS the activity's own `*Args` - * interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own + * live beside their `*Args` interfaces rather than in core: core cannot + * import a lexicon's types, so a wrapper whose `opts` IS the activity's own + * `*Args` interface can only sit in the lexicon that declares that + * interface. `opts`'s type in each wrapper below IS the activity's own + * `*Args` interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own * `gcpApply`/`gcpDelete`/`flociGcpUp`/`flociGcpDown` are unchanged and * produce byte-identical `ActivityStep` output; these are purely additive. */ diff --git a/lexicons/k3d/src/op/builders.ts b/lexicons/k3d/src/op/builders.ts index be8062b4c..9aeb2251b 100644 --- a/lexicons/k3d/src/op/builders.ts +++ b/lexicons/k3d/src/op/builders.ts @@ -1,9 +1,11 @@ /** * Typed step-builder wrappers for this lexicon's activities (chant #1288 * Stage 2). See `lexicons/k8s/src/op/builders.ts`'s module doc for why these - * live beside their `*Args` interfaces rather than in core or the temporal - * barrel. `opts`'s type in each wrapper below IS the activity's own `*Args` - * interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own + * live beside their `*Args` interfaces rather than in core: core cannot + * import a lexicon's types, so a wrapper whose `opts` IS the activity's own + * `*Args` interface can only sit in the lexicon that declares that + * interface. `opts`'s type in each wrapper below IS the activity's own + * `*Args` interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own * `k3dUp`/`k3dDown` are unchanged and produce byte-identical `ActivityStep` * output; these are purely additive. */ diff --git a/lexicons/k3s/src/op/builders.ts b/lexicons/k3s/src/op/builders.ts index d48e81b34..6f553a96c 100644 --- a/lexicons/k3s/src/op/builders.ts +++ b/lexicons/k3s/src/op/builders.ts @@ -1,9 +1,11 @@ /** * Typed step-builder wrappers for this lexicon's activities (chant #1288 * Stage 2). See `lexicons/k8s/src/op/builders.ts`'s module doc for why these - * live beside their `*Args` interfaces rather than in core or the temporal - * barrel. `opts`'s type in each wrapper below IS the activity's own `*Args` - * interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own + * live beside their `*Args` interfaces rather than in core: core cannot + * import a lexicon's types, so a wrapper whose `opts` IS the activity's own + * `*Args` interface can only sit in the lexicon that declares that + * interface. `opts`'s type in each wrapper below IS the activity's own + * `*Args` interface (via `Omit`/`WithStepRefs`) — never restated. `core`'s own * `k3sInstall`/`k3sUninstall` are unchanged and produce byte-identical * `ActivityStep` output; these are purely additive. */ diff --git a/lexicons/k8s/src/composites/composites.test.ts b/lexicons/k8s/src/composites/composites.test.ts index 50d30baf7..2d18cde57 100644 --- a/lexicons/k8s/src/composites/composites.test.ts +++ b/lexicons/k8s/src/composites/composites.test.ts @@ -3868,7 +3868,7 @@ describe("OperatorStack", () => { } }); - test("observe-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — observe never dispatches, per TMP014/convergeTick", () => { + test("observe-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — observe never dispatches, per OPS014/convergeTick", () => { const result: any = OperatorStack({ name: "chant-operator", image: "chant:latest", @@ -3880,7 +3880,7 @@ describe("OperatorStack", () => { } }); - test("reconcile-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — TMP014 only lets apply free-run a mutating dispatch", () => { + test("reconcile-dial ConvergeOp with a mutating dispatch target still gets read-only RBAC — OPS014 only lets apply free-run a mutating dispatch", () => { const result: any = OperatorStack({ name: "chant-operator", image: "chant:latest", diff --git a/lexicons/k8s/src/composites/operator-stack.ts b/lexicons/k8s/src/composites/operator-stack.ts index a77728baa..261b5cf78 100644 --- a/lexicons/k8s/src/composites/operator-stack.ts +++ b/lexicons/k8s/src/composites/operator-stack.ts @@ -30,25 +30,26 @@ * * `ConvergeOp` adds no authority an environment did not already grant * (#1484's Autonomy table). `OperatorStack` re-derives the same bound at the - * k8s RBAC layer, independently of the temporal lexicon (this module has no - * dependency on it — see the layering note below): for each hosted + * k8s RBAC layer, from the Op configs alone (see the Layering note below for + * what this module imports and what it restates): for each hosted * ConvergeOp, walk its `dispatchTargets` (the OpConfigs its rule table's * `run()` actions may name) through `classifyOpVerbClass` * (`packages/core/src/op/op-verb-class.ts`, #1954), then keep only the * highest verb class this host's `dial` could ever actually free-run — * exactly `convergeTick`'s own `verbClassAllowedToDispatch` gate and - * `TMP014`'s build-time refusal, restated as an RBAC ceiling: + * `OPS014`'s build-time refusal, restated as an RBAC ceiling: * * - `dial: "observe"` never dispatches (report-only) → read-only RBAC, * regardless of what the rule table's targets could otherwise do. - * - `dial: "reconcile"` only free-runs a read-only target (TMP014 refuses a + * - `dial: "reconcile"` only free-runs a read-only target (OPS014 refuses a * mutating dispatch under reconcile in v1) → read-only RBAC. * - `dial: "apply"` free-runs read-only and mutating targets → RBAC gains * create/update/patch, never delete. * - A `dispatchTargets` entry that itself classifies `destructive` is - * refused outright, at construction — TMP014 already refuses a - * destructive `run()` target under any dial in v1 (the local dispatch - * executor can't honor its required gate), so a `destructive` target + * refused outright, at construction — OPS014 already refuses a + * destructive `run()` target under any dial in v1 (a converge tick runs + * unattended, and a destructive dispatch needs a person's approval before + * it is attempted, not a gate read after the fact), so a `destructive` target * reaching this composite is either a config bypassing that build check * or a target `OperatorStack` should never grant permission toward. * `never delete`, unconditionally, in v1 — no verb class here ever grants @@ -104,7 +105,7 @@ export interface OperatorStackConvergeHost { /** * OpConfigs for every op this ConvergeOp's rule table may `run()` — the * sibling `*.op.ts` declarations its `run()` actions name. Used to derive - * least-privilege RBAC the same way `TMP014` derives its build-time + * least-privilege RBAC the same way `OPS014` derives its build-time * refusals. Omit or leave empty when every rule only `report()`s; the * host still gets read-only RBAC for its own observation. */ @@ -198,7 +199,7 @@ function dialAllowsVerbClass(dial: OperatorDial, verbClass: OpVerbClass): boolea * Derive the highest verb class a host's ServiceAccount actually needs: * the max, across `dispatchTargets`, of each target's own class — but only * counting a target `dial` could ever actually dispatch (one it can't just - * gets reported, per `TMP014`/`convergeTick`, and needs no elevated grant). + * gets reported, per `OPS014`/`convergeTick`, and needs no elevated grant). * A `dispatchTargets` entry that classifies `destructive` is refused * outright rather than silently ignored — see this module's RBAC * derivation doc on why a destructive target reaching this composite is diff --git a/lexicons/k8s/src/op/builders.ts b/lexicons/k8s/src/op/builders.ts index dc8f1095c..ad8c9fb89 100644 --- a/lexicons/k8s/src/op/builders.ts +++ b/lexicons/k8s/src/op/builders.ts @@ -22,8 +22,9 @@ * UNCHANGED and produce byte-identical `ActivityStep` output for the same * inputs — these are purely additive. Deliberately not swapped into core's * barrel: that would make `@intentius/chant` depend on this package (and on - * helm, for `helmInstall`) at runtime, which is exactly the "a worker image - * needs no kubectl binary" / "core stays product-agnostic" property the + * helm, for `helmInstall`) at runtime, so a project that never touches + * Kubernetes would install both just to import anything from `op`. That is + * exactly the "core stays product-agnostic" property the * `op/activities/index.ts` module docs across this repo call out on purpose * (activities live in per-product lexicons for this reason; the step-builder * layer shouldn't reintroduce the coupling from the other direction). An diff --git a/lexicons/terraform/src/composites/terraform-adopt-op.ts b/lexicons/terraform/src/composites/terraform-adopt-op.ts index 08e9e4a15..2b48e916a 100644 --- a/lexicons/terraform/src/composites/terraform-adopt-op.ts +++ b/lexicons/terraform/src/composites/terraform-adopt-op.ts @@ -183,7 +183,7 @@ export function TerraformAdoptOp(config: TerraformAdoptOpConfig): TerraformAdopt `Approve adopting live resources into the "${config.root}" estate. The Ledger phase's ` + `adoption ledger is what is being approved: every resource it lists as adoptable gets this ` + `estate's tofu-estate and tofu-address tags written onto it. The Adoptable and Ambiguous ` + - `search attributes on that phase are its counts; an ambiguous address is never adopted.`, + `run outcomes from that phase are its counts; an ambiguous address is never adopted.`, }), ]), phase("Adopt", [adopt]), diff --git a/lexicons/terraform/src/composites/terraform-apply-op.acceptance.test.ts b/lexicons/terraform/src/composites/terraform-apply-op.acceptance.test.ts index f68a4a311..797dd6f58 100644 --- a/lexicons/terraform/src/composites/terraform-apply-op.acceptance.test.ts +++ b/lexicons/terraform/src/composites/terraform-apply-op.acceptance.test.ts @@ -130,7 +130,7 @@ describe.skipIf(skipReason !== "")( ]); // The plan proposed two creates, and said so through the outcome the - // Plan step surfaces as the `Changed` search attribute. + // Plan step surfaces as the `Changed` run outcome. const plan = result.records.find((r) => r.phase === "Plan")!; expect(plan.outcome).toEqual({ name: "Changed", value: true }); diff --git a/lexicons/terraform/src/composites/terraform-apply-op.test.ts b/lexicons/terraform/src/composites/terraform-apply-op.test.ts index 2466432de..5da45fcad 100644 --- a/lexicons/terraform/src/composites/terraform-apply-op.test.ts +++ b/lexicons/terraform/src/composites/terraform-apply-op.test.ts @@ -100,7 +100,7 @@ describe("TerraformApplyOp gate (#2086)", () => { test("the Gate phase reports the plan's destroy count before it waits", () => { // GateStep has no condition field, so "on-destroy" cannot branch at build // time. The approver is told instead: a `show` of the saved plan runs - // first and surfaces `destroys` as a search attribute. + // first and surfaces `destroys` as a run outcome. const op = props({ name: "prod-apply", root: "app" }); const steps = op.phases.find((p) => p.name === "Gate")!.steps; const show = steps[0] as ActivityStep; diff --git a/lexicons/terraform/src/composites/terraform-apply-op.ts b/lexicons/terraform/src/composites/terraform-apply-op.ts index e18ba30f9..b62f49a79 100644 --- a/lexicons/terraform/src/composites/terraform-apply-op.ts +++ b/lexicons/terraform/src/composites/terraform-apply-op.ts @@ -89,7 +89,7 @@ import { * cannot branch at build time on a count the plan only produces at run time. * The workable v1 is that `"on-destroy"` and `"always"` emit the same Gate * phase, and the approver is told what is at stake instead: the phase reports - * the plan's `destroys` count as the `Destroys` search attribute before the + * the plan's `destroys` count as the `Destroys` run outcome before the * gate is reached, and the gate description says so. A gate that skips itself * when the plan turns out additive is a new step kind, out of scope here. */ @@ -213,7 +213,7 @@ export function TerraformApplyOp(config: TerraformApplyOpConfig): TerraformApply description: config.gateDescription ?? `Approve terraform apply of ${live ? "live " : ""}root "${config.root}" (gate: ${gateMode}). ` + - `The Destroys search attribute on this phase is the plan's destroy count.` + + `The Destroys run outcome from this phase is the plan's destroy count.` + (live ? " The apply that follows re-plans against the live system and applies this plan file only" + " if its own fresh plan agrees; otherwise it refuses with exit status 3 and the Apply" + diff --git a/lexicons/terraform/src/composites/terraform-watch-op.test.ts b/lexicons/terraform/src/composites/terraform-watch-op.test.ts index 87a593c3f..73eed10a8 100644 --- a/lexicons/terraform/src/composites/terraform-watch-op.test.ts +++ b/lexicons/terraform/src/composites/terraform-watch-op.test.ts @@ -45,7 +45,7 @@ describe("TerraformWatchOp phases (#2087)", () => { } }); - test("the Plan step reports drift as a search attribute", () => { + test("the Plan step reports drift as a run outcome", () => { const plan = props({ name: "app-watch", root: "app" }).phases[1].steps[0] as ActivityStep; expect(plan.fn).toBe("terraformPlan"); expect(plan.id).toBe("plan"); diff --git a/lexicons/terraform/src/composites/terraform-watch-op.ts b/lexicons/terraform/src/composites/terraform-watch-op.ts index ea042e570..429f62948 100644 --- a/lexicons/terraform/src/composites/terraform-watch-op.ts +++ b/lexicons/terraform/src/composites/terraform-watch-op.ts @@ -7,7 +7,7 @@ * the plan found. `terraform plan -detailed-exitcode` is the whole mechanism. * Exit 0 is "the world matches the configuration", exit 2 is drift, and * `terraformPlan` turns that into the boolean `changed`, which rides out of - * the Op as the `Drift` search attribute. + * the Op as the run's `Drift` outcome. * * Phases: Init, Plan, and (for a finding mode that opens something) Report. * @@ -40,7 +40,7 @@ * `-detailed-exitcode` exit, `Drift` as before), how many live resources sit * at a declared identity carrying no marker (`Unowned`), and how many of those * an exact content match makes claimable (`Adoptable`). All three come off a - * single live read, published as three search attributes from the one step. + * single live read, published as three run outcomes from the one step. * * The finding modes then carry the adoption ledger under the plan text: one * line per adoptable match with its address, its live identity and the @@ -126,7 +126,7 @@ export interface TerraformWatchOpConfig { schedule?: string; /** * What to do when the plan proposes changes. Default: `"report"`, which - * opens nothing. The `Drift` search attribute and the run's own log are + * opens nothing. The `Drift` run outcome and the run's own log are * the report. */ findingMode?: TerraformFindingMode; diff --git a/lexicons/terraform/src/op/adoption.ts b/lexicons/terraform/src/op/adoption.ts index 5187690a3..5413d3278 100644 --- a/lexicons/terraform/src/op/adoption.ts +++ b/lexicons/terraform/src/op/adoption.ts @@ -19,7 +19,7 @@ * `-json` are refused together (`internal/command/live_plan.go`: "this run * cannot produce both reports at once"), and an Op that reports adoptables * needs the machine-readable document anyway — for the counts it publishes as - * search attributes, and for the addresses an adoption step acts on. So the + * outcome attributes, and for the addresses an adoption step acts on. So the * ledger below is rendered from the document that run already has, in the row * form `-adoption-only` prints, rather than paying for a third live read. * diff --git a/lexicons/terraform/src/skills/chant-terraform.md b/lexicons/terraform/src/skills/chant-terraform.md index d07f5bdf5..035ee5121 100644 --- a/lexicons/terraform/src/skills/chant-terraform.md +++ b/lexicons/terraform/src/skills/chant-terraform.md @@ -65,7 +65,7 @@ export const { op } = TerraformApplyOp({ }); ``` -`gate: "never"` drops the Gate phase, so `chant run` walks straight from Plan to Apply; any other mode emits a Gate phase, and a run that reaches an unapproved gate records a pending fact on the gate ledger, ends `gated` and exits 3 until someone runs `chant approve `. The Gate phase always shows the saved plan first and reports its `destroys` count as a `Destroys` search attribute, because `GateStep` carries no condition to branch on at build time — the approver sees what's at stake before approving, rather than the Op deciding for them. +`gate: "never"` drops the Gate phase, so `chant run` walks straight from Plan to Apply; any other mode emits a Gate phase, and a run that reaches an unapproved gate records a pending fact on the gate ledger, ends `gated` and exits 3 until someone runs `chant approve `. The Gate phase always shows the saved plan first and reports its `destroys` count as a `Destroys` run outcome, because `GateStep` carries no condition to branch on at build time — the approver sees what's at stake before approving, rather than the Op deciding for them. Terraform has no automatic rollback, so `compensate: true` with no command throws at build time, naming the Op, rather than warning once an apply has already half-run: diff --git a/packages/core/src/cli/commands/build.test.ts b/packages/core/src/cli/commands/build.test.ts index 67bab0f29..00faa835b 100644 --- a/packages/core/src/cli/commands/build.test.ts +++ b/packages/core/src/cli/commands/build.test.ts @@ -950,19 +950,21 @@ export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexi }); }); - test("op worker files go to /dist/ops even with no --output (#878)", async () => { - // The generated Op worker must land where its hosting runtime reads it - // (`/dist/ops//worker.ts`) even when the build has no --output - // (an Op-only project often has no primary resource manifest to route). + test("serializer files under ops/ go to /dist/ops even with no --output (#878)", async () => { + // Whatever a serializer emits under `ops/` must land in + // `/dist/ops//`, beside the `op.json` core writes there, + // even when the build has no --output (an Op-only project often has no + // primary resource manifest to route). The routing is on the `ops/` + // prefix alone — it does not know or care what the files are. const opSerializer: Serializer = { name: "multi", rulePrefix: "MULTI", serialize: () => ({ primary: "{}", files: { - "ops/durable-hello/workflow.ts": "// workflow\n", - "ops/durable-hello/worker.ts": "// worker\n", - "ops/durable-hello/activities.ts": "// activities\n", + "ops/nightly-report/steps.json": "{}\n", + "ops/nightly-report/notes.md": "# notes\n", + "ops/nightly-report/nested/extra.txt": "extra\n", }, }), }; @@ -979,8 +981,9 @@ export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexi } as BuildOptions); expect(result.errors).toEqual([]); - expect(existsSync(join(testDir, "dist", "ops", "durable-hello", "worker.ts"))).toBe(true); - expect(existsSync(join(testDir, "dist", "ops", "durable-hello", "workflow.ts"))).toBe(true); + expect(existsSync(join(testDir, "dist", "ops", "nightly-report", "steps.json"))).toBe(true); + expect(existsSync(join(testDir, "dist", "ops", "nightly-report", "notes.md"))).toBe(true); + expect(existsSync(join(testDir, "dist", "ops", "nightly-report", "nested", "extra.txt"))).toBe(true); }); test("an Op entity emits dist/ops//op.json from core, with no serializer involved (#2118)", async () => { diff --git a/packages/core/src/cli/commands/build.ts b/packages/core/src/cli/commands/build.ts index e4c2d01ec..a8df1067c 100644 --- a/packages/core/src/cli/commands/build.ts +++ b/packages/core/src/cli/commands/build.ts @@ -763,12 +763,12 @@ export async function buildCommand(options: BuildOptions): Promise } } - // Op worker artifacts (`ops//{workflow,worker,activities}.ts`) always - // go to `/dist/ops/` — the fixed location a hosting lexicon's - // worker is read from — independent of `--output`, which routes the primary - // resource manifest. Without this, a bare `chant build` only printed them to - // stderr and `--output foo.yaml` scattered them next to `foo.yaml`, so the - // worker was never where its runtime looks. + // Anything a serializer emits under `ops/` always goes to + // `/dist/ops/` — the fixed place an Op's build output is read + // from, alongside the `op.json` core writes below — independent of + // `--output`, which routes the primary resource manifest. Without this, a + // bare `chant build` only printed them to stderr and `--output foo.yaml` + // scattered them next to `foo.yaml`. const projectDist = resolve(options.path ?? ".", "dist"); let opsWritten = 0; for (const [filename, entry] of [...additionalFiles]) { @@ -782,13 +782,13 @@ export async function buildCommand(options: BuildOptions): Promise } catch (err) { errors.push( formatError({ - message: `Failed to write Op worker file ${filename}: ${err instanceof Error ? err.message : String(err)}`, + message: `Failed to write Op output file ${filename}: ${err instanceof Error ? err.message : String(err)}`, }), ); } } if (opsWritten > 0) { - console.error(formatInfo(`Wrote ${opsWritten} Op worker file(s) under ${join(options.path ?? ".", "dist", "ops")}/`)); + console.error(formatInfo(`Wrote ${opsWritten} Op output file(s) under ${join(options.path ?? ".", "dist", "ops")}/`)); } // `dist/ops//op.json` — the Op IR (#1289), written here by core diff --git a/packages/core/src/components/auto-release.ts b/packages/core/src/components/auto-release.ts index 0892d8733..ca4283cda 100644 --- a/packages/core/src/components/auto-release.ts +++ b/packages/core/src/components/auto-release.ts @@ -13,9 +13,10 @@ * is exactly the kind of side effect that doesn't belong in a step * dispatcher tests exercise against a bare `CapabilityRegistry`. * - A ledger write is a non-idempotent git push with real-world side - * effects (network, `Date.now()`, file writes) — the kind of thing a - * replay-safe hosted runtime must not do inline either. It happens in the - * CLI, once the run has reported a terminal outcome. + * effects (network, `Date.now()`, file writes) — the kind of thing that + * belongs at the edge of a run rather than inside a step a retry may run + * a second time. It happens in the CLI, once the run has reported a + * terminal outcome. * * Reuses ../lifecycle/release-ledger.ts's `appendReleaseRecord` verbatim — * this module only decides *whether* to call it and *what digest* to record, diff --git a/packages/core/src/components/cli-support.ts b/packages/core/src/components/cli-support.ts index d2db9edca..a5873d18a 100644 --- a/packages/core/src/components/cli-support.ts +++ b/packages/core/src/components/cli-support.ts @@ -431,8 +431,7 @@ export interface RunComponentsResult { * local in-process executor — the CLI entrypoint the driver never had (#585, * follow-up to #556). Discovers the full component set first, then either: * - `selector === "all"`: resolves the whole set's dependency order/waves - * and dispatches every component through `runInterpretDriver`, matching - * what the generated orchestrator Op will eventually do — the same + * and dispatches every component through `runInterpretDriver`, in the same * order `chant graph --components` reports. * - a single name: dispatches just that component via `runComponentDeploy`, * without requiring the rest of its `dependsOn` graph to be present in diff --git a/packages/core/src/components/component.schema.json b/packages/core/src/components/component.schema.json index dc0a1255c..9c3425b2b 100644 --- a/packages/core/src/components/component.schema.json +++ b/packages/core/src/components/component.schema.json @@ -170,7 +170,7 @@ "signalName": { "type": "string", "minLength": 1, - "description": "Signal name the generated workflow waits for before continuing." + "description": "The gate's name — what `chant approve ` resolves." }, "timeout": { "type": "string", diff --git a/packages/core/src/components/pilots/pilots-lint-clean.test.ts b/packages/core/src/components/pilots/pilots-lint-clean.test.ts index 73842caf1..162ffe3cd 100644 --- a/packages/core/src/components/pilots/pilots-lint-clean.test.ts +++ b/packages/core/src/components/pilots/pilots-lint-clean.test.ts @@ -58,11 +58,12 @@ describe("Real pilots lint clean under COMP* (#562 acceptance criteria)", () => // gate-as-fact, #2119) — a run reaching it stops there pending `chant // approve`, which is exactly the standing human wait COMP004 exists to // surface, so COMP004 firing here is the *correct*, expected signal, not - // a defect. A real project would - // acknowledge it with `// chant-disable-next-line COMP004 -- ` - // once authored as an actual `*.component.ts` file (see - // ../../lint/rules/comp/comp004-gate-needs-durable-runtime.ts and the - // comp004/pass fixture demonstrating that opt-out end to end). + // a defect. A real project would acknowledge it with a file-level + // `// chant-disable COMP004 -- ` once authored as an actual + // `*.component.ts` file — a COMP* diagnostic is reported for the whole + // component and carries no line, so `-line`/`-next-line` do not suppress + // one (see ../../lint/rules/comp/comp004-gate-needs-durable-runtime.ts + // and the comp004/pass fixture demonstrating that opt-out end to end). const ctx = contextFor([neo4jCluster]); const diagnostics = runAll(ctx); expect(diagnostics.every((d) => d.checkId === "COMP004")).toBe(true); diff --git a/packages/core/src/lint/rules/__fixtures__/comp/comp004/pass/orders-table.component.ts b/packages/core/src/lint/rules/__fixtures__/comp/comp004/pass/orders-table.component.ts index d24db75d9..315b3f85f 100644 --- a/packages/core/src/lint/rules/__fixtures__/comp/comp004/pass/orders-table.component.ts +++ b/packages/core/src/lint/rules/__fixtures__/comp/comp004/pass/orders-table.component.ts @@ -1,7 +1,7 @@ import type { Component } from "../../../../../../components/component"; import { phase } from "../../../../../../components/component"; -/** COMP004 pass case: no gate step anywhere — nothing requires the durable backend. */ +/** COMP004 pass case: no gate step anywhere — nothing here stops the run for a person. */ export const ordersTable: Component = { name: "orders-table", archetype: "infra", diff --git a/packages/core/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts b/packages/core/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts index 42dee7673..449b16861 100644 --- a/packages/core/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts +++ b/packages/core/src/lint/rules/comp/comp004-gate-needs-durable-runtime.ts @@ -2,6 +2,13 @@ * COMP004: gate-needs-durable-runtime (the file is renamed in #2116; the * behaviour changed in #2119) * + * The file name and the exported symbol keep the old wording on purpose + * (#2195): the id is what an author writes in a `chant-disable` comment and + * what every import names, and churning both buys nothing. `description` + * below is the accurate sentence, and it is what the docs page uses as its + * heading — since #2119 the rule has nothing to do with which runtime hosts + * the run. + * * Flags a `gate` step anywhere in a component's composition — the point where * a run stops and waits for a person. Since #2119 that is not a refusal: the * driver decides the gate against the gate ledger (`../../../op/gate.ts`), and diff --git a/packages/core/src/lint/rules/op/ops014-converge-rule-refusals.test.ts b/packages/core/src/lint/rules/op/ops014-converge-rule-refusals.test.ts index 77db21575..b6fe3923a 100644 --- a/packages/core/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +++ b/packages/core/src/lint/rules/op/ops014-converge-rule-refusals.test.ts @@ -241,7 +241,7 @@ describe("OPS014: converge-rule-refusals", () => { expect(ops014.check(ctx)).toHaveLength(0); }); - test("ignores an Op with no Converge search attribute", () => { + test("ignores an Op with no Converge label", () => { const ctx = makeCtxFromEntities(new Map([["op", readOnlyOpEntity("op")]])); expect(ops014.check(ctx)).toHaveLength(0); }); diff --git a/packages/core/src/op/activities/apply.ts b/packages/core/src/op/activities/apply.ts index 26d38e9fb..0dc22b5f5 100644 --- a/packages/core/src/op/activities/apply.ts +++ b/packages/core/src/op/activities/apply.ts @@ -91,9 +91,9 @@ export interface NativeApplyArgs { * core's `NormalizedApply` (#1446, collapsed here in #1449). The per-target * shapes this used to carry (`fieldManager`, `stackName`/`status`/`action`, * a separate `notPrunable`) belong to the appliers and stay in their lexicons; - * this activity's result is what a workflow can gate on regardless of target, - * and the target-specific detail still reaches the operator on the activity - * log. + * this activity's result is the shape every Op reads back regardless of + * target, and the target-specific detail still reaches the operator on the + * activity log. */ export interface NativeApplyResult { /** Resources the provider was called for and which converged — created, @@ -448,7 +448,7 @@ function collapseEnvelope(envelope: ApplyResult, label: string): NativeApplyResu ); // #1447: a resource the applier made no call for is reported, not dropped — // otherwise a partial apply reads as a full one. The per-resource reasons - // ride the envelope; the count is what the workflow can gate on. + // ride the envelope; the count is what a later step reads. if (n.notAttempted.length > 0) { console.warn(`[${label}] NOT attempted: ${n.notAttempted.length} resource(s)`); for (const skip of n.notAttempted) { diff --git a/packages/core/src/op/activities/converge.test.ts b/packages/core/src/op/activities/converge.test.ts index d74849c8c..9cc7dad98 100644 --- a/packages/core/src/op/activities/converge.test.ts +++ b/packages/core/src/op/activities/converge.test.ts @@ -192,7 +192,7 @@ describe("planConvergeTick — flap damping", () => { // ── Runtime backstop (Finding A, #1954 pre-merge review) ──────────────── // -// TMP014 (build time) is the primary defense against a mutating dispatch +// OPS014 (build time) is the primary defense against a mutating dispatch // escalating under "reconcile" or a destructive dispatch reaching any dial. // `verbClassAllowedToDispatch`/`enforceVerbClassAtDispatch` are the runtime // backstop `convergeTick` applies to every "ran" outcome just before diff --git a/packages/core/src/op/activities/converge.ts b/packages/core/src/op/activities/converge.ts index e7f8851ac..181f94cdb 100644 --- a/packages/core/src/op/activities/converge.ts +++ b/packages/core/src/op/activities/converge.ts @@ -1,5 +1,5 @@ /** - * `convergeTick` — the activity a `ConvergeOp`-generated workflow's Converge + * `convergeTick` — the activity a `ConvergeOp`-generated Op's Converge * phase runs (../../composites/converge-op.ts). One call is one tick: * observe (shell to `chant lifecycle plan`/`chant components status`, the * same CLI surface `reconcilePr`/`lifecycleDiff` already shell to — see @@ -57,7 +57,7 @@ import type { ComponentStatusRow } from "../../lifecycle/status"; const execAsync = promisify(exec); -/** A `ConvergeRule`, restated with `S = ConvergeSymptom` fixed — the JSON shape `ConvergeOp` bakes into the workflow's `convergeTick` step args. */ +/** A `ConvergeRule`, restated with `S = ConvergeSymptom` fixed — the JSON shape `ConvergeOp` bakes into the Op's `convergeTick` step args. */ export type SerializedConvergeRule = ConvergeRule; export interface ConvergeTickArgs { @@ -70,10 +70,10 @@ export interface ConvergeTickArgs { * (report-only); `"reconcile"` free-runs a read-only op but never a * mutating one — the issue's table answer for `reconcile` × mutating is * "open PR", which v1 doesn't implement (epic #1487's onDrift-channel open - * question), so `reconcile` refuses that dispatch (`TMP014`, build time) + * question), so `reconcile` refuses that dispatch (`OPS014`, build time) * rather than silently escalating it to "run directly"; `"apply"` is the * only dial that free-runs a mutating op. A destructive op is refused - * under every dial, `"apply"` included, in v1 — see `TMP014`'s doc on why + * under every dial, `"apply"` included, in v1 — see `OPS014`'s doc on why * "destructive + apply + gated" can never actually dispatch. */ dial: "observe" | "reconcile" | "apply"; @@ -202,7 +202,7 @@ async function dispatchOp( * Verb class allowed to free-run for a given dial — the coarse first-pass * gate: `"observe"` never dispatches anything (report only), `"reconcile"` * and `"apply"` both allow *some* dispatch. Which verb classes each of those - * two actually permits is `TMP014`'s job at build time + * two actually permits is `OPS014`'s job at build time * (`../../lint/post-synth/tmp014-converge-rule-refusals.ts`) and * {@link verbClassAllowedToDispatch}'s job as this tick's own runtime * backstop, below. @@ -213,7 +213,7 @@ function dialAllowsDispatch(dial: ConvergeTickArgs["dial"]): boolean { /** * Runtime backstop for issue #1484's Autonomy table (pre-merge review of - * #1954): `TMP014` already refuses, at build time, a rule table shaped to + * #1954): `OPS014` already refuses, at build time, a rule table shaped to * reach this point with a mutating dispatch outside `"apply"` or any * destructive dispatch at all. This is the defense-in-depth check for a rule * table that reached `convergeTick` without going through that build — the @@ -229,7 +229,7 @@ export function verbClassAllowedToDispatch(dial: ConvergeTickArgs["dial"], verbC const effective = verbClass ?? "mutating"; if (effective === "read-only") return true; if (effective === "mutating") return dial === "apply"; - return false; // destructive: refused at runtime, unconditionally — see TMP014's doc on why the gate can never actually run. + return false; // destructive: refused at runtime, unconditionally — see OPS014's doc on why the gate can never actually run. } /** @@ -251,7 +251,7 @@ export function enforceVerbClassAtDispatch( action: "reported", reason: `runtime backstop: dial "${dial}" does not permit dispatching "${outcome.op}" ` + - `(${verbClass ?? "unclassifiable — treated as mutating, fail-closed"}) — TMP014 should already refuse ` + + `(${verbClass ?? "unclassifiable — treated as mutating, fail-closed"}) — OPS014 should already refuse ` + `this at build; reporting instead of risking a silent authority escalation`, }; } @@ -372,7 +372,7 @@ export async function convergeTick(args: ConvergeTickArgs, signal?: AbortSignal) // Execute: only "ran" outcomes cause a subprocess dispatch. Before that // dispatch, the runtime backstop (Finding A, #1954 pre-merge review) // re-classifies the target and downgrades to "reported" if this dial/verb - // class pairing was never supposed to reach dispatch — TMP014 (build time) + // class pairing was never supposed to reach dispatch — OPS014 (build time) // is the primary defense; this is what catches a rule table that reached // `convergeTick` without going through it. for (const outcome of plan.outcomes) { diff --git a/packages/core/src/op/activities/env-teardown.ts b/packages/core/src/op/activities/env-teardown.ts index 6cf7f02a7..a620d7983 100644 --- a/packages/core/src/op/activities/env-teardown.ts +++ b/packages/core/src/op/activities/env-teardown.ts @@ -46,11 +46,11 @@ export interface EnvTeardownArgs { * the Op instead. */ confirmProd?: boolean; - /** Path to the chant project (where chant.config.ts lives). Default: the worker's cwd. */ + /** Path to the chant project (where chant.config.ts lives). Default: the chant process's cwd. */ path?: string; } -/** What a completed (fully successful) teardown reports back to the workflow. */ +/** What a completed (fully successful) teardown reports back to the run. */ export interface EnvTeardownResult { environment: string; /** The ownership stack everything was selected on. */ @@ -150,8 +150,8 @@ export async function envTeardown( ); // Failures that survived core's bounded retry pass fail the activity — - // silence is never success, and the workflow decides what a failed - // teardown means for the run. + // silence is never success. A failed step is what the executor aborts the + // phase on and what the Op's `onFailure` compensation runs for. if (failed.length > 0) { const names = failed.map((o) => `${o.lexicon}/${o.name}${o.detail ? ` (${o.detail})` : ""}`).join(", "); throw new Error( diff --git a/packages/core/src/op/activities/lifecycle.ts b/packages/core/src/op/activities/lifecycle.ts index 1418e1f6d..c2acde964 100644 --- a/packages/core/src/op/activities/lifecycle.ts +++ b/packages/core/src/op/activities/lifecycle.ts @@ -61,13 +61,13 @@ function detectDrift(output: string): boolean { /** * Run `chant lifecycle diff ` and return the output + structured drift - * flag. Read-only; intended for use inside watch/observation workflows. + * flag. Read-only; intended for use inside watch/observation Ops. * Uses fastIdempotent profile. * * The `drifted` field is computed by scanning the output for any of the * MISSING / ORPHAN / DRIFTED / DISAPPEARED section headers documented in * cli/state.mdx. Pair with `outcomeAttribute: { name: "Drift", from: "drifted" }` - * on a WatchOp activity step to surface drift as a workflow search attribute. + * on a WatchOp activity step to surface drift as the run's `Drift` outcome. */ export async function lifecycleDiff(args: LifecycleDiffArgs, signal?: AbortSignal): Promise { const liveFlag = args.live ? " --live" : ""; diff --git a/packages/core/src/op/activities/reconcile.ts b/packages/core/src/op/activities/reconcile.ts index 5f55ccd5f..f3360312d 100644 --- a/packages/core/src/op/activities/reconcile.ts +++ b/packages/core/src/op/activities/reconcile.ts @@ -21,8 +21,8 @@ export interface ReconcilePrArgs { env: string; /** * The change-set entries that triggered this reconcile. Omit to derive them - * from `chant lifecycle plan --json` at run time — the form used inside a - * workflow, where the entries aren't known until the activity runs. + * from `chant lifecycle plan --json` at run time — the form used inside + * an Op, where the entries aren't known until the activity runs. */ entries?: ReconcileEntry[]; /** What to produce. Default: pull-request. */ diff --git a/packages/core/src/op/builders.ts b/packages/core/src/op/builders.ts index f7103a1d7..23f90b4c3 100644 --- a/packages/core/src/op/builders.ts +++ b/packages/core/src/op/builders.ts @@ -368,8 +368,8 @@ export const teardown = (path: string, opts?: { id?: string }): NamedActivitySte * ]), * ``` * - * `opts` also accepts `path` (the chant project directory, default the - * worker's cwd). Defaults to the `longInfra` profile (override via + * `opts` also accepts `path` (the chant project directory, default the chant + * process's cwd). Defaults to the `longInfra` profile (override via * `opts.profile`). */ export const envTeardown = ( @@ -871,8 +871,8 @@ export const spritesDown = (args: { /** * Gate an apply on organizational policy: build the project and run its - * `lint.policies` over the resolved resources, blocking the workflow on any - * violation. Place it before the apply phase. `env` (or `ownership.env`) lets a + * `lint.policies` over the resolved resources, failing the step on any + * violation so no later phase runs. Place it before the apply phase. `env` (or `ownership.env`) lets a * policy branch on environment. Single-attempt (`policyCheck` profile) — a * deterministic violation is not retried. */ diff --git a/packages/core/src/op/composites/apply-op.ts b/packages/core/src/op/composites/apply-op.ts index 6f5ae7f7d..3b93f7835 100644 --- a/packages/core/src/op/composites/apply-op.ts +++ b/packages/core/src/op/composites/apply-op.ts @@ -70,8 +70,8 @@ export interface ApplyOpConfig { /** * Approval gate before the apply. Implied when `delete: "gated"`; may also be * set explicitly. Omit `signalName` to default to `approve-`. The gate - * is resolved by `chant approve` on the ledger (#2119), not by a signal to a - * running workflow. + * is resolved by `chant approve` on the ledger (#2119), which the next run + * reads; nothing waits in the meantime. */ gate?: { signalName?: string; timeout?: string; description?: string }; /** @@ -104,7 +104,7 @@ export interface ApplyOpConfig { } export interface ApplyOpResources { - /** Op resource — generates the build→plan→[approve]→apply workflow. */ + /** Op resource — the build→plan→[approve]→apply Op. */ op: InstanceType; } diff --git a/packages/core/src/op/composites/composites.test.ts b/packages/core/src/op/composites/composites.test.ts index 9a759f5bc..29421d48c 100644 --- a/packages/core/src/op/composites/composites.test.ts +++ b/packages/core/src/op/composites/composites.test.ts @@ -50,7 +50,7 @@ describe("WatchOp: shape", () => { expect(getProps(op).schedule).toBeUndefined(); }); - test("an invalid cron is refused at construction with TMP010's wording", () => { + test("an invalid cron is refused at construction with cronSyntaxMessage's wording", () => { expect(() => WatchOp({ name: "prod-watch", env: "prod", schedule: "every 15 minutes" })).toThrow( /does not look like valid 5- or 6-field cron syntax/, ); @@ -68,11 +68,11 @@ describe("WatchOp: configuration", () => { expect(diffStep.fn).toBe("lifecycleDiff"); expect(snapStep.args).toEqual({ env: "prod" }); expect(diffStep.args).toEqual({ env: "prod", live: true }); - // Drift is surfaced as a workflow search attribute via outcomeAttribute (#41) + // Drift is surfaced as a run outcome via outcomeAttribute (#41) expect(diffStep.outcomeAttribute).toEqual({ name: "Drift", from: "drifted" }); }); - test("auto-emit search attrs include Watch + Env", () => { + test("labels include Watch + Env", () => { const { op } = WatchOp({ name: "p", env: "prod", schedule: "* * * * *" }); expect(getProps(op).labels).toEqual({ Watch: "true", Env: "prod" }); }); @@ -125,12 +125,12 @@ describe("ReconcileOp: configuration", () => { expect(reconcileStep.args).toEqual({ env: "prod", mode: "issue", owned: true }); }); - test("auto-emit search attrs include Reconcile + Env", () => { + test("labels include Reconcile + Env", () => { const { op } = ReconcileOp({ name: "p", env: "prod" }); expect(getProps(op).labels).toEqual({ Reconcile: "true", Env: "prod" }); }); - test("Plan phase surfaces Drift as a search attribute", () => { + test("Plan phase surfaces Drift as a run outcome", () => { const { op } = ReconcileOp({ name: "p", env: "prod" }); const phases = getProps(op).phases as Array>; const diffStep = (phases[1].steps as Array>)[0]; @@ -187,7 +187,7 @@ describe("ApplyOp: gating + deletes", () => { expect((applyStep.args as Record).deleteMode).toBe("owned-only"); }); - test("auto-emit search attrs include Apply + Env", () => { + test("labels include Apply + Env", () => { const { op } = ApplyOp({ name: "p", env: "prod" }); expect(getProps(op).labels).toEqual({ Apply: "true", Env: "prod" }); }); @@ -351,7 +351,7 @@ describe("WatchOp: receipt staleness (#1834)", () => { }, ], }); - // Staleness surfaces as a workflow search attribute, like Drift. + // Staleness surfaces as a run outcome, like Drift. expect(step.outcomeAttribute).toEqual({ name: "StaleReceipts", from: "stale" }); }); diff --git a/packages/core/src/op/composites/converge-op.test.ts b/packages/core/src/op/composites/converge-op.test.ts index 948cd0f7b..562a0aa26 100644 --- a/packages/core/src/op/composites/converge-op.test.ts +++ b/packages/core/src/op/composites/converge-op.test.ts @@ -83,7 +83,7 @@ describe("ConvergeOp composite (#1484)", () => { expect(props(op).schedule?.overlap).toBe("skip"); }); - test("an invalid cron is refused at construction, with TMP010's wording (#2120)", () => { + test("an invalid cron is refused at construction, with cronSyntaxMessage's wording (#2120)", () => { expect(() => ConvergeOp({ name: "staging-converge", env: "staging", rules: [driftRule], schedule: "every ten minutes" }), ).toThrow(/does not look like valid 5- or 6-field cron syntax/); diff --git a/packages/core/src/op/composites/converge-op.ts b/packages/core/src/op/composites/converge-op.ts index 4262ed77c..c4b6a2afa 100644 --- a/packages/core/src/op/composites/converge-op.ts +++ b/packages/core/src/op/composites/converge-op.ts @@ -71,8 +71,8 @@ import { CONVERGE_SYMPTOM_FIELDS } from "../../lifecycle/symptoms"; * | verb class | observe | reconcile | apply | * |-------------|-------------|-------------------------------------|--------------------------------------| * | read-only | free-run | free-run | free-run | - * | mutating | report only | **refused at build (TMP014)** | run | - * | destructive | refused | refused | **refused at build (TMP014), v1** | + * | mutating | report only | **refused at build (OPS014)** | run | + * | destructive | refused | refused | **refused at build (OPS014), v1** | * * - **`reconcile` × mutating is "open PR" in the issue's table — not yet * implemented.** Building that channel (reusing `ReconcileOp`'s @@ -80,7 +80,7 @@ import { CONVERGE_SYMPTOM_FIELDS } from "../../lifecycle/symptoms"; * `../converge-rule.ts`'s `ReportAction` doc and epic #1487's * onDrift-channel open question. Until it exists, a rule that would * dispatch a mutating op under `reconcile` is refused at build - * (`TMP014`), not silently escalated to "run directly" the way `apply` + * (`OPS014`), not silently escalated to "run directly" the way `apply` * would. A runtime backstop in `convergeTick` * (`../activities/converge.ts`) re-checks the same thing at dispatch * time, for a rule table that reached the tick without going through that @@ -97,7 +97,7 @@ import { CONVERGE_SYMPTOM_FIELDS } from "../../lifecycle/symptoms"; export type ConvergeDial = "observe" | "reconcile" | "apply"; export interface ConvergeOpConfig { - /** Op name (kebab-case). Also the generated workflow function name, camelCased. */ + /** Op name (kebab-case). Names the Op's output directory and is what `chant run` takes. */ name: string; /** Environment to converge (e.g. "staging"). */ env: string; @@ -119,7 +119,7 @@ export interface ConvergeOpConfig { } export interface ConvergeOpResources { - /** Op resource — generates the observe->converge workflow. */ + /** Op resource — the observe-then-converge Op. */ op: InstanceType; } @@ -173,9 +173,9 @@ export function ConvergeOp(config: ConvergeOpConfig): ConvergeOpResources { // predicates, a discriminated action union) for a hand-mirrored zod // contract to earn its keep, the same "deliberately partial" call // activity-contracts.ts already makes for kubectlApply/helmInstall's - // multi-field args. TMP012 skips an uncontracted `fn`, not flags it — see + // multi-field args. OPS012 skips an uncontracted `fn`, not flags it — see // that module's doc. The `preflightDrift` step-output reference below is - // still fully validated: TMP013's producer-side checks only need + // still fully validated: OPS013's producer-side checks only need // `lifecycleDiff`'s own contract, which is registered. const tickStep = activity( "convergeTick", diff --git a/packages/core/src/op/composites/lexicon-upgrade-op.test.ts b/packages/core/src/op/composites/lexicon-upgrade-op.test.ts index 8f722ba61..cb5300e68 100644 --- a/packages/core/src/op/composites/lexicon-upgrade-op.test.ts +++ b/packages/core/src/op/composites/lexicon-upgrade-op.test.ts @@ -42,7 +42,7 @@ describe("LexiconUpgradeOp composite (#527)", () => { expect(config.schedule).toEqual({ cron: "0 6 * * 1", overlap: "skip" }); }); - test("surfaces HasUpgrade as an outcome search attribute", () => { + test("surfaces HasUpgrade as a run outcome attribute", () => { const { op } = LexiconUpgradeOp({ lexicon: "docker" }); const config = (op as unknown as { props: Record }).props; const phases = config.phases as Array<{ steps: Array<{ outcomeAttribute?: { name: string; from: string } }> }>; diff --git a/packages/core/src/op/composites/lexicon-upgrade-op.ts b/packages/core/src/op/composites/lexicon-upgrade-op.ts index c1dff97fd..417a0c29c 100644 --- a/packages/core/src/op/composites/lexicon-upgrade-op.ts +++ b/packages/core/src/op/composites/lexicon-upgrade-op.ts @@ -60,8 +60,8 @@ export interface LexiconUpgradeOpConfig { /** Which in-scope lexicon to check. */ lexicon: SupportedLexicon; /** - * Op name (kebab-case). Also the generated workflow function name, - * camelCased. Defaults to `-upgrade`. + * Op name (kebab-case). Names the Op's output directory and is what `chant + * run` takes. Defaults to `-upgrade`. */ name?: string; /** @@ -82,7 +82,7 @@ export interface LexiconUpgradeOpConfig { } export interface LexiconUpgradeOpResources { - /** Op resource — generates the upgrade workflow on `chant build`. */ + /** Op resource — the upgrade Op, emitted on `chant build`. */ op: InstanceType; } @@ -121,9 +121,9 @@ export function LexiconUpgradeOp(config: LexiconUpgradeOpConfig): LexiconUpgrade ...(config.lexiconDir ? { lexiconDir: config.lexiconDir } : {}), mode: onFinding, }, - // Surface whether a PR-worthy upgrade was found as a workflow-level - // search attribute so "show me lexicons with an upgrade ready" is a - // one-filter query. + // Surface whether a PR-worthy upgrade was found as the run's + // `HasUpgrade` outcome on the run ledger, so a reader can pick out + // the lexicons with an upgrade ready. outcomeAttribute: { name: "HasUpgrade", from: "hasUpgrade" }, }, ]), diff --git a/packages/core/src/op/composites/pipeline-audit-op.ts b/packages/core/src/op/composites/pipeline-audit-op.ts index e09070fe5..146ce9f21 100644 --- a/packages/core/src/op/composites/pipeline-audit-op.ts +++ b/packages/core/src/op/composites/pipeline-audit-op.ts @@ -30,7 +30,7 @@ import type { OpResource } from "../resource"; import type { PipelineAuditMode } from "../activities/pipeline-audit"; export interface PipelineAuditOpConfig { - /** Op name (kebab-case). Also the generated workflow function name, camelCased. */ + /** Op name (kebab-case). Names the Op's output directory and is what `chant run` takes. */ name: string; /** Cron expression. When set, it lands on the Op as `schedule`; omit for one-shot. */ schedule?: string; @@ -47,7 +47,7 @@ export interface PipelineAuditOpConfig { } export interface PipelineAuditOpResources { - /** Op resource — generates the audit workflow on `chant build`. */ + /** Op resource — the audit Op, emitted on `chant build`. */ op: InstanceType; } diff --git a/packages/core/src/op/composites/reconcile-op.ts b/packages/core/src/op/composites/reconcile-op.ts index 9953d0828..e2de5f075 100644 --- a/packages/core/src/op/composites/reconcile-op.ts +++ b/packages/core/src/op/composites/reconcile-op.ts @@ -33,7 +33,7 @@ import type { OpResource } from "../resource"; import type { ReconcileMode } from "../activities/reconcile"; export interface ReconcileOpConfig { - /** Op name (kebab-case). Also the generated workflow function name, camelCased. */ + /** Op name (kebab-case). Names the Op's output directory and is what `chant run` takes. */ name: string; /** Environment to reconcile (e.g. "prod"). */ env: string; @@ -52,7 +52,7 @@ export interface ReconcileOpConfig { } export interface ReconcileOpResources { - /** Op resource — generates the snapshot→plan→regenerate→PR workflow. */ + /** Op resource — the snapshot→plan→regenerate→PR Op. */ op: InstanceType; } diff --git a/packages/core/src/op/composites/watch-op.ts b/packages/core/src/op/composites/watch-op.ts index 93d46a55e..976d060f4 100644 --- a/packages/core/src/op/composites/watch-op.ts +++ b/packages/core/src/op/composites/watch-op.ts @@ -2,8 +2,8 @@ * WatchOp composite — periodic state observation as an Op. * * Composes existing pieces: - * - The Op codegen (#7) emits a workflow that runs phases sequentially - * - The auto-emit search-attribute behavior (#28) tags each phase + * - The local executor (`../local-executor.ts`) runs the phases in order and + * files each step's record under the phase it belongs to * - The pre-built lifecycleSnapshot + lifecycleDiff activities * - `schedule` puts the cadence on the Op itself (#2120) * @@ -29,7 +29,7 @@ import type { OpResource } from "../resource"; import type { EffectReceiptDeclaration } from "../../effect-receipt"; export interface WatchOpConfig { - /** Op name (kebab-case). Also the generated workflow function name, camelCased. */ + /** Op name (kebab-case). Names the Op's output directory and is what `chant run` takes. */ name: string; /** Environment to snapshot + diff (e.g. "prod"). */ env: string; @@ -58,7 +58,7 @@ export interface WatchOpConfig { } export interface WatchOpResources { - /** Op resource — generates the snapshot+diff workflow on `chant build`. */ + /** Op resource — the snapshot+diff Op, emitted on `chant build`. */ op: InstanceType; } @@ -76,9 +76,9 @@ export function WatchOp(config: WatchOpConfig): WatchOpResources { phases: [ phase("Snapshot", [activity("lifecycleSnapshot", { env: config.env })]), phase("Diff", [ - // outcomeAttribute surfaces lifecycleDiff's `drifted` boolean as a - // workflow-level Drift search attribute, making 'show me runs that - // detected drift' a one-filter UI query. + // outcomeAttribute surfaces lifecycleDiff's `drifted` boolean as the + // run's `Drift` outcome on the run ledger, so a reader of the ledger + // can pick out the runs that detected drift. { kind: "activity", fn: "lifecycleDiff", @@ -87,8 +87,8 @@ export function WatchOp(config: WatchOpConfig): WatchOpResources { }, ]), // Receipt staleness (#1834): read-only over the receipt store — absent - // or differing receipts surface as findings (and a StaleReceipts search - // attribute); nothing runs and nothing is written. + // or differing receipts surface as findings (and a StaleReceipts run + // outcome); nothing runs and nothing is written. ...(config.receipts && config.receipts.length > 0 ? [ phase("Receipts", [ diff --git a/packages/core/src/op/composites/workflow-audit-op.ts b/packages/core/src/op/composites/workflow-audit-op.ts index 714e6c07f..349bb1214 100644 --- a/packages/core/src/op/composites/workflow-audit-op.ts +++ b/packages/core/src/op/composites/workflow-audit-op.ts @@ -34,7 +34,7 @@ import type { OpResource } from "../resource"; import type { WorkflowAuditMode } from "../activities/workflow-audit"; export interface WorkflowAuditOpConfig { - /** Op name (kebab-case). Also the generated workflow function name, camelCased. */ + /** Op name (kebab-case). Names the Op's output directory and is what `chant run` takes. */ name: string; /** * Cron expression. When set, it lands on the Op as `schedule` for @@ -75,8 +75,9 @@ export function WorkflowAuditOp(config: WorkflowAuditOpConfig): WorkflowAuditOpR kind: "activity", fn: "workflowSupplyChainAudit", args: { workflowsDir: config.workflowsDir ?? ".github/workflows", mode: onFinding }, - // Surface the finding count as a workflow-level search attribute so - // "show me audits that found drift" is a one-filter UI query. + // Surface the finding count as the run's `Findings` outcome on the + // run ledger, so a reader can pick out the audits that found + // something. outcomeAttribute: { name: "Findings", from: "findings" }, }, ]), diff --git a/packages/core/src/op/cron.test.ts b/packages/core/src/op/cron.test.ts index 91dc1d787..a344feb77 100644 --- a/packages/core/src/op/cron.test.ts +++ b/packages/core/src/op/cron.test.ts @@ -11,7 +11,7 @@ function at(y: number, m: number, d: number, h: number, min: number): Date { return new Date(y, m - 1, d, h, min, 0, 0); } -describe("isValidCronExpression — TMP010's parser", () => { +describe("isValidCronExpression — the shared parser", () => { test("accepts 5- and 6-field expressions", () => { expect(isValidCronExpression("*/10 * * * *")).toBe(true); expect(isValidCronExpression("0 6 * * 1")).toBe(true); @@ -32,7 +32,7 @@ describe("isValidCronExpression — TMP010's parser", () => { expect(isValidCronExpression(" 0 6 * * * ")).toBe(true); }); - test("the message names the expression, so the Op refusal and TMP010 read alike", () => { + test("the message names the expression, so the Op refusal and a lexicon's cron check read alike", () => { expect(cronSyntaxMessage("nope")).toBe( 'cron expression "nope" does not look like valid 5- or 6-field cron syntax', ); diff --git a/packages/core/src/op/cron.ts b/packages/core/src/op/cron.ts index 178e9f42a..472f0d672 100644 --- a/packages/core/src/op/cron.ts +++ b/packages/core/src/op/cron.ts @@ -34,8 +34,9 @@ export function isValidCronExpression(expr: string): boolean { } /** - * The message TMP010 reports and `Op()` throws with, so one wording covers - * both the build-time refusal and the post-synth warning. + * The message `Op()` throws with and a lexicon's post-synth cron check + * reports, so one wording covers both the build-time refusal and the + * post-synth diagnostic. */ export function cronSyntaxMessage(expr: string): string { return `cron expression "${expr}" does not look like valid 5- or 6-field cron syntax`; diff --git a/packages/core/src/op/local-executor.ts b/packages/core/src/op/local-executor.ts index f41623f68..05e25bd69 100644 --- a/packages/core/src/op/local-executor.ts +++ b/packages/core/src/op/local-executor.ts @@ -47,12 +47,12 @@ export interface StepRecord { status: "ok" | "fail" | "skipped"; durationMs: number; /** - * The first search attribute the step published, kept singular because a + * The first outcome attribute the step published, kept singular because a * step publishing one is the ordinary case and every reader of this field * predates the plural form. {@link StepRecord.outcomes} is the whole list. */ outcome?: { name: string; value: unknown }; - /** Every search attribute the step published, in authored order (#2105). Absent when it published none. */ + /** Every outcome attribute the step published, in authored order (#2105). Absent when it published none. */ outcomes?: Array<{ name: string; value: unknown }>; error?: string; /** Set on a `gate` step that passed (#2119): who resolved it, when, and at what address. */ diff --git a/packages/core/src/op/op-ir.ts b/packages/core/src/op/op-ir.ts index 2ca436a79..68061dd1f 100644 --- a/packages/core/src/op/op-ir.ts +++ b/packages/core/src/op/op-ir.ts @@ -57,8 +57,8 @@ * * A step's `args` may hold a {@link StepOutputRef} (`./step-output-ref.ts`) * anywhere in its structure — a placeholder for a - * prior step's declared return value, resolved by the serializer into a - * local variable in the generated `workflow.ts`. This module does not + * prior step's declared return value, which the executor substitutes for at + * run time from the producing step's record. This module does not * special-case it: `irActivityStep` copies `step.args` through as-is (same as * every other value), and `JSON.stringify` — both the literal one in * `serializeOpIR` and the structural-equality one `z.toJSONSchema`-adjacent diff --git a/packages/core/src/op/op-verb-class.ts b/packages/core/src/op/op-verb-class.ts index 56831e134..2ad54501f 100644 --- a/packages/core/src/op/op-verb-class.ts +++ b/packages/core/src/op/op-verb-class.ts @@ -115,7 +115,7 @@ export function classifyOpVerbClass(config: Pick): boolean { const isGate = (s: StepDefinition): s is GateStep => s.kind === "gate"; diff --git a/packages/core/src/op/op.test.ts b/packages/core/src/op/op.test.ts index f3bf6c13f..ae71f5562 100644 --- a/packages/core/src/op/op.test.ts +++ b/packages/core/src/op/op.test.ts @@ -53,7 +53,7 @@ describe("Op()", () => { expect(opProps(op).schedule).toEqual({ cron: "0 6 * * *", overlap: "skip" }); }); - it("refuses an invalid cron at construction, with TMP010's message (#2120)", () => { + it("refuses an invalid cron at construction, with cronSyntaxMessage's wording (#2120)", () => { expect(() => Op({ name: "nightly", overview: "o", phases: [], schedule: { cron: "0 6 * *" } })).toThrow( 'Op "nightly": cron expression "0 6 * *" does not look like valid 5- or 6-field cron syntax', ); diff --git a/packages/core/src/op/receipt-store.ts b/packages/core/src/op/receipt-store.ts index 22767d0bf..8abab2ff4 100644 --- a/packages/core/src/op/receipt-store.ts +++ b/packages/core/src/op/receipt-store.ts @@ -108,7 +108,7 @@ export interface ReceiptReadArgs extends ReceiptCheckInput {} export interface ReceiptReadResult { /** The receipt's live stored value, or null when absent. */ current: string | null; - /** The resolved expectation the workflow compares and later writes. */ + /** The resolved expectation the effect step compares and later writes. */ expectation: string; /** Convenience: `current === expectation`. */ applied: boolean; diff --git a/packages/core/src/op/step-output-ref.ts b/packages/core/src/op/step-output-ref.ts index 147684ff1..690a2c2ed 100644 --- a/packages/core/src/op/step-output-ref.ts +++ b/packages/core/src/op/step-output-ref.ts @@ -11,8 +11,10 @@ * value instead. * * Deliberately a reference, not an expression: `diff.out.driftedStacks` is - * a value placeholder the build resolves and the serializer compiles into a - * real local variable in the generated workflow — never `diff.out.count > + * a value placeholder that stays inert in the Op's data until the executor + * substitutes the producing step's recorded result for it, just before the + * consuming activity is called (`../op/local-executor.ts`'s + * `resolveStepOutputRefs` over `resultsById`) — never `diff.out.count > * 0` or a `.map()` over a reference. That property (an Op is data you can * read and know what it does, not a program) is exactly what makes it safe * to add this without Ops becoming programs; see the issue's "line not to diff --git a/packages/core/src/op/types.ts b/packages/core/src/op/types.ts index 520c1bf93..d1f71359a 100644 --- a/packages/core/src/op/types.ts +++ b/packages/core/src/op/types.ts @@ -9,7 +9,7 @@ import type { EffectReceiptRef } from "./receipt-store"; import type { ActivityProfileName } from "./activity-profiles"; export interface OpConfig { - /** Kebab-case identifier. Used as the workflow function name (camelCase) and output directory name. */ + /** Kebab-case identifier. Names the Op's output directory (`dist/ops//`), and is the name `chant run ` and another Op's `depends` refer to. */ name: string; /** Human-readable description shown in `chant run list` and deployment reports. */ overview: string; @@ -44,7 +44,8 @@ export interface OpConfig { /** * An Op's cadence (#2120). The cron is validated at `Op()` construction by - * the same permissive 5-/6-field parser TMP010 uses (`./cron.ts`). + * the permissive 5-/6-field parser in `./cron.ts`, the same one a lexicon's + * own post-synth cron check imports. */ export interface OpSchedule { /** 5- or 6-field cron expression, read in the running host's local time. */ @@ -165,7 +166,7 @@ export interface EffectStep { * nest. */ steps: Array; - /** Annotation carried into the generated workflow as a comment. */ + /** Annotation carried into the Op's build output as a comment. */ description?: string; } diff --git a/test/forgejo-preview-e2e.sh b/test/forgejo-preview-e2e.sh index 9fa4b8ad7..8b56042d0 100755 --- a/test/forgejo-preview-e2e.sh +++ b/test/forgejo-preview-e2e.sh @@ -10,8 +10,8 @@ set -euo pipefail # `== 'closed'`. This script: # # 1. builds the workflow (`.forgejo/workflows/preview.yml`) from ./src, -# 2. packs @intentius/chant + the fly and temporal lexicons into tarballs -# and assembles a consuming chant project around them, +# 2. packs @intentius/chant and the fly lexicon into tarballs and assembles +# a consuming chant project around them, # 3. boots mudflaps (the Fly Machines API emulator) on a dedicated Docker # network, reachable as `http://mudflaps:4280` from job containers, # 4. drives the workflow twice through a runner with stubbed PR event diff --git a/test/forgejo-preview-e2e/README.md b/test/forgejo-preview-e2e/README.md index fbca6415c..9fcf674b4 100644 --- a/test/forgejo-preview-e2e/README.md +++ b/test/forgejo-preview-e2e/README.md @@ -18,7 +18,7 @@ teardown pr- --yes` (#1222) sweeps exactly that. `../forgejo-preview-e2e.sh`: 1. builds `.forgejo/workflows/preview.yml` from `src/`, -2. packs core + fly + temporal into tarballs the jobs `npm install`, +2. packs core + fly into tarballs the jobs `npm install`, 3. boots mudflaps (Fly Machines emulator) on a Docker network the job containers share (`http://mudflaps:4280`), 4. runs the workflow twice with stubbed `pull_request` payloads diff --git a/test/no-egress.test.ts b/test/no-egress.test.ts index f16413659..60865b01e 100644 --- a/test/no-egress.test.ts +++ b/test/no-egress.test.ts @@ -272,10 +272,9 @@ const report: PhaseReport[] = []; * * Each of those is one test doing a hundred-odd builds or lints — a batch, not * a unit — and they are among the heaviest tests in the suite. Standalone they - * take two to four seconds; on a saturated run (the fourteen-fork pool, the - * temporal suites bundling workflows with webpack in-process) they have been - * seen past the file's 20s default and reported as a timeout, which reads as a - * network violation to anyone skimming CI. The generous ceiling is not masking + * take two to four seconds; sharing a machine with the rest of the fork pool + * they have been seen past the file's 20s default and reported as a timeout, + * which reads as a network violation to anyone skimming CI. The generous ceiling is not masking * a hang: a guarded phase that genuinely blocks on a socket throws at the call * site rather than waiting, because the guard never lets a connection open. */ diff --git a/vitest.config.ts b/vitest.config.ts index d1d1848a6..da5fc1af9 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -108,10 +108,11 @@ export default defineConfig({ // Vitest 4 moved the per-worker node flags from poolOptions.forks.execArgv // to this top-level key; the default pool is still forks. execArgv: [`--max-old-space-size=${forkHeapMb}`], - // The Temporal runtime/compile-smoke suites bundle workflows with webpack - // in-process, which loads the CI runner enough to push short-timeout tests - // (e.g. build.test.ts discovery) past the 5s default under contention. - // 20s absorbs that without masking a genuinely hung test for long. + // 20s rather than the 5s default. The heaviest tests here do real work on + // disk — build.test.ts's discovery, the CLI end-to-end cases — and sharing + // a machine with the rest of the fork pool they have been seen past 5s + // while still making progress. 20s absorbs that without masking a + // genuinely hung test for long. testTimeout: 20_000, }, }); From 71fe1914ca941b28c2efbdde4f2578adcf0cf764 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 6 Sep 2026 23:26:04 -0600 Subject: [PATCH 2/2] chore(op,lexicons): delete safeHeartbeat and its callers, FTN020 imports core's cron validator Sub-issue 4 of #2191, the dead-code half. `safeHeartbeat` was a no-op kept alive so the lexicons calling it would keep compiling. Forty-four call sites across core, aws, azure, fly, gcp, gitlab, helm, k3d, k3s, k8s, render and terraform are gone, and so is the export. Five of them ran a live 15 second `setInterval` for the length of a shell-out (helm's two install paths, k3s install, k3d cluster create, and terraform's `withHeartbeat` wrapper around seven calls); the timers and their `clearInterval` plumbing go with them, since nothing was listening and nothing acted on a missed tick. FTN020 now imports core's `isValidCronExpression` rather than carrying a divergent copy of it, and refuses the `@daily`-style nicknames the copy used to accept. chant validates and matches one cron notion: `cronMatches` cannot evaluate a nickname, so a cadence written that way is one chant itself would never fire. The rule doc, its audit-catalog guidance, the fountain resources page and the test record the decision. The four CI lexicon pipeline tests stop teaching `--temporal` as a sample `runCommand` (it exits 2) and use `--json` or `--verbose`, and `run.test.ts`'s `makeOp` drops the `taskQueue?` field `OpConfig` has not had since #2118. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0148a4dPTErCBpmNy7J7933f --- .../content/docs/lint-rules/audit-rules.mdx | 4 +- lexicons/aws/src/op/activities/aws-apply.ts | 4 +- lexicons/azure/src/op/activities/az-apply.ts | 4 - lexicons/fly/src/op/activities/fly-apply.ts | 11 +- lexicons/fountain/docs/pages/resources.mdx | 6 +- .../docs/src/content/docs/resources.mdx | 6 +- .../fountain/docs/src/content/docs/rules.mdx | 2 +- lexicons/fountain/src/lint/audit-catalog.ts | 6 +- .../post-synth/ftn020-schedule-cron-syntax.ts | 43 ++--- .../src/lint/post-synth/post-synth.test.ts | 13 +- lexicons/gcp/src/op/activities/gcp-apply.ts | 6 +- .../components/generate-op-pipeline.test.ts | 4 +- .../src/components/generate-pipeline.test.ts | 4 +- .../components/generate-op-pipeline.test.ts | 4 +- .../src/components/generate-pipeline.test.ts | 4 +- lexicons/gitlab/src/op/activities/gitlab.ts | 3 +- lexicons/helm/src/op/activities/helm.ts | 19 +- lexicons/k3d/src/op/activities/k3d.ts | 15 +- lexicons/k3s/src/op/activities/k3s.ts | 21 +- lexicons/k8s/src/op/activities/kubectl.ts | 105 +++++----- .../k8s/src/op/activities/wait-for-ready.ts | 3 +- .../render/src/op/activities/render-apply.ts | 5 +- .../terraform/src/op/activities/terraform.ts | 180 ++++++++---------- packages/core/src/cli/handlers/run.test.ts | 2 +- packages/core/src/op/activity-runtime.ts | 17 -- packages/core/src/op/emulator-lifecycle.ts | 3 +- packages/core/src/op/index.ts | 2 +- 27 files changed, 192 insertions(+), 304 deletions(-) diff --git a/docs/src/content/docs/lint-rules/audit-rules.mdx b/docs/src/content/docs/lint-rules/audit-rules.mdx index 095756eb1..664909ec2 100644 --- a/docs/src/content/docs/lint-rules/audit-rules.mdx +++ b/docs/src/content/docs/lint-rules/audit-rules.mdx @@ -2667,9 +2667,9 @@ fountain reconciles by name — rename one, or the second silently overwrites th ### FTN020 -**Schedule cron is not five-field UTC cron syntax** — merge-worthy · guidance +**Schedule cron is not five- or six-field UTC cron syntax** — merge-worthy · guidance -Fix the expression. fountain stores an unparseable cron and then never fires it. +Fix the expression. fountain stores an unparseable cron and then never fires it. The @daily-style shorthands are refused too (#2195): chant validates and matches one cron notion, the five- or six-field one, so write the fields out. ### FTN021 diff --git a/lexicons/aws/src/op/activities/aws-apply.ts b/lexicons/aws/src/op/activities/aws-apply.ts index e6e81c065..772716683 100644 --- a/lexicons/aws/src/op/activities/aws-apply.ts +++ b/lexicons/aws/src/op/activities/aws-apply.ts @@ -1,5 +1,5 @@ import { readFileSync } from "node:fs"; -import { safeHeartbeat, sleep } from "@intentius/chant/op"; +import { sleep } from "@intentius/chant/op"; import { awsDeployCapabilitiesForBody } from "../../components/cloud-executor.js"; import { resolveEndpointOverride } from "../../api/read-client.js"; import { ownershipStackTagsForBody } from "../../ownership.js"; @@ -128,7 +128,6 @@ export async function waitForStackSettled( const deadline = Date.now() + opts.timeoutMs; while (Date.now() < deadline) { if (signal?.aborted) throw new Error("awsApply aborted"); - safeHeartbeat({ step: "awsApply", stack: stackName }); const res = await http(url, cfnForm("DescribeStacks", { StackName: stackName }), signal); const status = stackStatus(res.text); if (status && isTerminalStatus(status)) return status; @@ -231,7 +230,6 @@ export async function awsDelete( const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { if (signal?.aborted) throw new Error("awsDelete aborted"); - safeHeartbeat({ step: "awsDelete", stack: args.stackName }); const d = await http(url, cfnForm("DescribeStacks", { StackName: args.stackName }), signal); if (d.status >= 300 && isStackMissing(d.text)) return { stackName: args.stackName, deleted: true }; const status = stackStatus(d.text); diff --git a/lexicons/azure/src/op/activities/az-apply.ts b/lexicons/azure/src/op/activities/az-apply.ts index 5f6483639..6ddd563d3 100644 --- a/lexicons/azure/src/op/activities/az-apply.ts +++ b/lexicons/azure/src/op/activities/az-apply.ts @@ -1,6 +1,5 @@ import { readFileSync } from "node:fs"; import { createHash } from "node:crypto"; -import { safeHeartbeat } from "@intentius/chant/op"; import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership"; import { AZURE_TAG_OWNERSHIP_KEYS } from "../../ownership"; import { lookupApiVersion } from "../../serializer"; @@ -337,7 +336,6 @@ export async function azApply( const applied: Array<{ type: string; name: string }> = []; for (const resource of orderArmResources(resources)) { const name = String(await evalArmString(resource.name, ctx)); - safeHeartbeat({ step: "azApply", type: resource.type, name }); // Stamp chant ownership so a later prune can tell chant-managed resources // apart from foreign ones in the same group. const body = await armResourceBody(resource, ctx); @@ -646,7 +644,6 @@ export async function pruneArmOrphans( notPrunable.push({ type: item.type, name: item.name, reason: "no-api-version" }); continue; } - safeHeartbeat({ step: "azPrune", type: item.type, name: item.name }); const result = await deleteArmResource(item.type, item.name, apiVersion, ctx, http, signal); console.log(`pruned: ${item.type}/${item.name} (${ctx.base})`); pruned.push(result); @@ -680,7 +677,6 @@ export async function azDelete( const deleted: Array<{ type: string; name: string; deleted: boolean }> = []; for (const resource of orderArmResources(template.resources ?? []).reverse()) { const name = String(await evalArmString(resource.name, ctx)); - safeHeartbeat({ step: "azDelete", type: resource.type, name }); const result = await deleteArmResource(resource.type, name, resource.apiVersion, ctx, http, signal); console.log(`${result.deleted ? "deleted" : "absent"}: ${resource.type}/${name} (${base})`); deleted.push(result); diff --git a/lexicons/fly/src/op/activities/fly-apply.ts b/lexicons/fly/src/op/activities/fly-apply.ts index 8d9a5bb45..efbbf8424 100644 --- a/lexicons/fly/src/op/activities/fly-apply.ts +++ b/lexicons/fly/src/op/activities/fly-apply.ts @@ -23,7 +23,7 @@ */ import { readFileSync } from "node:fs"; -import { safeHeartbeat, sleep } from "@intentius/chant/op"; +import { sleep } from "@intentius/chant/op"; import { hasOwnershipMarker } from "@intentius/chant/ownership"; import { FLY_METADATA_OWNERSHIP_KEYS } from "../../ownership"; import { @@ -396,7 +396,6 @@ export async function waitForMachine( while (Date.now() < deadline) { if (signal?.aborted) throw new Error("waitForMachine aborted"); attempt++; - safeHeartbeat({ step: "waitForMachine", app, id, attempt }); const res = await http("GET", url, undefined, undefined, signal); if (res.status === 200 && (parseJson(res.text) as { ok?: boolean })?.ok === true) { return; @@ -537,7 +536,6 @@ export async function pruneMachines( for (const m of await listMachines(ctx, app, http, signal)) { if (!isChantOwned(m.config?.metadata) || keep.has(m.name)) continue; if (m.state === "destroyed" || m.state === "destroying") continue; - safeHeartbeat({ step: "pruneMachine", app, name: m.name }); await destroyMachine(ctx, app, m.id, http, signal, opts); console.log(`pruned: ${app}/${m.name} (${ctx.base})`); pruned.push({ app, name: m.name, id: m.id }); @@ -935,7 +933,6 @@ export async function flyApply( // Apps first. const apps: Array<{ app: string; created: boolean }> = []; for (const req of appReqs) { - safeHeartbeat({ step: "flyApply", kind: "app", name: appNameFromRequest(req) }); const result = await applyApp(ctx, req, http, signal); console.log(`${result.created ? "created" : "unchanged"}: app/${result.app} (${ctx.base})`); apps.push(result); @@ -967,7 +964,6 @@ export async function flyApply( const volumes: Array<{ app: string; name: string; action: "created" | "noop" }> = []; for (const [entityName, req] of volumeReqs) { const app = resolveApp(resourceAppSegment(req.endpoint), soleApp); - safeHeartbeat({ step: "flyApply", kind: "volume", name: entityName }); const result = await applyVolume(ctx, app, entityName, req, http, signal); track(keepVolumes, app, result.name); console.log(`${result.action}: volume/${app}/${result.name} (${ctx.base})`); @@ -979,7 +975,6 @@ export async function flyApply( const app = resolveApp(machineAppSegment(req.endpoint), soleApp); const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName; track(keepMachines, app, name); - safeHeartbeat({ step: "flyApply", kind: "machine", name }); const result = await applyMachine(ctx, app, entityName, req, http, signal, opts); console.log(`${result.action}: machine/${app}/${result.name} (${ctx.base})`); machines.push({ app, name: result.name, action: result.action }); @@ -989,7 +984,6 @@ export async function flyApply( const ips: Array<{ app: string; type: string; action: "created" | "noop" }> = []; for (const [entityName, req] of ipReqs) { const app = resolveApp(resourceAppSegment(req.endpoint), soleApp); - safeHeartbeat({ step: "flyApply", kind: "ip", name: entityName }); const result = await applyIp(ctx, app, req, http, signal); track(keepIps, app, result.type); console.log(`${result.action}: ip/${app}/${result.type} (${ctx.base})`); @@ -999,7 +993,6 @@ export async function flyApply( const certs: Array<{ app: string; hostname: string; action: "created" | "noop" }> = []; for (const [, req] of certReqs) { const app = resolveApp(resourceAppSegment(req.endpoint), soleApp); - safeHeartbeat({ step: "flyApply", kind: "certificate", name: String(req.body.hostname ?? "") }); const result = await applyCert(ctx, app, req, http, signal); track(keepCerts, app, result.hostname); console.log(`${result.action}: certificate/${app}/${result.hostname} (${ctx.base})`); @@ -1012,7 +1005,6 @@ export async function flyApply( const app = resolveApp(resourceAppSegment(req.endpoint), soleApp); const name = secretNameSegment(req.endpoint); track(keepSecrets, app, name); - safeHeartbeat({ step: "flyApply", kind: "secret", name }); const result = await applySecret(ctx, app, name, req, http, signal); console.log(`set: secret/${app}/${result.name} (${ctx.base})`); secrets.push({ app, name: result.name }); @@ -1072,7 +1064,6 @@ export async function flyDelete( const name = typeof req.body.name === "string" && req.body.name ? req.body.name : entityName; const live = (await listMachines(ctx, app, http, signal)).find((m) => m.name === name); if (!live) continue; - safeHeartbeat({ step: "flyDelete", kind: "machine", name }); await destroyMachine(ctx, app, live.id, http, signal, opts); machines.push({ app, name }); } diff --git a/lexicons/fountain/docs/pages/resources.mdx b/lexicons/fountain/docs/pages/resources.mdx index 5f5269fb9..ae27883a0 100644 --- a/lexicons/fountain/docs/pages/resources.mdx +++ b/lexicons/fountain/docs/pages/resources.mdx @@ -56,8 +56,10 @@ reference at build rather than partway through an apply. ## Schedule -A cron prompt sent to a Teammate. `cron` is five fields in UTC, and FTN020 -rejects an expression fountain would store and then never fire. `one_off: +A cron prompt sent to a Teammate. `cron` is five or six fields in UTC, and +FTN020 rejects an expression fountain would store and then never fire. The +`@daily`-style shorthands are refused with it: chant validates and matches one +cron notion, the five- or six-field one, so write the fields out. `one_off: false` sends the prompt into the teammate's own thread, where a busy teammate means the run is skipped rather than queued; `one_off: true` opens a fresh conversation on each fire. diff --git a/lexicons/fountain/docs/src/content/docs/resources.mdx b/lexicons/fountain/docs/src/content/docs/resources.mdx index e6b526ef9..edabc18aa 100644 --- a/lexicons/fountain/docs/src/content/docs/resources.mdx +++ b/lexicons/fountain/docs/src/content/docs/resources.mdx @@ -60,8 +60,10 @@ reference at build rather than partway through an apply. ## Schedule -A cron prompt sent to a Teammate. `cron` is five fields in UTC, and FTN020 -rejects an expression fountain would store and then never fire. `one_off: +A cron prompt sent to a Teammate. `cron` is five or six fields in UTC, and +FTN020 rejects an expression fountain would store and then never fire. The +`@daily`-style shorthands are refused with it: chant validates and matches one +cron notion, the five- or six-field one, so write the fields out. `one_off: false` sends the prompt into the teammate's own thread, where a busy teammate means the run is skipped rather than queued; `one_off: true` opens a fresh conversation on each fire. diff --git a/lexicons/fountain/docs/src/content/docs/rules.mdx b/lexicons/fountain/docs/src/content/docs/rules.mdx index 522d71f2d..fe3757a23 100644 --- a/lexicons/fountain/docs/src/content/docs/rules.mdx +++ b/lexicons/fountain/docs/src/content/docs/rules.mdx @@ -28,7 +28,7 @@ Post-synth checks validate the serialized output after the build pipeline comple | `FTN015` | Secret-shaped MCP env keys must be $\{VAR\} references, not literals | | `FTN016` | Agent runtime must be a known runtime and model canonical provider/model_id | | `FTN017` | Two declarations of the same kind must not resolve to the same fountain name | -| `FTN020` | Schedule cron must be five-field UTC cron (or a supported @nickname) | +| `FTN020` | Schedule cron must be five- or six-field UTC cron (no @nickname shorthands) | | `FTN021` | A Teammate's agent and a Schedule's teammate must resolve within the build | | `FTN022` | Webhook url must be https and must not be a loopback or private address | | `FTN023` | FTN023 | diff --git a/lexicons/fountain/src/lint/audit-catalog.ts b/lexicons/fountain/src/lint/audit-catalog.ts index ec434b410..a01287702 100644 --- a/lexicons/fountain/src/lint/audit-catalog.ts +++ b/lexicons/fountain/src/lint/audit-catalog.ts @@ -120,8 +120,10 @@ export const fountainAuditCatalog: Record = { "FTN020", "merge-worthy", "correctness", - "Schedule cron is not five-field UTC cron syntax", - "Fix the expression. fountain stores an unparseable cron and then never fires it.", + "Schedule cron is not five- or six-field UTC cron syntax", + "Fix the expression. fountain stores an unparseable cron and then never fires it. " + + "The @daily-style shorthands are refused too (#2195): chant validates and matches one cron " + + "notion, the five- or six-field one, so write the fields out.", ), FTN021: rule( "FTN021", diff --git a/lexicons/fountain/src/lint/post-synth/ftn020-schedule-cron-syntax.ts b/lexicons/fountain/src/lint/post-synth/ftn020-schedule-cron-syntax.ts index 2bce0adbf..9352cb6a2 100644 --- a/lexicons/fountain/src/lint/post-synth/ftn020-schedule-cron-syntax.ts +++ b/lexicons/fountain/src/lint/post-synth/ftn020-schedule-cron-syntax.ts @@ -1,4 +1,5 @@ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth"; +import { isValidCronExpression } from "@intentius/chant/op"; import { propsOf } from "../../entity-props"; /** @@ -9,34 +10,25 @@ import { propsOf } from "../../entity-props"; * failure mode nobody notices until the thing the schedule was watching has * already gone wrong. * - * Upstream documents five fields in UTC plus the `@daily`-style shorthands, - * with `@reboot` refused (there is no boot to hang it on). The field check is - * deliberately permissive — a pre-submission guard, not a second scheduler. - * Six fields pass too: a seconds column is the common variant, and rejecting - * it here would be chant inventing a stricter rule than the server enforces. + * The field check is core's own `isValidCronExpression` (`@intentius/chant/op`, + * #2120) rather than a copy of it: deliberately permissive about what a field + * contains, a pre-submission guard rather than a second scheduler. Six fields + * pass too — a seconds column is the common variant, and rejecting it here + * would be chant inventing a stricter rule than the server enforces. * - * #2120: switch to @intentius/chant/op/cron once it lands. The five-or-six - * field check below is inlined from the temporal lexicon's TMP010 until then, - * so both lexicons agree on what a cron string is. + * The `@daily`-style nicknames are refused (#2195). This rule used to accept + * them and core's validator never has. chant has one idea of what a cron + * string is: the expression an Op's `schedule` carries, the one `chant + * operator` matches a tick against, and the one this rule checks. `cronMatches` + * cannot evaluate a nickname, so a cadence written that way is a cadence chant + * itself will not fire, even where fountain upstream would take it. Write the + * five fields out (`@daily` is `0 0 * * *`). `@reboot` was already refused, + * since there is no boot to hang it on. */ -/** Very permissive cron field pattern — catches obvious syntax errors. */ -const CRON_FIELD = /^[0-9*,/\-?LW#]+$/; - -/** The nicknames fountain accepts; `@reboot` is documented as refused. */ -const CRON_NICKNAMES = new Set(["@yearly", "@annually", "@monthly", "@weekly", "@daily", "@midnight", "@hourly"]); - -function isValidCronExpression(expr: string): boolean { - const trimmed = expr.trim(); - if (trimmed.startsWith("@")) return CRON_NICKNAMES.has(trimmed.toLowerCase()); - const fields = trimmed.split(/\s+/); - if (fields.length < 5 || fields.length > 6) return false; - return fields.every((f) => CRON_FIELD.test(f)); -} - export const scheduleCronSyntaxCheck: PostSynthCheck = { id: "FTN020", - description: "Schedule cron must be five-field UTC cron (or a supported @nickname)", + description: "Schedule cron must be five- or six-field UTC cron (no @nickname shorthands)", check(ctx: PostSynthContext): PostSynthDiagnostic[] { const diagnostics: PostSynthDiagnostic[] = []; @@ -51,8 +43,9 @@ export const scheduleCronSyntaxCheck: PostSynthCheck = { checkId: "FTN020", severity: "error", message: - `Schedule "${name}" cron "${cron}" is not five-field cron syntax — ` + - `fountain would store it and never fire it`, + `Schedule "${name}" cron "${cron}" is not five- or six-field cron syntax — ` + + `fountain would store it and never fire it. The @daily-style shorthands are ` + + `not accepted either; write the fields out.`, entity: name, lexicon: "fountain", }); diff --git a/lexicons/fountain/src/lint/post-synth/post-synth.test.ts b/lexicons/fountain/src/lint/post-synth/post-synth.test.ts index e4c50bf9a..fff3abb5d 100644 --- a/lexicons/fountain/src/lint/post-synth/post-synth.test.ts +++ b/lexicons/fountain/src/lint/post-synth/post-synth.test.ts @@ -167,14 +167,19 @@ describe("FTN020 schedule-cron-syntax", () => { expect(scheduleCronSyntaxCheck.check(ctx({ s: { entityType: SCHEDULE, cron: "0 3 * *" } }))).toHaveLength(1); }); - it("accepts five fields, six fields, and the documented nicknames", () => { - for (const cron of ["0 3 * * *", "0 9 * * 1-5", "*/30 0 3 * * *", "@daily"]) { + it("accepts five and six fields", () => { + for (const cron of ["0 3 * * *", "0 9 * * 1-5", "*/30 0 3 * * *"]) { expect(scheduleCronSyntaxCheck.check(ctx({ s: { entityType: SCHEDULE, cron } })), cron).toHaveLength(0); } }); - it("rejects @reboot, which fountain documents as unsupported", () => { - expect(scheduleCronSyntaxCheck.check(ctx({ s: { entityType: SCHEDULE, cron: "@reboot" } }))).toHaveLength(1); + // #2195: the rule is core's `isValidCronExpression`, which takes no + // nickname. `cronMatches` cannot evaluate one either, so a cadence written + // that way is one chant never fires. + it("rejects the @nickname shorthands, including @daily and @reboot", () => { + for (const cron of ["@daily", "@hourly", "@midnight", "@yearly", "@reboot"]) { + expect(scheduleCronSyntaxCheck.check(ctx({ s: { entityType: SCHEDULE, cron } })), cron).toHaveLength(1); + } }); }); diff --git a/lexicons/gcp/src/op/activities/gcp-apply.ts b/lexicons/gcp/src/op/activities/gcp-apply.ts index b79cfff91..64311be30 100644 --- a/lexicons/gcp/src/op/activities/gcp-apply.ts +++ b/lexicons/gcp/src/op/activities/gcp-apply.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { parseYAML } from "@intentius/chant/yaml"; -import { safeHeartbeat, sleep } from "@intentius/chant/op"; +import { sleep } from "@intentius/chant/op"; import { hasOwnershipMarker, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership"; import { GCP_RESOURCE_OWNERSHIP_KEYS } from "../../ownership"; import { @@ -552,7 +552,6 @@ export async function waitForOperation( while (Date.now() < deadline) { if (signal?.aborted) throw new Error("waitForOperation aborted"); attempt++; - safeHeartbeat({ step: "waitForOperation", attempt }); const res = await http("GET", pollUrl, undefined, signal); const body = res.status < 300 ? parseJson(res.text) : undefined; if (body) { @@ -723,7 +722,6 @@ export async function pruneOrphans( } for (const item of mapper.list.items(parseJson(res.text))) { if (!isChantOwned(item.labels) || keep.has(item.name)) continue; - safeHeartbeat({ step: "prune", kind, name: item.name }); const result = await deleteResource(mapper, { kind, metadata: { name: item.name } }, ctx, http, signal); console.log(`pruned: ${kind}/${item.name} (${ctx.base})`); pruned.push(result); @@ -775,7 +773,6 @@ export async function gcpApply( continue; } const ctx = resolve(mapper, r); - safeHeartbeat({ step: "gcpApply", kind: mapper.kind, name: r.metadata?.name }); const result = await applyResource(mapper, r, ctx, http, signal); const verb = result.created ? "created" : result.updated ? "updated" : "unchanged"; console.log(`${verb}: ${result.kind}/${result.name} (${ctx.base})`); @@ -850,7 +847,6 @@ export async function gcpDelete( } const base = (resolveGcpEndpoint(args) ?? mapper.defaultHost).replace(/\/$/, ""); const project = args.project ?? resolveGcpProject(r); - safeHeartbeat({ step: "gcpDelete", kind: mapper.kind, name: r.metadata?.name }); const result = await deleteResource(mapper, r, { base, project }, http, signal); console.log(`${result.deleted ? "deleted" : "absent"}: ${result.kind}/${result.name} (${base})`); deleted.push(result); diff --git a/lexicons/github/src/components/generate-op-pipeline.test.ts b/lexicons/github/src/components/generate-op-pipeline.test.ts index ffa798c36..00ed39c37 100644 --- a/lexicons/github/src/components/generate-op-pipeline.test.ts +++ b/lexicons/github/src/components/generate-op-pipeline.test.ts @@ -201,7 +201,7 @@ describe("generateGithubOpPipeline: a cross-cutting change is one generator edit { name: "prod-reconcile", schedule: "0 * * * *" }, ]; const result = generateGithubOpPipeline(specs, { - runCommand: ["chant", "run", "{name}", "--temporal"], + runCommand: ["chant", "run", "{name}", "--json"], beforeScript: ["npm ci"], extraScript: ["echo done"], }); @@ -211,7 +211,7 @@ describe("generateGithubOpPipeline: a cross-cutting change is one generator edit const jobName = Object.keys(doc.jobs!)[0]; const runLines = doc.jobs![jobName].steps.filter((s) => typeof s.run === "string").map((s) => s.run as string); expect(runLines[0]).toBe("npm ci"); - expect(runLines[1]).toContain("--temporal"); + expect(runLines[1]).toContain("--json"); expect(runLines[2]).toBe("echo done"); } }); diff --git a/lexicons/github/src/components/generate-pipeline.test.ts b/lexicons/github/src/components/generate-pipeline.test.ts index 45f155346..2a6208c07 100644 --- a/lexicons/github/src/components/generate-pipeline.test.ts +++ b/lexicons/github/src/components/generate-pipeline.test.ts @@ -222,7 +222,7 @@ describe("generateGithubPipeline: a cross-cutting change is one generator edit, test("changing the trigger command (runCommand) updates every job's script uniformly", () => { const components = pilotComponents(); const result = generateGithubPipeline(components, { - runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--temporal"], + runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--verbose"], }); const jobs = parsedJobs(result.yaml); @@ -230,7 +230,7 @@ describe("generateGithubPipeline: a cross-cutting change is one generator edit, const lines = runLines(jobs[job.jobName]); // The runCommand prefix reflects in every job; output-threading flags // (--seed-outputs/--dump-outputs) may be appended per the dependency graph. - expect(lines[0].startsWith(`chant run --components ${job.component} --env staging --temporal`)).toBe(true); + expect(lines[0].startsWith(`chant run --components ${job.component} --env staging --verbose`)).toBe(true); } }); diff --git a/lexicons/gitlab/src/components/generate-op-pipeline.test.ts b/lexicons/gitlab/src/components/generate-op-pipeline.test.ts index 3be077bd8..0653c853c 100644 --- a/lexicons/gitlab/src/components/generate-op-pipeline.test.ts +++ b/lexicons/gitlab/src/components/generate-op-pipeline.test.ts @@ -89,7 +89,7 @@ describe("generateGitlabOpPipeline: a cross-cutting change is one generator edit { name: "prod-reconcile", schedule: "0 * * * *" }, ]; const result = generateGitlabOpPipeline(specs, { - runCommand: ["chant", "run", "{name}", "--temporal"], + runCommand: ["chant", "run", "{name}", "--json"], beforeScript: ["npm ci"], extraScript: ["echo done"], }); @@ -99,7 +99,7 @@ describe("generateGitlabOpPipeline: a cross-cutting change is one generator edit const job = parsed[spec.name] as Record; const script = job.script as string[]; expect(script[0]).toBe("npm ci"); - expect(script[1]).toContain("--temporal"); + expect(script[1]).toContain("--json"); expect(script[2]).toBe("echo done"); } }); diff --git a/lexicons/gitlab/src/components/generate-pipeline.test.ts b/lexicons/gitlab/src/components/generate-pipeline.test.ts index 048c5a8cd..e7ecbe6ba 100644 --- a/lexicons/gitlab/src/components/generate-pipeline.test.ts +++ b/lexicons/gitlab/src/components/generate-pipeline.test.ts @@ -186,7 +186,7 @@ describe("generateGitlabPipeline: a cross-cutting change is one generator edit, test("changing the trigger command (runCommand) updates every job's script uniformly", () => { const components = pilotComponents(); const result = generateGitlabPipeline(components, { - runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--temporal"], + runCommand: ["chant", "run", "--components", "{name}", "--env", "staging", "--verbose"], }); const parsed = parseYAML(result.yaml); @@ -194,7 +194,7 @@ describe("generateGitlabPipeline: a cross-cutting change is one generator edit, const script = (parsed[job.jobName] as Record).script as string[]; // The runCommand prefix reflects in every job; output-threading flags // (--seed-outputs/--dump-outputs) may be appended per the dependency graph. - expect(script[0].startsWith(`chant run --components ${job.component} --env staging --temporal`)).toBe(true); + expect(script[0].startsWith(`chant run --components ${job.component} --env staging --verbose`)).toBe(true); } }); diff --git a/lexicons/gitlab/src/op/activities/gitlab.ts b/lexicons/gitlab/src/op/activities/gitlab.ts index 45e0ad535..d8b2dc034 100644 --- a/lexicons/gitlab/src/op/activities/gitlab.ts +++ b/lexicons/gitlab/src/op/activities/gitlab.ts @@ -1,6 +1,6 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import { safeHeartbeat, sleep } from "@intentius/chant/op"; +import { sleep } from "@intentius/chant/op"; const execAsync = promisify(exec); @@ -34,7 +34,6 @@ export async function gitlabPipeline(args: GitlabPipelineArgs, signal?: AbortSig while (true) { if (signal?.aborted) throw new Error("gitlabPipeline aborted"); attempt++; - safeHeartbeat({ step: "gitlabPipeline", project: args.name, attempt }); const { stdout } = await execAsync( `glab ci status --project ${args.name} --format json`, diff --git a/lexicons/helm/src/op/activities/helm.ts b/lexicons/helm/src/op/activities/helm.ts index 24963ba32..2e82795e8 100644 --- a/lexicons/helm/src/op/activities/helm.ts +++ b/lexicons/helm/src/op/activities/helm.ts @@ -14,7 +14,6 @@ import { compareCapabilityProfile, probeClusterCapabilities, } from "./cluster-probe"; -import { safeHeartbeat } from "@intentius/chant/op"; import { maybeRecordAutoRelease, type AutoReleaseResult, @@ -431,17 +430,9 @@ export async function helmInstall( if (args.values) parts.push("-f", args.values); for (const [k, v] of Object.entries(args.set ?? {})) parts.push("--set", `${k}=${v}`); - const heartbeatInterval = setInterval(() => { - safeHeartbeat({ step: "helm install", release: args.name }); - }, 15_000); - - try { - const { stdout, stderr } = await execAsync(parts.join(" "), { signal }); - if (stdout) console.log(stdout); - if (stderr) console.error(stderr); - } finally { - clearInterval(heartbeatInterval); - } + const { stdout, stderr } = await execAsync(parts.join(" "), { signal }); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); const profileOverride = profileAssertion && !profileAssertion.matched ? profileAssertion.divergences.join("; ") : undefined; @@ -562,9 +553,6 @@ async function pinnedHelmInstall( } const wrapperDir = mkdtempSync(join(tmpdir(), "chant-helm-pinned-")); - const heartbeatInterval = setInterval(() => { - safeHeartbeat({ step: "helm install (pinned)", release: args.name }); - }, 15_000); try { materializeWrapperChart(routed, wrapperDir); const parts = ["helm", "upgrade", "--install", "--wait", args.name, wrapperDir]; @@ -573,7 +561,6 @@ async function pinnedHelmInstall( if (stdout) console.log(stdout); if (stderr) console.error(stderr); } finally { - clearInterval(heartbeatInterval); rmSync(wrapperDir, { recursive: true, force: true }); } diff --git a/lexicons/k3d/src/op/activities/k3d.ts b/lexicons/k3d/src/op/activities/k3d.ts index f0c538176..19451fa26 100644 --- a/lexicons/k3d/src/op/activities/k3d.ts +++ b/lexicons/k3d/src/op/activities/k3d.ts @@ -1,6 +1,5 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import { safeHeartbeat } from "@intentius/chant/op"; const execAsync = promisify(exec); @@ -158,17 +157,9 @@ export async function k3dUp(args: K3dUpArgs, signal?: AbortSignal): Promise { - safeHeartbeat({ step: "k3d cluster create", cluster: args.name }); - }, 15_000); - - try { - const { stdout, stderr } = await execAsync(k3dUpCommand(args), { signal }); - if (stdout) console.log(stdout); - if (stderr) console.error(stderr); - } finally { - clearInterval(heartbeatInterval); - } + const { stdout, stderr } = await execAsync(k3dUpCommand(args), { signal }); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); return resolveConnection(args, signal); } diff --git a/lexicons/k3s/src/op/activities/k3s.ts b/lexicons/k3s/src/op/activities/k3s.ts index 69266612b..674125370 100644 --- a/lexicons/k3s/src/op/activities/k3s.ts +++ b/lexicons/k3s/src/op/activities/k3s.ts @@ -1,6 +1,5 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import { safeHeartbeat } from "@intentius/chant/op"; import { K3S_VERSION } from "../../spec/fetch"; const execAsync = promisify(exec); @@ -139,20 +138,12 @@ export async function k3sInstall( // `k3s --version` errors when k3s is absent — fall through to install. } - const heartbeatInterval = setInterval(() => { - safeHeartbeat({ step: "k3s install", role: args.role, version: target }); - }, 15_000); - - try { - const { stdout, stderr } = await execAsync(k3sInstallCommand(args), { - signal, - env: { ...process.env, ...k3sInstallEnv(args) }, - }); - if (stdout) console.log(stdout); - if (stderr) console.error(stderr); - } finally { - clearInterval(heartbeatInterval); - } + const { stdout, stderr } = await execAsync(k3sInstallCommand(args), { + signal, + env: { ...process.env, ...k3sInstallEnv(args) }, + }); + if (stdout) console.log(stdout); + if (stderr) console.error(stderr); return { version: target, installed: true }; } diff --git a/lexicons/k8s/src/op/activities/kubectl.ts b/lexicons/k8s/src/op/activities/kubectl.ts index 4e68b40c0..6c48c60cb 100644 --- a/lexicons/k8s/src/op/activities/kubectl.ts +++ b/lexicons/k8s/src/op/activities/kubectl.ts @@ -34,7 +34,6 @@ import { readFileSync, readdirSync, statSync } from "node:fs"; import { join } from "node:path"; import { loadAll } from "js-yaml"; -import { safeHeartbeat } from "@intentius/chant/op"; import { loadChantConfig, resolveOwnershipStack } from "@intentius/chant/config"; import { hasOwnershipMarker, @@ -310,69 +309,56 @@ export async function applyManifest( ): Promise { const documents = args.documents ?? readManifestDocuments(args.manifest); const { fieldManager, stack } = await resolveApplyIdentity(args); - const heartbeatInterval = setInterval(() => { - safeHeartbeat({ step: "kubectl apply", manifest: args.manifest }); - }, 15_000); - - try { - const { client } = await connect({ - ...(args.environment !== undefined ? { environment: args.environment } : {}), - ...(args.context !== undefined ? { context: args.context } : {}), - ...(args.cwd !== undefined ? { cwd: args.cwd } : {}), - }); + const { client } = await connect({ + ...(args.environment !== undefined ? { environment: args.environment } : {}), + ...(args.context !== undefined ? { context: args.context } : {}), + ...(args.cwd !== undefined ? { cwd: args.cwd } : {}), + }); - const applied: AppliedRef[] = []; - for (const document of documents) { - const stamped = stampOwnership(document as K8sObject, stack); - let result: K8sObject; - try { - result = await client.apply(stamped, { - fieldManager, - force: args.force ?? false, - dryRun: args.dryRun, - signal, - }); - } catch (err) { - // "chant never forces a conflict on its own" is about taking fields - // from ANOTHER tool. A conflict where every contested field is owned - // by another `chant:*` manager is chant contesting itself — the - // ownership-stack → unit-stack label migration, or a renamed deploy - // unit — and refusing that forever would strand every estate applied - // before the rename with no non-force path back. Retake those fields - // deliberately, once, and only when no foreign manager is involved. - if (!isChantSelfConflict(err)) throw err; - result = await client.apply(stamped, { fieldManager, force: true, dryRun: args.dryRun, signal }); - } - const ref: AppliedRef = { - apiVersion: String(result.apiVersion ?? document.apiVersion ?? ""), - kind: String(result.kind ?? document.kind ?? ""), - name: String(result.metadata?.name ?? ""), - ...(result.metadata?.namespace !== undefined - ? { namespace: String(result.metadata.namespace) } - : {}), - }; - applied.push(ref); - safeHeartbeat({ - step: "kubectl apply", - manifest: args.manifest, - applied: `${ref.kind}/${ref.name}`, + const applied: AppliedRef[] = []; + for (const document of documents) { + const stamped = stampOwnership(document as K8sObject, stack); + let result: K8sObject; + try { + result = await client.apply(stamped, { + fieldManager, + force: args.force ?? false, + dryRun: args.dryRun, + signal, }); - console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} applied${args.dryRun ? " (dry run — nothing persisted)" : ""}`); + } catch (err) { + // "chant never forces a conflict on its own" is about taking fields + // from ANOTHER tool. A conflict where every contested field is owned + // by another `chant:*` manager is chant contesting itself — the + // ownership-stack → unit-stack label migration, or a renamed deploy + // unit — and refusing that forever would strand every estate applied + // before the rename with no non-force path back. Retake those fields + // deliberately, once, and only when no foreign manager is involved. + if (!isChantSelfConflict(err)) throw err; + result = await client.apply(stamped, { fieldManager, force: true, dryRun: args.dryRun, signal }); } + const ref: AppliedRef = { + apiVersion: String(result.apiVersion ?? document.apiVersion ?? ""), + kind: String(result.kind ?? document.kind ?? ""), + name: String(result.metadata?.name ?? ""), + ...(result.metadata?.namespace !== undefined + ? { namespace: String(result.metadata.namespace) } + : {}), + }; + applied.push(ref); + console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} applied${args.dryRun ? " (dry run — nothing persisted)" : ""}`); + } - const deleteMode = args.deleteMode ?? "never"; - const { pruned, retained } = - deleteMode === "never" || args.dryRun - ? { pruned: [], retained: [] } - : await pruneOrphans(client, applied, { - ...(stack !== undefined ? { stack } : {}), - signal, - }); + const deleteMode = args.deleteMode ?? "never"; + const { pruned, retained } = + deleteMode === "never" || args.dryRun + ? { pruned: [], retained: [] } + : await pruneOrphans(client, applied, { + ...(stack !== undefined ? { stack } : {}), + signal, + }); - return { fieldManager, applied, pruned, retained }; - } finally { - clearInterval(heartbeatInterval); - } + return { fieldManager, applied, pruned, retained }; } /** @@ -524,7 +510,6 @@ async function pruneOrphans( for (const ref of candidates) { await client.delete(ref, { ...(options.signal ? { signal: options.signal } : {}) }); pruned.push(ref); - safeHeartbeat({ step: "prune", pruned: `${ref.kind}/${ref.name}` }); console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`); } return { pruned, retained }; diff --git a/lexicons/k8s/src/op/activities/wait-for-ready.ts b/lexicons/k8s/src/op/activities/wait-for-ready.ts index b4cc7aeb8..8bb7644a3 100644 --- a/lexicons/k8s/src/op/activities/wait-for-ready.ts +++ b/lexicons/k8s/src/op/activities/wait-for-ready.ts @@ -1,4 +1,4 @@ -import { safeHeartbeat, sleep } from "@intentius/chant/op"; +import { sleep } from "@intentius/chant/op"; import { defaultK8sConnector, type K8sConnector } from "../../api/connect"; /** @@ -356,7 +356,6 @@ export async function waitForReady( attempt++; const obj = await fetcher(args, signal); - safeHeartbeat({ step: "waitForReady", kind: args.kind, name: args.name, attempt }); const term = firstTerminal(obj, spec); if (term) { diff --git a/lexicons/render/src/op/activities/render-apply.ts b/lexicons/render/src/op/activities/render-apply.ts index 79167735a..d9072c461 100644 --- a/lexicons/render/src/op/activities/render-apply.ts +++ b/lexicons/render/src/op/activities/render-apply.ts @@ -30,7 +30,7 @@ */ import { readFileSync } from "node:fs"; -import { safeHeartbeat, sleep } from "@intentius/chant/op"; +import { sleep } from "@intentius/chant/op"; import { hasOwnershipMarker, readOwnership, type OwnershipMarker } from "@intentius/chant/ownership"; import { applyResult, @@ -745,7 +745,6 @@ export async function waitForDeploy( id = d.id; status = d.status; } - safeHeartbeat({ step: "renderApply", kind: "deploy", service: serviceId, deploy: id, status }); if (DEPLOY_LIVE.has(status)) return status; if (DEPLOY_FAILED.has(status)) { throw new Error(`render: deploy ${id} of service ${serviceId} ended ${status}`); @@ -828,7 +827,6 @@ export async function renderApplyDetailed( for (const [entityName, req] of ordered) { const entry = catalogEntry(req.entityType); - safeHeartbeat({ step: "renderApply", kind: entry.kind, name: req.name }); // Resolve markers now that every dependency is live. const body = (await resolveMarkers(ctx, req.body, lives, http, infoCache, owner, signal)) as Record; @@ -1024,7 +1022,6 @@ export async function renderDelete( for (const [entityName, req] of ordered) { const entry = catalogEntry(req.entityType); - safeHeartbeat({ step: "renderDelete", kind: entry.kind, name: req.name }); const endpoint = await resolveEndpointFor(req); const id = endpoint ? await findId(entityName) : undefined; if (!id) { diff --git a/lexicons/terraform/src/op/activities/terraform.ts b/lexicons/terraform/src/op/activities/terraform.ts index 04e17e374..04214e22f 100644 --- a/lexicons/terraform/src/op/activities/terraform.ts +++ b/lexicons/terraform/src/op/activities/terraform.ts @@ -4,9 +4,8 @@ * * Shaped after `lexicons/k3s/src/op/activities/k3s.ts`: `promisify(exec)` with * the caller's `AbortSignal` forwarded so a local timeout or Ctrl-C kills the - * child, `safeHeartbeat` on an interval around the long calls, and every - * command and environment string produced by a pure exported function so a - * test can assert on it without running terraform. + * child, and every command and environment string produced by a pure exported + * function so a test can assert on it without running terraform. * * Two invariants hold for every call: * @@ -44,7 +43,6 @@ import { exec } from "node:child_process"; import { writeFileSync } from "node:fs"; import { promisify } from "node:util"; import { resolve, dirname, join } from "node:path"; -import { safeHeartbeat } from "@intentius/chant/op"; import { loadChantConfigUpward } from "@intentius/chant/config"; import type { TerraformConfig, TerraformRootConfig } from "../../config"; import { detectLiveEstate } from "./live-detect"; @@ -64,9 +62,6 @@ const execAsync = promisify(exec); */ const MAX_BUFFER = 64 * 1024 * 1024; -/** Heartbeat cadence for the long calls, matching k3s's installer loop. */ -const HEARTBEAT_MS = 15_000; - /** Plan file written into the root directory when a step names none. */ export const DEFAULT_PLAN_FILE = "chant.tfplan"; @@ -375,7 +370,7 @@ export interface ChoudoufuAdoptResult { mechanism: "tag-write"; /** The declared addresses whose live resources now carry this estate's markers. */ adopted: string[]; - /** `adopted.length`, as a scalar a search attribute can carry. */ + /** `adopted.length`, as a scalar an outcome attribute can carry. */ adoptedCount: number; /** Every candidate this run declined, contested addresses included, with the reason. */ refused: AdoptionRefusal[]; @@ -733,16 +728,6 @@ async function run( return execAsync(cmd, { cwd: dir, env: { ...process.env, ...env }, signal, maxBuffer: MAX_BUFFER }); } -/** Run `body` with a heartbeat ticking, so a long terraform call is not read as a hung one. */ -async function withHeartbeat(details: Record, body: () => Promise): Promise { - const timer = setInterval(() => safeHeartbeat(details), HEARTBEAT_MS); - try { - return await body(); - } finally { - clearInterval(timer); - } -} - function report(stdout: string, stderr: string): void { if (stdout) console.log(stdout); if (stderr) console.error(stderr); @@ -767,9 +752,7 @@ export async function terraformInit( ...(args.reconfigure ? { reconfigure: true } : {}), }); - const { stdout, stderr } = await withHeartbeat({ step: "terraform init", root: args.root, dir }, () => - run(cmd, dir, terraformEnvironment(root), signal), - ); + const { stdout, stderr } = await run(cmd, dir, terraformEnvironment(root), signal); report(stdout, stderr); return { dir, ...(root.workspace ? { workspace: root.workspace } : {}) }; @@ -802,26 +785,25 @@ export async function terraformPlan( ...(args.destroy ? { destroy: true } : {}), }); - const changed = await withHeartbeat({ step: "terraform plan", root: args.root, dir }, async () => { - try { - const { stdout, stderr } = await run(cmd, dir, env, signal); - report(stdout, stderr); - return false; - } catch (err) { - // An abort or a spawn failure carries no numeric exit code. That is not - // terraform answering, so it propagates untouched. - const failure = err as ExecFailure; - if (typeof failure.code !== "number") throw err; - if (failure.code !== 2) { - const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim(); - throw new Error( - `${binary} plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`, - ); - } - report(failure.stdout ?? "", failure.stderr ?? ""); - return true; + let changed: boolean; + try { + const { stdout, stderr } = await run(cmd, dir, env, signal); + report(stdout, stderr); + changed = false; + } catch (err) { + // An abort or a spawn failure carries no numeric exit code. That is not + // terraform answering, so it propagates untouched. + const failure = err as ExecFailure; + if (typeof failure.code !== "number") throw err; + if (failure.code !== 2) { + const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim(); + throw new Error( + `${binary} plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`, + ); } - }); + report(failure.stdout ?? "", failure.stderr ?? ""); + changed = true; + } const jsonRun = await run(terraformShowCommand({ binary, json: true, planFile }), dir, env, signal); const textRun = await run(terraformShowCommand({ binary, json: false, planFile }), dir, env, signal); @@ -881,10 +863,7 @@ export async function terraformApply( const cmd = terraformApplyCommand({ binary, planFile }); try { - const { stdout, stderr } = await withHeartbeat( - { step: "terraform apply", root: args.root, dir, planFile }, - () => run(cmd, dir, terraformEnvironment(root), signal), - ); + const { stdout, stderr } = await run(cmd, dir, terraformEnvironment(root), signal); report(stdout, stderr); return { planFile, dir, applied: true }; } catch (err) { @@ -1004,28 +983,27 @@ export async function choudoufuLivePlan( const env = terraformEnvironment(resolved.root); const documentPath = args.documentPath ?? DEFAULT_LIVE_PLAN_DOCUMENT_FILE; - const { drift, stdout: jsonStdout } = await withHeartbeat( - { step: "choudoufu live-plan", root: args.root, dir, estate }, - async () => { - const cmd = choudoufuLivePlanCommand({ binary, estate, json: true }); - try { - const { stdout, stderr } = await run(cmd, dir, env, signal); - report(stdout, stderr); - return { drift: false, stdout }; - } catch (err) { - const failure = err as ExecFailure; - if (typeof failure.code !== "number") throw err; - if (failure.code !== 2) { - const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim(); - throw new Error( - `${binary} live-plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`, - ); - } - report(failure.stdout ?? "", failure.stderr ?? ""); - return { drift: true, stdout: failure.stdout ?? "" }; - } - }, - ); + let drift: boolean; + let jsonStdout: string; + const planCmd = choudoufuLivePlanCommand({ binary, estate, json: true }); + try { + const { stdout, stderr } = await run(planCmd, dir, env, signal); + report(stdout, stderr); + drift = false; + jsonStdout = stdout; + } catch (err) { + const failure = err as ExecFailure; + if (typeof failure.code !== "number") throw err; + if (failure.code !== 2) { + const detail = (failure.stderr ?? "").trim() || (failure.stdout ?? "").trim(); + throw new Error( + `${binary} live-plan failed in ${dir} (exit ${failure.code})${detail ? `\n${detail}` : ""}`, + ); + } + report(failure.stdout ?? "", failure.stderr ?? ""); + drift = true; + jsonStdout = failure.stdout ?? ""; + } // On the `-estate` path choudoufu prints refresh progress to stdout ahead of // the document (choudoufu #894), so parse from the first line that opens it. @@ -1094,9 +1072,7 @@ export async function choudoufuLiveLs( const env = terraformEnvironment(resolved.root); const cmd = choudoufuLiveLsCommand({ binary, estate, ...(args.consistent ? { consistent: true } : {}) }); - const { stdout, stderr } = await withHeartbeat({ step: "choudoufu live-ls", root: args.root, dir, estate }, () => - run(cmd, dir, env, signal), - ); + const { stdout, stderr } = await run(cmd, dir, env, signal); report(stdout, stderr); return { json: JSON.parse(stdout), dir, estate }; @@ -1179,24 +1155,22 @@ export async function choudoufuAdopt( }); } - await withHeartbeat({ step: "choudoufu adopt", root: args.root, dir }, async () => { - for (const candidate of adoptions) { - if (!candidate.command) { - refused.push({ - addr: candidate.addr, - identity: candidate.identity, - reason: - `${candidate.type} is tagged through its own service call, which choudoufu does not print a ` + - `command for. Write tofu-estate=${candidate.markerEstate} ` + - `tofu-address=${candidate.markerAddress} onto it with that call.`, - }); - continue; - } - const { stdout, stderr } = await run(candidate.command, dir, env, signal); - report(stdout, stderr); - adopted.push(candidate.addr); + for (const candidate of adoptions) { + if (!candidate.command) { + refused.push({ + addr: candidate.addr, + identity: candidate.identity, + reason: + `${candidate.type} is tagged through its own service call, which choudoufu does not print a ` + + `command for. Write tofu-estate=${candidate.markerEstate} ` + + `tofu-address=${candidate.markerAddress} onto it with that call.`, + }); + continue; } - }); + const { stdout, stderr } = await run(candidate.command, dir, env, signal); + report(stdout, stderr); + adopted.push(candidate.addr); + } return { mechanism: "tag-write", @@ -1228,24 +1202,22 @@ export async function choudoufuLiveCheck( const env = terraformEnvironment(root); const cmd = choudoufuLiveCheckCommand({ binary }); - return withHeartbeat({ step: "choudoufu live-check", root: args.root, dir }, async () => { - const parseOrUndefined = (text: string): unknown => { - try { - return JSON.parse(text); - } catch { - return undefined; - } - }; + const parseOrUndefined = (text: string): unknown => { try { - const { stdout, stderr } = await run(cmd, dir, env, signal); - report(stdout, stderr); - return { refused: false, json: parseOrUndefined(stdout), text: stdout, dir }; - } catch (err) { - const failure = err as ExecFailure; - if (typeof failure.code !== "number") throw err; - const stdout = failure.stdout ?? ""; - report(stdout, failure.stderr ?? ""); - return { refused: true, json: parseOrUndefined(stdout), text: stdout, dir }; + return JSON.parse(text); + } catch { + return undefined; } - }); + }; + try { + const { stdout, stderr } = await run(cmd, dir, env, signal); + report(stdout, stderr); + return { refused: false, json: parseOrUndefined(stdout), text: stdout, dir }; + } catch (err) { + const failure = err as ExecFailure; + if (typeof failure.code !== "number") throw err; + const stdout = failure.stdout ?? ""; + report(stdout, failure.stderr ?? ""); + return { refused: true, json: parseOrUndefined(stdout), text: stdout, dir }; + } } diff --git a/packages/core/src/cli/handlers/run.test.ts b/packages/core/src/cli/handlers/run.test.ts index bbc9fb8eb..b40b374f5 100644 --- a/packages/core/src/cli/handlers/run.test.ts +++ b/packages/core/src/cli/handlers/run.test.ts @@ -79,7 +79,7 @@ function makeArgs(overrides: Partial = {}): ParsedArgs { }; } -function makeOp(name: string, depends: string[] = []): [string, { config: { name: string; phases: unknown[]; taskQueue?: string; depends?: string[]; overview: string } }] { +function makeOp(name: string, depends: string[] = []): [string, { config: { name: string; phases: unknown[]; depends?: string[]; overview: string } }] { return [name, { config: { name, phases: [], depends, overview: `${name} overview` } }]; } diff --git a/packages/core/src/op/activity-runtime.ts b/packages/core/src/op/activity-runtime.ts index 782c190b1..849b04d67 100644 --- a/packages/core/src/op/activity-runtime.ts +++ b/packages/core/src/op/activity-runtime.ts @@ -5,23 +5,6 @@ * lexicon from depending on another lexicon just to sleep between polls. */ -/** - * No-op. - * - * @deprecated Heartbeating was a liveness protocol between a worker and the - * server that scheduled it. chant's ops run in-process on a machine that keeps - * state and leaves a record (chant #2114), so nothing is listening for a - * heartbeat and nothing acts on a missed one. An activity that wants to report - * progress should write a line — the executor streams an activity's output with - * the step record it belongs to. - * - * Kept as a call-compatible no-op so the lexicons that call it keep compiling; - * it goes away once none of them do. - */ -export function safeHeartbeat(_details?: unknown): void { - // Intentionally empty. -} - /** * Sleep for `ms`, rejecting early if `signal` aborts. Polling activities use * this between attempts so a local-executor timeout or Ctrl-C interrupts the diff --git a/packages/core/src/op/emulator-lifecycle.ts b/packages/core/src/op/emulator-lifecycle.ts index 845df160c..8c959043a 100644 --- a/packages/core/src/op/emulator-lifecycle.ts +++ b/packages/core/src/op/emulator-lifecycle.ts @@ -1,6 +1,6 @@ import { exec } from "node:child_process"; import { promisify } from "node:util"; -import { safeHeartbeat, sleep } from "./activity-runtime"; +import { sleep } from "./activity-runtime"; const execAsync = promisify(exec); @@ -158,7 +158,6 @@ export function emulatorLifecycle(spec: EmulatorSpec): EmulatorLifecycle { let ok = false; while (Date.now() < deadline) { if (signal?.aborted) throw new Error(`emulator "${name}" wait aborted`); - safeHeartbeat({ step: "emulatorUp", container: name }); try { const res = await fetch(url, { signal }); if (res.ok && ready(await res.text())) { diff --git a/packages/core/src/op/index.ts b/packages/core/src/op/index.ts index 11e5109a5..84527cb98 100644 --- a/packages/core/src/op/index.ts +++ b/packages/core/src/op/index.ts @@ -10,7 +10,7 @@ export { Op, phase, activity, gate, effect, build, kubectlApply, helmInstall, he spriteTaskCreate, spriteTaskRefresh, spriteTaskRelease, spritesUp, spritesDown } from "./builders"; export { OpResource } from "./resource"; -export { safeHeartbeat, sleep } from "./activity-runtime"; +export { sleep } from "./activity-runtime"; export { emulatorLifecycle, emulatorsOf, endpointEnvVars } from "./emulator-lifecycle"; export type { EmulatorSpec, EmulatorCapability, EmulatorDeclaration, EmulatorUpArgs, EmulatorLifecycle } from "./emulator-lifecycle"; export { checkFreshness, compare, formatResult, latestRelease, parseVersion, unpinned } from "./emulator-freshness";