Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/src/content/docs/lint-rules/audit-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 6 additions & 1 deletion docs/src/content/docs/lint-rules/composition.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <component> <gate>`: 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 <lexicon>`, never declared in the component itself.

Expand Down
6 changes: 3 additions & 3 deletions lexicons/aws/src/codegen/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ export async function generateDocs(options?: { verbose?: boolean }): Promise<voi
// `rules` is NOT suppressed: the hand-written `lint-rules` page in docs/pages/
// explains 26 of the lexicon's 50 rules in depth, and the whole WAW032+
// hardening pass had no entry there at all (#1312). The generated table is
// the complete, always-current list, so both ship — the overview/reference
// pairing temporal uses. Duplicating 24 descriptions into the prose page
// would just create a third copy to drift.
// the complete, always-current list, so both ship: a hand-written overview
// beside a generated reference. Duplicating 24 descriptions into the prose
// page would just create a third copy to drift.
suppressPages: ["pseudo-parameters"],
examplesDir: join(pkgDir, "examples"),
};
Expand Down
4 changes: 1 addition & 3 deletions lexicons/aws/src/op/activities/aws-apply.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 4 additions & 4 deletions lexicons/aws/src/op/builders.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* 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: core cannot import a lexicon's types (lexicons depend on core, not
* the reverse), and wiring them into the cloud-agnostic temporal barrel
* would make it depend on every cloud lexicon at runtime. `opts`'s type in
* live beside their `*Args` interfaces rather than in core: core cannot
* import a lexicon's types (lexicons depend on core, not the reverse), 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
* `awsApply`/`awsDelete`/`flociUp`/`flociDown` are unchanged and produce
Expand Down
3 changes: 1 addition & 2 deletions lexicons/azure/src/deep-observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,7 @@ export interface AzureDeepObserveOptions {
* show`. One call per entity, same as the thin path — ARM has no bulk
* "describe everything in this group" call with per-resource depth the way
* CloudFormation's `describe-stack-resources` does, so there is no cheap
* list-then-describe split to make here the way AWS's or temporal's readers
* do.
* list-then-describe split to make here the way AWS's own reader does.
*/
export async function observeResourcesDeepAzure(
options: AzureDeepObserveOptions,
Expand Down
4 changes: 0 additions & 4 deletions lexicons/azure/src/op/activities/az-apply.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions lexicons/azure/src/op/builders.ts
Original file line number Diff line number Diff line change
@@ -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
* `azApply`/`azDelete`/`azGroupEnsure`/`azGroupDelete`/`flociAzUp`/
* `flociAzDown` are unchanged and produce byte-identical `ActivityStep`
* output; these are purely additive.
Expand Down
2 changes: 1 addition & 1 deletion lexicons/cedar/src/dogwood/replay-activity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,7 @@ describe("PolicyReplayOp", () => {
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" });
});
Expand Down
2 changes: 1 addition & 1 deletion lexicons/cedar/src/dogwood/replay-activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
5 changes: 3 additions & 2 deletions lexicons/cedar/src/dogwood/replay-op.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
},
]),
Expand Down
29 changes: 15 additions & 14 deletions lexicons/fly/src/components/run-agent-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });

Expand Down
11 changes: 1 addition & 10 deletions lexicons/fly/src/op/activities/fly-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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})`);
Expand All @@ -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 });
Expand All @@ -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})`);
Expand All @@ -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})`);
Expand All @@ -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 });
Expand Down Expand Up @@ -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 });
}
Expand Down
6 changes: 4 additions & 2 deletions lexicons/fountain/docs/pages/resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions lexicons/fountain/docs/src/content/docs/resources.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion lexicons/fountain/docs/src/content/docs/rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
6 changes: 4 additions & 2 deletions lexicons/fountain/src/lint/audit-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,10 @@ export const fountainAuditCatalog: Record<string, RuleMeta> = {
"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",
Expand Down
Loading
Loading