@intentius/chant 0.85.0 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +13 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +13 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/sessions.d.ts +23 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +14 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts +211 -0
- package/dist/workspace/conformance/index.d.ts.map +1 -0
- package/dist/workspace/conformance/vitest.d.ts +11 -0
- package/dist/workspace/conformance/vitest.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +28 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +40 -0
- package/dist/workspace/declared-kinds.d.ts +43 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/intent-cli.d.ts +2 -1
- package/dist/workspace/intent-cli.d.ts.map +1 -1
- package/dist/workspace/intent-joins.d.ts +45 -8
- package/dist/workspace/intent-joins.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts +71 -7
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +31 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +47 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-sessions.d.ts +51 -0
- package/dist/workspace/record-sessions.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +2 -0
- package/dist/workspace/record-source.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +71 -4
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-since.d.ts +90 -0
- package/dist/workspace/records-since.d.ts.map +1 -0
- package/dist/workspace/records-write.d.ts +171 -0
- package/dist/workspace/records-write.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +244 -15
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +60 -0
- package/dist/workspace/runtimes.d.ts.map +1 -0
- package/dist/workspace/status-gates.d.ts +90 -0
- package/dist/workspace/status-gates.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +17 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +85 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +56 -0
- package/dist/workspace/work.d.ts.map +1 -0
- package/package.json +19 -1
- package/src/cli/main.ts +55 -3
- package/src/cli/registry.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +14 -0
- package/src/workspace/__fixtures__/sessions.ts +66 -0
- package/src/workspace/checks/records.ts +19 -0
- package/src/workspace/checks.test.ts +2 -0
- package/src/workspace/checks.ts +7 -1
- package/src/workspace/composites.schema.json +65 -3
- package/src/workspace/composites.test.ts +95 -5
- package/src/workspace/composites.ts +28 -7
- package/src/workspace/conformance/__fixture__/app/package.json +7 -0
- package/src/workspace/conformance/__fixture__/app/src/server.mjs +29 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +32 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +376 -0
- package/src/workspace/conformance/__fixture__/decisions/fix-001-how-the-app-is-deployed.md +40 -0
- package/src/workspace/conformance/__fixture__/delivery/chant.config.ts +7 -0
- package/src/workspace/conformance/__fixture__/delivery/lexicon/index.ts +26 -0
- package/src/workspace/conformance/__fixture__/delivery/package.json +7 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.component.ts +14 -0
- package/src/workspace/conformance/__fixture__/delivery/src/app.ts +4 -0
- package/src/workspace/conformance/conformance.test.ts +149 -0
- package/src/workspace/conformance/index.mjs +31 -0
- package/src/workspace/conformance/index.ts +453 -0
- package/src/workspace/conformance/vitest.ts +62 -0
- package/src/workspace/declaration.schema.json +40 -0
- package/src/workspace/declaration.ts +62 -0
- package/src/workspace/declared-kinds.test.ts +321 -0
- package/src/workspace/declared-kinds.ts +76 -0
- package/src/workspace/graph-cli.ts +8 -0
- package/src/workspace/intent-cli.ts +29 -6
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent-joins.test.ts +60 -0
- package/src/workspace/intent-joins.ts +71 -19
- package/src/workspace/intent.schema.json +304 -7
- package/src/workspace/intent.test.ts +99 -0
- package/src/workspace/intent.ts +365 -46
- package/src/workspace/ls.schema.json +34 -0
- package/src/workspace/ls.ts +69 -4
- package/src/workspace/read-contract.test.ts +30 -9
- package/src/workspace/reason-codes.test.ts +16 -4
- package/src/workspace/reason-codes.ts +55 -4
- package/src/workspace/record-assets.test.ts +3 -1
- package/src/workspace/record-sessions.ts +105 -0
- package/src/workspace/record-source.ts +14 -5
- package/src/workspace/records-amend.schema.json +167 -0
- package/src/workspace/records-cli.ts +308 -19
- package/src/workspace/records-contract.test.ts +57 -2
- package/src/workspace/records-formats.test.ts +640 -0
- package/src/workspace/records-new.schema.json +158 -0
- package/src/workspace/records-quorum.test.ts +196 -0
- package/src/workspace/records-review.schema.json +227 -0
- package/src/workspace/records-sessions.test.ts +108 -0
- package/src/workspace/records-since.schema.json +193 -0
- package/src/workspace/records-since.test.ts +174 -0
- package/src/workspace/records-since.ts +259 -0
- package/src/workspace/records-write-contract.test.ts +125 -0
- package/src/workspace/records-write.test.ts +373 -0
- package/src/workspace/records-write.ts +765 -0
- package/src/workspace/records.schema.json +202 -9
- package/src/workspace/records.ts +700 -41
- package/src/workspace/runtimes.ts +107 -0
- package/src/workspace/status-contract.test.ts +163 -0
- package/src/workspace/status-gates.ts +215 -0
- package/src/workspace/status.schema.json +69 -3
- package/src/workspace/status.ts +35 -2
- package/src/workspace/trust/seal.test.ts +232 -0
- package/src/workspace/trust/seal.ts +195 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +390 -0
- package/src/workspace/work.ts +163 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runtimes a member's components can deploy on, for `chant workspace
|
|
3
|
+
* graph --composites` (#2674): the built-in `local` runtime, and every lexicon
|
|
4
|
+
* the member's `chant.config.ts` configures whose `opRuntime` hosts
|
|
5
|
+
* `chant run --components`. That is the list `chant run --components <name>
|
|
6
|
+
* --on <runtime>` accepts in the member's directory (`../cli/handlers/run.ts`),
|
|
7
|
+
* so a reader such as hud offers these and never guesses a runtime.
|
|
8
|
+
*
|
|
9
|
+
* `local` is the default. A configured default (`run.on`) is read once the
|
|
10
|
+
* config type has one; until then the command without `--on` runs locally.
|
|
11
|
+
*
|
|
12
|
+
* The config is read the way `chant run` reads it, from the member's
|
|
13
|
+
* directory, in the working tree or in the tree `--at` exported. Reading it
|
|
14
|
+
* runs the member's `chant.config.ts` and imports the lexicons it lists.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { lexiconNames } from "../lexicon-module";
|
|
19
|
+
import type { ReasonCode } from "./reason-codes";
|
|
20
|
+
|
|
21
|
+
/** The runtime core provides, which `chant run` uses without `--on`. */
|
|
22
|
+
export const LOCAL_RUNTIME = "local";
|
|
23
|
+
|
|
24
|
+
/** Why a member's runtimes list only part of what its config names. Closed: part of the read contract. */
|
|
25
|
+
export const RUNTIME_REASON_CODES = [
|
|
26
|
+
/** The member's chant.config.ts could not be read, so only local is listed. */
|
|
27
|
+
"runtimes-config-unreadable",
|
|
28
|
+
/** A configured lexicon could not be loaded, so it is not listed. */
|
|
29
|
+
"runtimes-lexicon-unreadable",
|
|
30
|
+
] as const satisfies readonly ReasonCode[];
|
|
31
|
+
export type RuntimeReasonCode = (typeof RUNTIME_REASON_CODES)[number];
|
|
32
|
+
|
|
33
|
+
export interface RuntimeReason {
|
|
34
|
+
code: RuntimeReasonCode;
|
|
35
|
+
message: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One runtime a component can deploy on. */
|
|
39
|
+
export interface ComponentRuntime {
|
|
40
|
+
/** What `--on` takes: the lexicon's name, or `local`. */
|
|
41
|
+
name: string;
|
|
42
|
+
/** The lexicon that hosts it, or null for `local`. */
|
|
43
|
+
lexicon: string | null;
|
|
44
|
+
/** True for the runtime `chant run --components` uses without `--on`. */
|
|
45
|
+
default: boolean;
|
|
46
|
+
/** The command that deploys the component on it, run in the member's directory. */
|
|
47
|
+
command: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What one member's config says about runtimes. */
|
|
51
|
+
export interface MemberRuntimes {
|
|
52
|
+
/** Configured lexicons whose `opRuntime` hosts component runs, in config order. */
|
|
53
|
+
lexicons: string[];
|
|
54
|
+
/** The default runtime's name. */
|
|
55
|
+
default: string;
|
|
56
|
+
reasons: RuntimeReason[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Loads a lexicon plugin by name; `loadPlugin` from the CLI unless a test swaps it. */
|
|
60
|
+
export type PluginLoader = (name: string) => Promise<{ opRuntime?: { runComponents?: unknown } }>;
|
|
61
|
+
|
|
62
|
+
const firstLine = (err: unknown): string => (err instanceof Error ? err.message : String(err)).split("\n")[0];
|
|
63
|
+
|
|
64
|
+
/** Read the runtimes a member's config offers. `dir` is the member's directory on disk. */
|
|
65
|
+
export async function readMemberRuntimes(dir: string, load?: PluginLoader): Promise<MemberRuntimes> {
|
|
66
|
+
const reasons: RuntimeReason[] = [];
|
|
67
|
+
let names: string[];
|
|
68
|
+
try {
|
|
69
|
+
const { loadChantConfig } = await import("../config");
|
|
70
|
+
names = lexiconNames((await loadChantConfig(dir)).config.lexicons ?? []);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { lexicons: [], default: LOCAL_RUNTIME, reasons: [{ code: "runtimes-config-unreadable", message: `chant.config.ts: ${firstLine(err)}` }] };
|
|
73
|
+
}
|
|
74
|
+
const loader = load ?? (await import("../cli/plugins")).loadPlugin;
|
|
75
|
+
const lexicons: string[] = [];
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
try {
|
|
78
|
+
const plugin = await loader(name);
|
|
79
|
+
if (plugin.opRuntime?.runComponents) lexicons.push(name);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
reasons.push({ code: "runtimes-lexicon-unreadable", message: `lexicon "${name}": ${firstLine(err)}` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { lexicons, default: LOCAL_RUNTIME, reasons };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The runtimes one component can deploy on, `local` first, each with its command line. */
|
|
88
|
+
export function componentRuntimes(component: string, member: MemberRuntimes | undefined): ComponentRuntime[] {
|
|
89
|
+
const def = member?.default ?? LOCAL_RUNTIME;
|
|
90
|
+
const entry = (name: string, lexicon: string | null): ComponentRuntime => ({
|
|
91
|
+
name,
|
|
92
|
+
lexicon,
|
|
93
|
+
default: name === def,
|
|
94
|
+
command: `chant run --components ${component}${name === LOCAL_RUNTIME ? "" : ` --on ${name}`}`,
|
|
95
|
+
});
|
|
96
|
+
return [entry(LOCAL_RUNTIME, null), ...(member?.lexicons ?? []).filter((l) => l !== LOCAL_RUNTIME).map((l) => entry(l, l))];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read the runtimes of each member of kind chant, from the tree at `root`. */
|
|
100
|
+
export async function readRuntimesIn(root: string, members: readonly { name: string; dir: string; kind: string }[], load?: PluginLoader): Promise<Map<string, MemberRuntimes>> {
|
|
101
|
+
const out = new Map<string, MemberRuntimes>();
|
|
102
|
+
for (const m of members) {
|
|
103
|
+
if (m.kind !== "chant") continue;
|
|
104
|
+
out.set(m.name, await readMemberRuntimes(m.dir === "." ? root : join(root, ...m.dir.split("/")), load));
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
runWorkspaceStatus,
|
|
20
20
|
STATUS_CONTRACT_VERSION,
|
|
21
21
|
STATUS_ERROR_CODES,
|
|
22
|
+
STATUS_GATE_REASON_CODES,
|
|
22
23
|
STATUS_OUTPUT_SCHEMA_ID,
|
|
23
24
|
STATUS_REASON_CODES,
|
|
24
25
|
workspaceStatus,
|
|
@@ -146,6 +147,7 @@ describe("status output schema", () => {
|
|
|
146
147
|
test("lists exactly the reason and error codes the code can return", () => {
|
|
147
148
|
expect(schema.$defs.environment.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_REASON_CODES]);
|
|
148
149
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...STATUS_ERROR_CODES]);
|
|
150
|
+
expect(schema.$defs.gateLedger.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_GATE_REASON_CODES]);
|
|
149
151
|
});
|
|
150
152
|
});
|
|
151
153
|
|
|
@@ -296,6 +298,167 @@ describe("chant workspace status on built workspaces", () => {
|
|
|
296
298
|
});
|
|
297
299
|
});
|
|
298
300
|
|
|
301
|
+
/** One pending fact, as a run records it. */
|
|
302
|
+
const pending = (op: string, gate: string, timestamp: string, extra: Record<string, unknown> = {}) =>
|
|
303
|
+
JSON.stringify({ version: 1, kind: "pending", op, gate, timestamp, expiresAt: "2026-12-31T00:00:00.000Z", origin: "cli", ...extra });
|
|
304
|
+
|
|
305
|
+
/** One approval, as `chant approve` records it. */
|
|
306
|
+
const approval = (op: string, gate: string, resolvedBy: string, timestamp: string, extra: Record<string, unknown> = {}) =>
|
|
307
|
+
JSON.stringify({ version: 1, kind: "resolution", op, gate, resolvedBy, timestamp, origin: "cli", approver: { kind: "human" }, ...extra });
|
|
308
|
+
|
|
309
|
+
const NOW = "2026-09-24T00:00:00.000Z";
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* web writes _members/web/, api and the root member read the flat _gates/.
|
|
313
|
+
* Each gate is in one state: approved, pending with part of a quorum,
|
|
314
|
+
* expired, superseded, and an Op gate that records no environment.
|
|
315
|
+
*/
|
|
316
|
+
function gated(): string {
|
|
317
|
+
const root = twoLayouts();
|
|
318
|
+
lifecycle(root, {
|
|
319
|
+
"_members/web/staging/releases.jsonl": jsonl(release("web", "staging", D("a"), "a".repeat(40))),
|
|
320
|
+
"_members/web/_gates/web.jsonl": jsonl(
|
|
321
|
+
pending("web", "deploy", "2026-09-20T00:00:00.000Z", { environment: "staging", planDigest: D("1") }),
|
|
322
|
+
approval("web", "deploy", "alice", "2026-09-20T01:00:00.000Z", { environment: "staging", planDigest: D("1") }),
|
|
323
|
+
pending("web", "deploy", "2026-09-21T00:00:00.000Z", { environment: "prod", planDigest: D("2") }),
|
|
324
|
+
pending("web", "deploy", "2026-09-21T00:00:00.000Z", { environment: "qa", planDigest: D("3") }),
|
|
325
|
+
// chant approve --expire writes a line with no environment; a run ignores it for an environment-bound gate.
|
|
326
|
+
pending("web", "deploy", "2026-09-22T00:00:00.000Z", { expiresAt: "2026-09-22T00:00:00.000Z" }),
|
|
327
|
+
pending("web", "review", "2026-09-20T00:00:00.000Z", { environment: "staging", planDigest: D("4"), approval: { mode: "log-only", quorum: { count: 2 } } }),
|
|
328
|
+
approval("web", "review", "bob", "2026-09-20T02:00:00.000Z", { environment: "staging", planDigest: D("4") }),
|
|
329
|
+
approval("web", "review", "unattested", "2026-09-20T03:00:00.000Z", { environment: "staging", planDigest: D("4"), origin: "mcp", approver: { kind: "agent" } }),
|
|
330
|
+
),
|
|
331
|
+
"staging/releases.jsonl": jsonl(release("api", "staging", D("b"), "b".repeat(40))),
|
|
332
|
+
"_gates/api.jsonl": jsonl(
|
|
333
|
+
pending("api", "deploy", "2026-09-10T00:00:00.000Z", { environment: "staging", planDigest: D("5"), expiresAt: "2026-09-12T00:00:00.000Z" }),
|
|
334
|
+
pending("api", "smoke", "2026-09-20T00:00:00.000Z", { environment: "staging", planDigest: D("6") }),
|
|
335
|
+
approval("api", "smoke", "carol", "2026-09-20T05:00:00.000Z", { environment: "staging", planDigest: D("7") }),
|
|
336
|
+
"{not json",
|
|
337
|
+
),
|
|
338
|
+
"_gates/nightly.jsonl": jsonl(pending("nightly", "confirm", "2026-09-23T00:00:00.000Z"), approval("nightly", "confirm", "dave", "2026-09-23T01:00:00.000Z")),
|
|
339
|
+
});
|
|
340
|
+
return root;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
describe("gate state in chant workspace status --json (#2674)", () => {
|
|
344
|
+
test("each member lists its gates with state, approvals, quorum and the approve line", async () => {
|
|
345
|
+
const doc = result(await workspaceStatus({ cwd: gated(), env: "staging", compareTo: "prod", now: NOW }));
|
|
346
|
+
expectValid(doc);
|
|
347
|
+
const [site, web, api] = doc.members;
|
|
348
|
+
expect(web.gateLedger).toEqual({ layout: "members", path: "_members/web/_gates", shared: false, malformed: 0, reason: null });
|
|
349
|
+
expect(web.gates).toEqual([
|
|
350
|
+
{
|
|
351
|
+
component: "web",
|
|
352
|
+
name: "deploy",
|
|
353
|
+
env: "staging",
|
|
354
|
+
planDigest: D("1"),
|
|
355
|
+
state: "approved",
|
|
356
|
+
recordedAt: "2026-09-20T00:00:00.000Z",
|
|
357
|
+
expiresAt: "2026-12-31T00:00:00.000Z",
|
|
358
|
+
approvals: [{ principal: "alice", channel: "cli", at: "2026-09-20T01:00:00.000Z" }],
|
|
359
|
+
needed: 1,
|
|
360
|
+
approve: "chant approve web deploy --env staging",
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
component: "web",
|
|
364
|
+
name: "deploy",
|
|
365
|
+
env: "prod",
|
|
366
|
+
planDigest: D("2"),
|
|
367
|
+
state: "pending",
|
|
368
|
+
recordedAt: "2026-09-21T00:00:00.000Z",
|
|
369
|
+
expiresAt: "2026-12-31T00:00:00.000Z",
|
|
370
|
+
approvals: [],
|
|
371
|
+
needed: 1,
|
|
372
|
+
approve: "chant approve web deploy --env prod",
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
component: "web",
|
|
376
|
+
name: "review",
|
|
377
|
+
env: "staging",
|
|
378
|
+
planDigest: D("4"),
|
|
379
|
+
state: "pending",
|
|
380
|
+
recordedAt: "2026-09-20T00:00:00.000Z",
|
|
381
|
+
expiresAt: "2026-12-31T00:00:00.000Z",
|
|
382
|
+
// The agent's approval doesn't count toward the quorum.
|
|
383
|
+
approvals: [{ principal: "bob", channel: "cli", at: "2026-09-20T02:00:00.000Z" }],
|
|
384
|
+
needed: 2,
|
|
385
|
+
approve: "chant approve web review --env staging",
|
|
386
|
+
},
|
|
387
|
+
]);
|
|
388
|
+
// api and the root member both read the flat _gates/.
|
|
389
|
+
expect(api.gateLedger).toEqual({ layout: "flat", path: "_gates", shared: true, malformed: 1, reason: null });
|
|
390
|
+
expect(site.gates).toEqual(api.gates);
|
|
391
|
+
expect(api.gates.map((g) => [g.component, g.name, g.env, g.state, g.approve])).toEqual([
|
|
392
|
+
["api", "deploy", "staging", "expired", "chant approve api deploy --env staging"],
|
|
393
|
+
["api", "smoke", "staging", "superseded", "chant approve api smoke --env staging"],
|
|
394
|
+
["nightly", "confirm", null, "approved", "chant approve nightly confirm"],
|
|
395
|
+
]);
|
|
396
|
+
// The superseding approval named another plan, so it isn't listed as one that counts.
|
|
397
|
+
expect(api.gates[1].approvals).toEqual([]);
|
|
398
|
+
expect(api.gates[2]).toMatchObject({ planDigest: null, approvals: [{ principal: "dave", channel: "cli" }] });
|
|
399
|
+
// Gate reasons and gate states don't change readable or the summary.
|
|
400
|
+
expect(doc.summary.unreadable).toBe(0);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("only the environments asked for are listed, and the text view doesn't change", async () => {
|
|
404
|
+
const root = gated();
|
|
405
|
+
const doc = result(await workspaceStatus({ cwd: root, env: "prod", now: NOW }));
|
|
406
|
+
expectValid(doc);
|
|
407
|
+
const web = doc.members.find((m) => m.name === "web")!;
|
|
408
|
+
expect(web.gates.map((g) => [g.name, g.env])).toEqual([["deploy", "prod"]]);
|
|
409
|
+
const plain = { ...doc, members: doc.members.map((m) => ({ ...m, gates: [], gateLedger: { ...m.gateLedger, reason: null } })) };
|
|
410
|
+
expect(formatStatus(doc)).toBe(formatStatus(plain));
|
|
411
|
+
expect(formatStatus(doc)).not.toContain("deploy");
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("a member with no gate ledger, a checkout with no branch, and an unreadable ledger each carry a reason", async () => {
|
|
415
|
+
const root = twoLayouts();
|
|
416
|
+
const none = result(await workspaceStatus({ cwd: root, env: "staging", now: NOW }));
|
|
417
|
+
expectValid(none);
|
|
418
|
+
expect(none.members.map((m) => [m.name, m.gateLedger.reason?.code, m.gates.length])).toEqual([
|
|
419
|
+
["site", "gates-no-gate-ledger", 0],
|
|
420
|
+
["web", "gates-no-gate-ledger", 0],
|
|
421
|
+
["api", "gates-no-gate-ledger", 0],
|
|
422
|
+
]);
|
|
423
|
+
expect(none.members[1].gateLedger.reason!.message).toBe("_members/web/_gates does not exist on chant/lifecycle: no run has reached a gate");
|
|
424
|
+
|
|
425
|
+
const bare = repo({ "chant.workspace.json": declaration([{ name: "web", dir: "web", kind: "chant" }]) });
|
|
426
|
+
const noBranch = result(await workspaceStatus({ cwd: bare, env: "prod", now: NOW }));
|
|
427
|
+
expectValid(noBranch);
|
|
428
|
+
expect(noBranch.members[0].gateLedger).toMatchObject({ layout: "flat", path: "_gates", reason: { code: "gates-no-ledger" } });
|
|
429
|
+
|
|
430
|
+
const broken = result(
|
|
431
|
+
await workspaceStatus({
|
|
432
|
+
cwd: gated(),
|
|
433
|
+
env: "staging",
|
|
434
|
+
now: NOW,
|
|
435
|
+
readGates: async (dir) => {
|
|
436
|
+
if (dir === "_members/web/_gates") throw new Error("fatal: bad object\nmore");
|
|
437
|
+
return null;
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
);
|
|
441
|
+
expectValid(broken);
|
|
442
|
+
const web = broken.members.find((m) => m.name === "web")!;
|
|
443
|
+
expect(web.gateLedger.reason).toEqual({ code: "gates-ledger-unreadable", message: "_members/web/_gates: fatal: bad object" });
|
|
444
|
+
expect(web.gates).toEqual([]);
|
|
445
|
+
expect(web.readable).toBe(true);
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("the JSON the command prints carries the gates", async () => {
|
|
449
|
+
const root = gated();
|
|
450
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
451
|
+
try {
|
|
452
|
+
expect(await runWorkspaceStatus({ args: { extraPositional: "staging", extraPositional2: root, json: true } } as unknown as CommandContext)).toBe(0);
|
|
453
|
+
const printed = JSON.parse(String(log.mock.calls.at(-1)?.[0])) as StatusDocument;
|
|
454
|
+
expectValid(printed);
|
|
455
|
+
expect(result(printed).members.find((m) => m.name === "web")!.gates.map((g) => g.approve)).toContain("chant approve web deploy --env staging");
|
|
456
|
+
} finally {
|
|
457
|
+
log.mockRestore();
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
});
|
|
461
|
+
|
|
299
462
|
describe("chant workspace status on the chant repo (#2557)", () => {
|
|
300
463
|
test("validates and lists every declared member", async () => {
|
|
301
464
|
const doc = result(await workspaceStatus({ cwd: join(REPO, "packages", "core"), env: "prod", compareTo: "staging" }));
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gate state for `chant workspace status --json` (#2674): each member's gates
|
|
3
|
+
* as its gate ledger on `chant/lifecycle` records them, so a reader such as
|
|
4
|
+
* hud never runs `git show` on the branch itself.
|
|
5
|
+
*
|
|
6
|
+
* A member's gate ledger is the `_gates/` directory under its ledger prefix:
|
|
7
|
+
* `_members/<member>/_gates/<component>.jsonl` for a member that writes the
|
|
8
|
+
* member layout (#2538), and the flat `_gates/<component>.jsonl` otherwise,
|
|
9
|
+
* chosen the same way as its release ledger. Each file is keyed by the
|
|
10
|
+
* component, or by the op for a gate an Op run reached, and holds both the
|
|
11
|
+
* pending facts a run records and the approvals `chant approve` records
|
|
12
|
+
* (`../lifecycle/gate-ledger.ts`).
|
|
13
|
+
*
|
|
14
|
+
* A gate's state is decided the way a run decides it (`evaluateGate` in
|
|
15
|
+
* `../op/gate.ts`), without writing anything: the newest pending fact for the
|
|
16
|
+
* gate in its environment is the anchor, only approvals recorded since it and
|
|
17
|
+
* for its plan count, and a gate with an `approval` block counts them with
|
|
18
|
+
* `tallyGateApprovals`, as `describeApprovalProgress` does after an approve.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import type { GateResolutionRecord, PendingGateRecord } from "../lifecycle/gate-ledger";
|
|
23
|
+
import { isPendingGateExpired, latestPendingGate, latestResolutionForPlan, parseGateLedger } from "../lifecycle/gate-ledger";
|
|
24
|
+
import { tallyGateApprovals } from "../op/gate";
|
|
25
|
+
import type { ReasonCode } from "./reason-codes";
|
|
26
|
+
|
|
27
|
+
/** Why a member's gates can't be listed. Closed: a new code is a contract change. */
|
|
28
|
+
export const GATE_REASON_CODES = [
|
|
29
|
+
/** The checkout has no `chant/lifecycle` branch, so there is no ledger to read gates from. */
|
|
30
|
+
"gates-no-ledger",
|
|
31
|
+
/** The branch has no gate ledger for the member: no run of it has reached a gate. */
|
|
32
|
+
"gates-no-gate-ledger",
|
|
33
|
+
/** Reading the member's gate ledger failed, so no gate is listed. */
|
|
34
|
+
"gates-ledger-unreadable",
|
|
35
|
+
] as const satisfies readonly ReasonCode[];
|
|
36
|
+
export type GateReasonCode = (typeof GATE_REASON_CODES)[number];
|
|
37
|
+
|
|
38
|
+
export type GateState = "pending" | "approved" | "expired" | "superseded";
|
|
39
|
+
|
|
40
|
+
export interface StatusGateApproval {
|
|
41
|
+
/** Who recorded it: `chant approve --approver`, or the CI or shell identity. */
|
|
42
|
+
principal: string;
|
|
43
|
+
/** The channel it was recorded on (`cli`, `mcp`, `acp`), or null for a record older than chant#2384. */
|
|
44
|
+
channel: string | null;
|
|
45
|
+
at: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface StatusGate {
|
|
49
|
+
/** The ledger file's key: the component, or the op for a gate an Op run reached. */
|
|
50
|
+
component: string;
|
|
51
|
+
/** The gate's name. */
|
|
52
|
+
name: string;
|
|
53
|
+
/** The environment the gate was reached in, or null for a gate that records none (an Op gate). */
|
|
54
|
+
env: string | null;
|
|
55
|
+
/** The plan the run reached the gate with, or null for a gate that binds no plan. */
|
|
56
|
+
planDigest: string | null;
|
|
57
|
+
state: GateState;
|
|
58
|
+
/** When the run recorded the pending fact the state is read against. */
|
|
59
|
+
recordedAt: string;
|
|
60
|
+
expiresAt: string;
|
|
61
|
+
/** The approvals that count toward the gate for this plan, oldest first. */
|
|
62
|
+
approvals: StatusGateApproval[];
|
|
63
|
+
/** How many human approvals the gate needs: its quorum's count, 1 without one. */
|
|
64
|
+
needed: number;
|
|
65
|
+
/** The command that approves it, run in the member's directory. */
|
|
66
|
+
approve: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StatusGateLedger {
|
|
70
|
+
/** `members` for `_members/<member>/_gates`, `flat` for `_gates`. */
|
|
71
|
+
layout: "members" | "flat";
|
|
72
|
+
/** The gate ledger directory on the branch. */
|
|
73
|
+
path: string;
|
|
74
|
+
/** True when more than one member in this output reads the same flat directory. */
|
|
75
|
+
shared: boolean;
|
|
76
|
+
/** Lines that are neither a pending fact nor an approval, skipped. */
|
|
77
|
+
malformed: number;
|
|
78
|
+
reason: { code: GateReasonCode; message: string } | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Reads the gate ledger files in `dir` on the branch at `commit`: file name
|
|
83
|
+
* without `.jsonl` to contents. Null when the directory doesn't exist; throws
|
|
84
|
+
* when it can't be read.
|
|
85
|
+
*/
|
|
86
|
+
export type GateLedgerReader = (dir: string, commit: string, cwd: string) => Promise<Map<string, string> | null>;
|
|
87
|
+
|
|
88
|
+
function git(cwd: string, args: string[]): string {
|
|
89
|
+
return execFileSync("git", args, { cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const defaultGateLedgerReader: GateLedgerReader = async (dir, commit, cwd) => {
|
|
93
|
+
try {
|
|
94
|
+
git(cwd, ["rev-parse", "--verify", "--quiet", `${commit}:${dir}`]);
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const files = new Map<string, string>();
|
|
99
|
+
for (const name of git(cwd, ["ls-tree", "--name-only", `${commit}:${dir}`]).split("\n")) {
|
|
100
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
101
|
+
files.set(name.slice(0, -".jsonl".length), git(cwd, ["cat-file", "blob", `${commit}:${dir}/${name}`]));
|
|
102
|
+
}
|
|
103
|
+
return files;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The exact `chant approve` line for a gate. */
|
|
107
|
+
export function approveCommand(component: string, gate: string, env: string | null): string {
|
|
108
|
+
return `chant approve ${component} ${gate}${env !== null ? ` --env ${env}` : ""}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const approvalOf = (r: GateResolutionRecord): StatusGateApproval => ({ principal: r.resolvedBy, channel: r.origin ?? null, at: r.timestamp });
|
|
112
|
+
|
|
113
|
+
/** One gate in one environment, decided against its newest pending fact. */
|
|
114
|
+
function decide(component: string, standing: PendingGateRecord, resolutions: GateResolutionRecord[], now: string): StatusGate {
|
|
115
|
+
const gate = standing.gate;
|
|
116
|
+
const env = standing.environment ?? null;
|
|
117
|
+
const since = standing.timestamp;
|
|
118
|
+
const planDigest = standing.planDigest;
|
|
119
|
+
// #2574: an approval recorded for another environment doesn't count; one
|
|
120
|
+
// that records none is kept, so it reads as a mismatch, as a run reads it.
|
|
121
|
+
const own = env === null ? resolutions : resolutions.filter((r) => r.environment === undefined || r.environment === env);
|
|
122
|
+
|
|
123
|
+
let approved: boolean;
|
|
124
|
+
let approvals: GateResolutionRecord[];
|
|
125
|
+
let needed = 1;
|
|
126
|
+
let mismatched: GateResolutionRecord | undefined;
|
|
127
|
+
if (standing.approval) {
|
|
128
|
+
const tally = tallyGateApprovals(own, gate, since, planDigest, standing.approval);
|
|
129
|
+
approved = tally.permit !== undefined || tally.counted.length >= tally.need;
|
|
130
|
+
approvals = tally.permit && !tally.counted.includes(tally.permit) ? [...tally.counted, tally.permit] : tally.counted;
|
|
131
|
+
needed = tally.need;
|
|
132
|
+
mismatched = tally.mismatched;
|
|
133
|
+
} else {
|
|
134
|
+
const at = (r: GateResolutionRecord) => new Date(r.timestamp).getTime();
|
|
135
|
+
approvals = own
|
|
136
|
+
.filter((r) => r.gate === gate && at(r) >= new Date(since).getTime() && (planDigest === undefined || r.planDigest === planDigest))
|
|
137
|
+
.sort((a, b) => at(a) - at(b));
|
|
138
|
+
approved = approvals.length > 0;
|
|
139
|
+
mismatched = latestResolutionForPlan(own, gate, since, planDigest).mismatched;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const state: GateState = approved ? "approved" : isPendingGateExpired(standing, now) ? "expired" : mismatched ? "superseded" : "pending";
|
|
143
|
+
return {
|
|
144
|
+
component,
|
|
145
|
+
name: gate,
|
|
146
|
+
env,
|
|
147
|
+
planDigest: planDigest ?? null,
|
|
148
|
+
state,
|
|
149
|
+
recordedAt: standing.timestamp,
|
|
150
|
+
expiresAt: standing.expiresAt,
|
|
151
|
+
approvals: approvals.map(approvalOf),
|
|
152
|
+
needed,
|
|
153
|
+
approve: approveCommand(component, gate, env),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Every gate in one ledger file, one per gate and environment, for the
|
|
159
|
+
* environments in `envs`. A gate whose facts record no environment (an Op
|
|
160
|
+
* gate) is listed whatever `envs` is. A pending fact with no environment on a
|
|
161
|
+
* gate whose other facts have one is left out, as a run leaves it out: such a
|
|
162
|
+
* line binds no environment, and `chant approve --expire` writes one.
|
|
163
|
+
*/
|
|
164
|
+
export function gatesInLedger(component: string, content: string, envs: readonly string[], now: string): { gates: StatusGate[]; malformed: number } {
|
|
165
|
+
const { pending, resolutions, malformed } = parseGateLedger(content);
|
|
166
|
+
const gates: StatusGate[] = [];
|
|
167
|
+
for (const gate of [...new Set(pending.map((p) => p.gate))].sort()) {
|
|
168
|
+
const facts = pending.filter((p) => p.gate === gate);
|
|
169
|
+
const bound = facts.some((p) => p.environment !== undefined);
|
|
170
|
+
const byEnv = new Map<string | null, PendingGateRecord[]>();
|
|
171
|
+
for (const p of facts) {
|
|
172
|
+
const env = p.environment ?? null;
|
|
173
|
+
if (bound && env === null) continue;
|
|
174
|
+
if (env !== null && !envs.includes(env)) continue;
|
|
175
|
+
byEnv.set(env, [...(byEnv.get(env) ?? []), p]);
|
|
176
|
+
}
|
|
177
|
+
for (const env of [...byEnv.keys()].sort((a, b) => envs.indexOf(a ?? "") - envs.indexOf(b ?? ""))) {
|
|
178
|
+
gates.push(decide(component, latestPendingGate(byEnv.get(env)!, gate)!, resolutions, now));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { gates, malformed };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Read one member's gate ledger directory at `commit` and list its gates. */
|
|
185
|
+
export async function readMemberGates(
|
|
186
|
+
dir: string,
|
|
187
|
+
layout: "members" | "flat",
|
|
188
|
+
commit: string | null,
|
|
189
|
+
envs: readonly string[],
|
|
190
|
+
cwd: string,
|
|
191
|
+
now: string,
|
|
192
|
+
read: GateLedgerReader = defaultGateLedgerReader,
|
|
193
|
+
): Promise<{ gates: StatusGate[]; ledger: StatusGateLedger }> {
|
|
194
|
+
const ledger: StatusGateLedger = { layout, path: dir, shared: false, malformed: 0, reason: null };
|
|
195
|
+
if (commit === null) {
|
|
196
|
+
return { gates: [], ledger: { ...ledger, reason: { code: "gates-no-ledger", message: "the checkout has no chant/lifecycle branch, so no gate is recorded in it" } } };
|
|
197
|
+
}
|
|
198
|
+
let files: Map<string, string> | null;
|
|
199
|
+
try {
|
|
200
|
+
files = await read(dir, commit, cwd);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const message = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
203
|
+
return { gates: [], ledger: { ...ledger, reason: { code: "gates-ledger-unreadable", message: `${dir}: ${message}` } } };
|
|
204
|
+
}
|
|
205
|
+
if (files === null) {
|
|
206
|
+
return { gates: [], ledger: { ...ledger, reason: { code: "gates-no-gate-ledger", message: `${dir} does not exist on chant/lifecycle: no run has reached a gate` } } };
|
|
207
|
+
}
|
|
208
|
+
const gates: StatusGate[] = [];
|
|
209
|
+
for (const component of [...files.keys()].sort()) {
|
|
210
|
+
const read = gatesInLedger(component, files.get(component)!, envs, now);
|
|
211
|
+
gates.push(...read.gates);
|
|
212
|
+
ledger.malformed += read.malformed;
|
|
213
|
+
}
|
|
214
|
+
return { gates, ledger };
|
|
215
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
3
|
"$id": "https://intentius.io/chant/schemas/workspace/status/v1/status.schema.json",
|
|
4
4
|
"title": "chant workspace status output",
|
|
5
|
-
"description": "What `chant workspace status <env> --json` prints, version 1 of the read contract for releases across members (#2524 D15, D19, #2544). Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`.",
|
|
5
|
+
"description": "What `chant workspace status <env> --json` prints, version 1 of the read contract for releases across members (#2524 D15, D19, #2544). Readers ignore fields they do not know; a field is only ever added within a version. The reason and error codes are closed lists: a new code is a new contract version. Contract version 1 is written by chant 0.81.0 and newer; a reader that needs it refuses output whose `contract` it doesn't know. Every code is in the one closed list of `reason-codes.ts`. Each member's gates, read from its gate ledger on the same branch, are in the JSON only (#2674).",
|
|
6
6
|
"oneOf": [{ "$ref": "#/$defs/result" }, { "$ref": "#/$defs/failure" }],
|
|
7
7
|
"$defs": {
|
|
8
8
|
"result": {
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
},
|
|
68
68
|
"member": {
|
|
69
69
|
"type": "object",
|
|
70
|
-
"required": ["name", "dir", "kind", "environments", "compare", "readable"],
|
|
70
|
+
"required": ["name", "dir", "kind", "environments", "compare", "readable", "gateLedger", "gates"],
|
|
71
71
|
"properties": {
|
|
72
72
|
"name": { "type": "string" },
|
|
73
73
|
"dir": { "type": "string", "description": "Relative to the workspace root, with / separators; \".\" is the root member." },
|
|
@@ -83,7 +83,73 @@
|
|
|
83
83
|
"description": "Null without --compare-to.",
|
|
84
84
|
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/compare" }]
|
|
85
85
|
},
|
|
86
|
-
"readable": { "type": "boolean", "description": "True exactly when no environment has a reason." }
|
|
86
|
+
"readable": { "type": "boolean", "description": "True exactly when no environment has a reason. A gate ledger reason doesn't change it." },
|
|
87
|
+
"gateLedger": { "$ref": "#/$defs/gateLedger" },
|
|
88
|
+
"gates": {
|
|
89
|
+
"description": "Each gate the member's gate ledger records, one per gate and environment, for env and compareTo. A gate that records no environment (an Op gate) is listed whatever the environment. Sorted by component, then gate. Empty when gateLedger has a reason.",
|
|
90
|
+
"type": "array",
|
|
91
|
+
"items": { "$ref": "#/$defs/gate" }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"gateLedger": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"required": ["layout", "path", "shared", "malformed", "reason"],
|
|
98
|
+
"properties": {
|
|
99
|
+
"layout": {
|
|
100
|
+
"enum": ["members", "flat"],
|
|
101
|
+
"description": "members: _members/<member>/_gates. flat: _gates. Chosen as for the member's release ledger."
|
|
102
|
+
},
|
|
103
|
+
"path": { "type": "string", "description": "The gate ledger directory on chant/lifecycle. Each file in it is <component>.jsonl, or <op>.jsonl for a gate an Op run reached." },
|
|
104
|
+
"shared": { "type": "boolean", "description": "True when more than one member in this output reads the flat _gates directory, so they list the same gates." },
|
|
105
|
+
"malformed": { "type": "integer", "minimum": 0, "description": "Lines that are neither a pending fact nor an approval. They are skipped." },
|
|
106
|
+
"reason": {
|
|
107
|
+
"oneOf": [
|
|
108
|
+
{ "type": "null" },
|
|
109
|
+
{
|
|
110
|
+
"type": "object",
|
|
111
|
+
"required": ["code", "message"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"code": { "enum": ["gates-no-ledger", "gates-no-gate-ledger", "gates-ledger-unreadable"] },
|
|
114
|
+
"message": { "type": "string" }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
"gate": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"required": ["component", "name", "env", "planDigest", "state", "recordedAt", "expiresAt", "approvals", "needed", "approve"],
|
|
124
|
+
"properties": {
|
|
125
|
+
"component": { "type": "string", "description": "The ledger file's key: the component, or the op for a gate an Op run reached." },
|
|
126
|
+
"name": { "type": "string", "description": "The gate's name." },
|
|
127
|
+
"env": {
|
|
128
|
+
"description": "The environment the run reached the gate in, or null for a gate that records none.",
|
|
129
|
+
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/env" }]
|
|
130
|
+
},
|
|
131
|
+
"planDigest": { "type": ["string", "null"], "description": "The plan the run reached the gate with (#2300), or null for a gate that binds no plan. An approval counts only for this plan." },
|
|
132
|
+
"state": {
|
|
133
|
+
"enum": ["pending", "approved", "expired", "superseded"],
|
|
134
|
+
"description": "Read from the newest pending fact for the gate in env, as a run reads it. approved: the approvals recorded since it, for its plan, pass the gate (its quorum, or a policy permit in enforce mode). expired: not approved, and the pending fact is past expiresAt, so the next run records a fresh one. superseded: not approved, and an approval recorded since it names a different plan, so it doesn't count. pending: none of these."
|
|
135
|
+
},
|
|
136
|
+
"recordedAt": { "type": "string", "description": "When the run recorded the pending fact." },
|
|
137
|
+
"expiresAt": { "type": "string" },
|
|
138
|
+
"approvals": {
|
|
139
|
+
"description": "The approvals that count toward the gate for its plan, oldest first.",
|
|
140
|
+
"type": "array",
|
|
141
|
+
"items": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"required": ["principal", "channel", "at"],
|
|
144
|
+
"properties": {
|
|
145
|
+
"principal": { "type": "string", "description": "Who recorded it." },
|
|
146
|
+
"channel": { "type": ["string", "null"], "description": "The channel it was recorded on, such as cli or mcp, or null for a record older than chant#2384." },
|
|
147
|
+
"at": { "type": "string" }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
"needed": { "type": "integer", "minimum": 1, "description": "The human approvals the gate needs: its quorum's count, 1 without one." },
|
|
152
|
+
"approve": { "type": "string", "description": "The chant approve line for the gate, run in the member's directory: chant approve <component> <gate>, with --env <env> when env is not null." }
|
|
87
153
|
}
|
|
88
154
|
},
|
|
89
155
|
"environment": {
|