@intentius/chant 0.95.0 → 0.96.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.
Files changed (83) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/cli/mcp/server.d.ts +10 -4
  3. package/dist/cli/mcp/server.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +9 -1
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/lifecycle/run-ledger.d.ts +2 -0
  7. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  8. package/dist/op/activities/shell.d.ts.map +1 -1
  9. package/dist/op/index.d.ts +2 -0
  10. package/dist/op/index.d.ts.map +1 -1
  11. package/dist/op/local-executor.d.ts.map +1 -1
  12. package/dist/op/operator.d.ts.map +1 -1
  13. package/dist/op/run-live.d.ts +127 -0
  14. package/dist/op/run-live.d.ts.map +1 -0
  15. package/dist/op/runtime.d.ts +2 -0
  16. package/dist/op/runtime.d.ts.map +1 -1
  17. package/dist/workspace/checks/diagrams.d.ts +5 -0
  18. package/dist/workspace/checks/diagrams.d.ts.map +1 -1
  19. package/dist/workspace/declaration.d.ts +5 -5
  20. package/dist/workspace/declaration.d.ts.map +1 -1
  21. package/dist/workspace/declaration.schema.json +50 -7
  22. package/dist/workspace/intent-cli.d.ts +6 -0
  23. package/dist/workspace/intent-cli.d.ts.map +1 -1
  24. package/dist/workspace/intent-record.d.ts +145 -0
  25. package/dist/workspace/intent-record.d.ts.map +1 -0
  26. package/dist/workspace/intent.d.ts +46 -1
  27. package/dist/workspace/intent.d.ts.map +1 -1
  28. package/dist/workspace/ls.d.ts +2 -2
  29. package/dist/workspace/ls.d.ts.map +1 -1
  30. package/dist/workspace/patch.d.ts +122 -0
  31. package/dist/workspace/patch.d.ts.map +1 -0
  32. package/dist/workspace/points-cli.d.ts +8 -0
  33. package/dist/workspace/points-cli.d.ts.map +1 -1
  34. package/dist/workspace/reason-codes.d.ts +2 -0
  35. package/dist/workspace/reason-codes.d.ts.map +1 -1
  36. package/dist/workspace/record-decided.d.ts +36 -0
  37. package/dist/workspace/record-decided.d.ts.map +1 -0
  38. package/dist/workspace/records-cli.d.ts +8 -0
  39. package/dist/workspace/records-cli.d.ts.map +1 -1
  40. package/dist/workspace/status-stewards.d.ts +79 -12
  41. package/dist/workspace/status-stewards.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/cli/main.test.ts +16 -0
  44. package/src/cli/main.ts +34 -3
  45. package/src/cli/mcp/server.test.ts +14 -0
  46. package/src/cli/mcp/server.ts +10 -4
  47. package/src/cli/registry.ts +9 -1
  48. package/src/lifecycle/run-ledger.ts +7 -1
  49. package/src/op/activities/shell.ts +4 -1
  50. package/src/op/index.ts +2 -0
  51. package/src/op/local-executor.ts +51 -3
  52. package/src/op/operator.ts +2 -11
  53. package/src/op/run-live.test.ts +111 -0
  54. package/src/op/run-live.ts +305 -0
  55. package/src/op/runtime.ts +2 -0
  56. package/src/op/steward-points.test.ts +81 -0
  57. package/src/workspace/checks/diagrams.test.ts +24 -0
  58. package/src/workspace/checks/diagrams.ts +10 -3
  59. package/src/workspace/declaration.schema.json +50 -7
  60. package/src/workspace/declaration.test.ts +18 -0
  61. package/src/workspace/declaration.ts +7 -7
  62. package/src/workspace/intent-cli.ts +47 -1
  63. package/src/workspace/intent-record.schema.json +529 -0
  64. package/src/workspace/intent-record.test.ts +257 -0
  65. package/src/workspace/intent-record.ts +371 -0
  66. package/src/workspace/intent.schema.json +33 -1
  67. package/src/workspace/intent.ts +29 -12
  68. package/src/workspace/ls-contract.test.ts +14 -0
  69. package/src/workspace/ls.schema.json +3 -3
  70. package/src/workspace/ls.ts +3 -3
  71. package/src/workspace/patch.schema.json +321 -0
  72. package/src/workspace/patch.test.ts +198 -0
  73. package/src/workspace/patch.ts +377 -0
  74. package/src/workspace/points-cli.ts +18 -0
  75. package/src/workspace/read-contract.test.ts +29 -1
  76. package/src/workspace/reason-codes.test.ts +2 -0
  77. package/src/workspace/reason-codes.ts +3 -0
  78. package/src/workspace/record-decided.ts +107 -0
  79. package/src/workspace/records-cli.ts +12 -0
  80. package/src/workspace/records.schema.json +15 -0
  81. package/src/workspace/status-contract.test.ts +76 -2
  82. package/src/workspace/status-stewards.ts +149 -27
  83. package/src/workspace/status.schema.json +69 -6
@@ -0,0 +1,305 @@
1
+ /**
2
+ * The in-flight run record: what an Op run is doing while it runs.
3
+ *
4
+ * The run ledger (`../lifecycle/run-ledger.ts`) gets one record when a run
5
+ * settles, so until then a reader sees the previous run. A run that writes to
6
+ * the ledger (the executor's `ledger` option) also keeps two files beside the
7
+ * checkout's git directory, outside the working tree and the lifecycle branch:
8
+ *
9
+ * - `<git-common-dir>/chant/runs/<key>.json`, the run's id, steward, work
10
+ * item, current phase and step, and the phases it has finished with their
11
+ * durations, rewritten whole as the run moves;
12
+ * - `<git-common-dir>/chant/runs/<key>.activity.jsonl`, lines a step's
13
+ * process appends while it works, one per line.
14
+ *
15
+ * `<key>` folds the member's ledger prefix, the env and the Op's name into one
16
+ * file name. Both files are removed once the run's ledger record is written.
17
+ * A run that died without removing them is recognised by its process: a record
18
+ * whose host is this one and whose pid is gone is not in flight.
19
+ *
20
+ * A step's process finds the activity file in `CHANT_RUN_ACTIVITY`, which the
21
+ * `shellCmd` activity sets for a run that keeps one, and appends a line to it:
22
+ * a JSON object `{"at": "<ISO-8601>", "text": "..."}`, or plain text. An
23
+ * in-process activity calls {@link reportRunActivity}.
24
+ */
25
+
26
+ import { AsyncLocalStorage } from "node:async_hooks";
27
+ import { execFile } from "node:child_process";
28
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
29
+ import { hostname } from "node:os";
30
+ import { isAbsolute, join, resolve } from "node:path";
31
+ import { promisify } from "node:util";
32
+ import { resolveMemberLedger } from "../lifecycle/member-ledger";
33
+
34
+ const execFileAsync = promisify(execFile);
35
+
36
+ /** The environment variable naming the activity file, for a step's process. */
37
+ export const RUN_ACTIVITY_ENV = "CHANT_RUN_ACTIVITY";
38
+ /** The environment variable naming the run, for a step's process. */
39
+ export const RUN_ID_ENV = "CHANT_RUN_ID";
40
+
41
+ /** How many activity lines a reader gets by default: the newest ones. */
42
+ export const IN_FLIGHT_ACTIVITY_LINES = 20;
43
+
44
+ /** A phase the run has finished. */
45
+ export interface InFlightPhase {
46
+ name: string;
47
+ status: "ok" | "fail" | "skipped";
48
+ durationMs: number;
49
+ }
50
+
51
+ /** The in-flight record as the run writes it. */
52
+ export interface InFlightRecord {
53
+ version: 1;
54
+ id: string;
55
+ op: string;
56
+ env: string;
57
+ steward: string | null;
58
+ /** The work item the run's lease holds, once it is claimed. */
59
+ item: string | null;
60
+ started: string;
61
+ /** When the record was last rewritten. */
62
+ updated: string;
63
+ host: string;
64
+ pid: number;
65
+ phase: { name: string; started: string } | null;
66
+ /** The newest step still running: its id when it has one, else its activity. */
67
+ step: { name: string; fn: string; started: string } | null;
68
+ phases: InFlightPhase[];
69
+ }
70
+
71
+ /** One activity line as a reader gets it. `seq` counts from 1 over the run. */
72
+ export interface InFlightActivityLine {
73
+ seq: number;
74
+ at: string | null;
75
+ text: string;
76
+ }
77
+
78
+ /** The in-flight record with its newest activity lines. */
79
+ export interface InFlightRun extends InFlightRecord {
80
+ activity: { total: number; lines: InFlightActivityLine[] };
81
+ }
82
+
83
+ /** The file name a run's record is kept under, the prefix and env folded in. */
84
+ function keyOf(prefix: string, env: string, op: string): string {
85
+ return `${prefix}${env}/${op}`.replace(/[\/\\]/g, "__").replace(/[^A-Za-z0-9._@-]/g, "_");
86
+ }
87
+
88
+ /** The checkout's shared git directory (the same for every worktree), or null outside a checkout. */
89
+ async function gitCommonDir(cwd: string): Promise<string | null> {
90
+ try {
91
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--git-common-dir"], { cwd });
92
+ const dir = stdout.trim();
93
+ return dir === "" ? null : isAbsolute(dir) ? dir : resolve(cwd, dir);
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /** Where the run of `op` in `env` for the project at `cwd` keeps its files, or null outside a checkout. */
100
+ export async function inFlightPaths(cwd: string, env: string, op: string): Promise<{ record: string; activity: string } | null> {
101
+ const common = await gitCommonDir(cwd);
102
+ if (!common) return null;
103
+ const { prefix } = await resolveMemberLedger(cwd);
104
+ const base = join(common, "chant", "runs", keyOf(prefix, env, op));
105
+ return { record: `${base}.json`, activity: `${base}.activity.jsonl` };
106
+ }
107
+
108
+ function processAlive(pid: number): boolean {
109
+ try {
110
+ process.kill(pid, 0);
111
+ return true;
112
+ } catch (err) {
113
+ return (err as NodeJS.ErrnoException).code === "EPERM";
114
+ }
115
+ }
116
+
117
+ /** One activity line appended to `file`. Never throws: a report is not worth failing a step for. */
118
+ function appendActivity(file: string, text: string): void {
119
+ const line = text.replace(/\s+/g, " ").trim();
120
+ if (line === "") return;
121
+ try {
122
+ appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), text: line })}\n`);
123
+ } catch {
124
+ // The run's files are gone or unwritable: the line is dropped.
125
+ }
126
+ }
127
+
128
+ /**
129
+ * A running Op's in-flight record. The executor opens one per run that
130
+ * writes to the ledger and calls it as the run moves. Every write is
131
+ * best-effort: a record that can't be written leaves the run as it was.
132
+ */
133
+ export class LiveRun {
134
+ private readonly rec: InFlightRecord;
135
+ private readonly running: InFlightRecord["step"][] = [];
136
+
137
+ private constructor(private readonly paths: { record: string; activity: string }, rec: InFlightRecord) {
138
+ this.rec = rec;
139
+ }
140
+
141
+ /** Open the record for a run starting now, or undefined when the project is not a checkout. */
142
+ static async open(opts: { cwd: string; env: string; op: string; id: string; started: string; steward?: string }): Promise<LiveRun | undefined> {
143
+ let paths: Awaited<ReturnType<typeof inFlightPaths>>;
144
+ try {
145
+ paths = await inFlightPaths(opts.cwd, opts.env, opts.op);
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ if (!paths) return undefined;
150
+ const run = new LiveRun(paths, {
151
+ version: 1,
152
+ id: opts.id,
153
+ op: opts.op,
154
+ env: opts.env,
155
+ steward: opts.steward ?? null,
156
+ item: null,
157
+ started: opts.started,
158
+ updated: opts.started,
159
+ host: hostname(),
160
+ pid: process.pid,
161
+ phase: null,
162
+ step: null,
163
+ phases: [],
164
+ });
165
+ try {
166
+ mkdirSync(join(paths.record, ".."), { recursive: true });
167
+ writeFileSync(paths.activity, "");
168
+ } catch {
169
+ return undefined;
170
+ }
171
+ run.write();
172
+ return run;
173
+ }
174
+
175
+ /** The file a step's process appends activity lines to. */
176
+ get activityFile(): string {
177
+ return this.paths.activity;
178
+ }
179
+
180
+ get id(): string {
181
+ return this.rec.id;
182
+ }
183
+
184
+ phaseStarted(name: string): void {
185
+ this.rec.phase = { name, started: new Date().toISOString() };
186
+ this.write();
187
+ }
188
+
189
+ phaseEnded(name: string, status: InFlightPhase["status"], durationMs: number): void {
190
+ this.rec.phases.push({ name, status, durationMs });
191
+ if (this.rec.phase?.name === name) this.rec.phase = null;
192
+ this.write();
193
+ }
194
+
195
+ /** A step started; the returned function says it ended. */
196
+ stepStarted(fn: string, id?: string): () => void {
197
+ const step = { name: id ?? fn, fn, started: new Date().toISOString() };
198
+ this.running.push(step);
199
+ this.rec.step = step;
200
+ this.write();
201
+ return () => {
202
+ const at = this.running.indexOf(step);
203
+ if (at >= 0) this.running.splice(at, 1);
204
+ this.rec.step = this.running.at(-1) ?? null;
205
+ this.write();
206
+ };
207
+ }
208
+
209
+ item(item: string): void {
210
+ this.rec.item = item;
211
+ this.write();
212
+ }
213
+
214
+ report(text: string): void {
215
+ appendActivity(this.paths.activity, text);
216
+ }
217
+
218
+ /** The run has settled and its ledger record is written: remove its files. */
219
+ close(): void {
220
+ for (const file of [this.paths.record, this.paths.activity]) {
221
+ try {
222
+ rmSync(file, { force: true });
223
+ } catch {
224
+ // Left behind; a reader drops it once this process is gone.
225
+ }
226
+ }
227
+ }
228
+
229
+ private write(): void {
230
+ this.rec.updated = new Date().toISOString();
231
+ const tmp = `${this.paths.record}.${process.pid}.tmp`;
232
+ try {
233
+ writeFileSync(tmp, JSON.stringify(this.rec));
234
+ renameSync(tmp, this.paths.record);
235
+ } catch {
236
+ // Best-effort, as the module doc says.
237
+ }
238
+ }
239
+ }
240
+
241
+ const context = new AsyncLocalStorage<LiveRun>();
242
+
243
+ /** Run `fn` as part of `live`'s run, so the steps under it find it. */
244
+ export function withLiveRun<T>(live: LiveRun | undefined, fn: () => Promise<T>): Promise<T> {
245
+ return live ? context.run(live, fn) : fn();
246
+ }
247
+
248
+ /** The run the calling step is part of, when it keeps an in-flight record. */
249
+ export function currentLiveRun(): LiveRun | undefined {
250
+ return context.getStore();
251
+ }
252
+
253
+ /** The variables a step's child process gets so it can report activity. Empty outside such a run. */
254
+ export function liveRunEnv(): Record<string, string> {
255
+ const live = context.getStore();
256
+ return live ? { [RUN_ACTIVITY_ENV]: live.activityFile, [RUN_ID_ENV]: live.id } : {};
257
+ }
258
+
259
+ /** Append an activity line to the run the calling activity is part of. A no-op outside one. */
260
+ export function reportRunActivity(text: string): void {
261
+ context.getStore()?.report(text);
262
+ }
263
+
264
+ function parseActivity(content: string, limit: number): InFlightRun["activity"] {
265
+ const raw = content.split("\n").filter((l) => l.trim() !== "");
266
+ const from = Math.max(0, raw.length - limit);
267
+ const lines = raw.slice(from).map((line, i): InFlightActivityLine => {
268
+ const seq = from + i + 1;
269
+ try {
270
+ const parsed = JSON.parse(line) as { at?: unknown; text?: unknown };
271
+ if (parsed && typeof parsed === "object" && typeof parsed.text === "string") {
272
+ return { seq, at: typeof parsed.at === "string" ? parsed.at : null, text: parsed.text };
273
+ }
274
+ } catch {
275
+ // Plain text.
276
+ }
277
+ return { seq, at: null, text: line.trim() };
278
+ });
279
+ return { total: raw.length, lines };
280
+ }
281
+
282
+ /**
283
+ * The run of `op` in `env` in flight for the project at `cwd`, with its
284
+ * newest `limit` activity lines, or null when none is. A record left by a
285
+ * process on this host that is gone is not in flight.
286
+ */
287
+ export async function readInFlightRun(cwd: string, env: string, op: string, limit = IN_FLIGHT_ACTIVITY_LINES): Promise<InFlightRun | null> {
288
+ const paths = await inFlightPaths(cwd, env, op);
289
+ if (!paths || !existsSync(paths.record)) return null;
290
+ let rec: InFlightRecord;
291
+ try {
292
+ rec = JSON.parse(readFileSync(paths.record, "utf8")) as InFlightRecord;
293
+ } catch {
294
+ return null;
295
+ }
296
+ if (rec.version !== 1 || typeof rec.id !== "string" || typeof rec.started !== "string") return null;
297
+ if (rec.host === hostname() && typeof rec.pid === "number" && !processAlive(rec.pid)) return null;
298
+ let content = "";
299
+ try {
300
+ content = readFileSync(paths.activity, "utf8");
301
+ } catch {
302
+ // No activity yet.
303
+ }
304
+ return { ...rec, phases: Array.isArray(rec.phases) ? rec.phases : [], activity: parseActivity(content, limit) };
305
+ }
package/src/op/runtime.ts CHANGED
@@ -66,6 +66,8 @@ export interface OpRunPhaseRecord {
66
66
  name: string;
67
67
  /** `fail` if any step failed, `skipped` if every step was skipped, else `ok`. */
68
68
  status: "ok" | "fail" | "skipped";
69
+ /** The phase's wall-clock time, for a phase that ran. Absent on records written before it was kept, and for a phase skipped whole. */
70
+ durationMs?: number;
69
71
  steps: OpRunStepRecord[];
70
72
  }
71
73
 
@@ -17,6 +17,7 @@ import { workspacePoints } from "../workspace/points-cli";
17
17
  import type { WireAnswer } from "../workspace/points";
18
18
  import { readMemberStewards } from "../workspace/status-stewards";
19
19
  import { readRunLedger } from "../lifecycle/run-ledger";
20
+ import { acquireLease, releaseLease } from "../lifecycle/lease";
20
21
  import type { ActivityFn, ActivityProfile } from "./activity-registry";
21
22
  import { runOpLocally } from "./local-executor";
22
23
  import { createBesideState, formatRoundLine, runOperatorRound, waitForBesideRuns } from "./operator";
@@ -422,3 +423,83 @@ describe("a waiting run of an Op beside the steward's turns (#2861)", () => {
422
423
  expect((await readMemberStewards(root, "local", "2027-01-01T00:11:00Z")).stewards.find((s) => s.name === "build-steward")!.waiting).toEqual([]);
423
424
  });
424
425
  });
426
+
427
+ describe("status lists a wait only while its question is open (studio box, 0.95.1)", () => {
428
+ const stewardShape = () =>
429
+ contract({ $schema: statusSchema.$schema, $id: `urn:test:status-steward-open-${Math.random()}`, $defs: statusSchema.$defs, $ref: "#/$defs/steward" });
430
+
431
+ /** A waiting run of `op` recorded as `steward`'s, and its ledger record. */
432
+ async function waitingRun(steward: string, op: OpConfig, release: string) {
433
+ const acts = new Map<string, ActivityFn>([
434
+ ...shipActivities([]),
435
+ ["askShip", async (args) => (await askPointInRun({ cwd: root, point: "ship-now", inputs: { release: args.release }, subject: release, on })).answer],
436
+ ]);
437
+ process.env[STEWARD_ENV] = steward;
438
+ try {
439
+ expect((await runOpLocally(op, acts, PROFILES, undefined, { ledger: { cwd: root } })).status).toBe("waiting");
440
+ } finally {
441
+ resetStewardTurn();
442
+ }
443
+ return (await readRunLedger("local", op.name, { cwd: root })).records.at(-1)!;
444
+ }
445
+
446
+ function declare(name: string, beside: OpConfig) {
447
+ const steward = declareSteward({ name, ops: [], beside: [{ op: beside, ready: { kind: "activity", fn: "readyShip", args: {} } }], capabilities: ["inference"] });
448
+ mkdirSync(join(root, "ops"), { recursive: true });
449
+ writeFileSync(join(root, "chant.config.json"), "{}\n");
450
+ writeFileSync(join(root, "ops", `${name}.op.ts`), `export const steward = ${JSON.stringify(steward)};\n`);
451
+ }
452
+
453
+ const entryOf = async (name: string) =>
454
+ (await readMemberStewards(root, "local", new Date().toISOString())).stewards.find((s) => s.name === name)!;
455
+
456
+ test("an open question is listed; once answered it is not, and lastRun.point has its state now", async () => {
457
+ const op = shipOp("open-dispatch", "r-40");
458
+ declare("open-steward", op);
459
+ const run = await waitingRun("open-steward", op, "r-40");
460
+ const question = run.point!.id;
461
+
462
+ const open = await entryOf("open-steward");
463
+ stewardShape().expectValid(open);
464
+ expect(open.waiting).toEqual([
465
+ { op: "open-dispatch", run: run.id, id: question, point: "ship-now", state: "escalated", path: run.point!.path, subject: "r-40", since: run.point!.since },
466
+ ]);
467
+
468
+ // A person answers; no run of the Op has started since, so the ledger's newest record still says waiting.
469
+ expect("error" in (await answerPoint({ cwd: root, id: question, answer: "yes", by: ["alice"], on }))).toBe(false);
470
+ expect((await readRunLedger("local", "open-dispatch", { cwd: root })).records.at(-1)).toMatchObject({ id: run.id, status: "waiting", point: { state: "escalated" } });
471
+ const answered = await entryOf("open-steward");
472
+ stewardShape().expectValid(answered);
473
+ expect(answered.waiting).toEqual([]);
474
+ expect(answered.ops[0].lastRun).toMatchObject({ id: run.id, status: "waiting", point: { id: question, state: "answered" } });
475
+ });
476
+
477
+ test("a newer run in flight, holding the Op's lease since the waiting run ended, drops the wait", async () => {
478
+ const op = shipOp("busy-dispatch", "r-41");
479
+ declare("busy-steward", op);
480
+ const run = await waitingRun("busy-steward", op, "r-41");
481
+ expect((await entryOf("busy-steward")).waiting.map((w) => w.run)).toEqual([run.id]);
482
+
483
+ // The next run takes the Op's lease and is still going: it has no ledger record yet.
484
+ const lease = await acquireLease("busy-dispatch", "busy-steward/busy-dispatch@box", { cwd: root });
485
+ expect(lease.acquired).toBe(true);
486
+ const busy = await entryOf("busy-steward");
487
+ stewardShape().expectValid(busy);
488
+ expect(busy.ops[0].beside!.lease).toMatchObject({ holder: "busy-steward/busy-dispatch@box", live: true });
489
+ expect(busy.ops[0].lastRun).toMatchObject({ id: run.id, status: "waiting", point: { state: "escalated" } });
490
+ expect(busy.waiting).toEqual([]);
491
+
492
+ // Once it lets go without a record, the question is still open and waited on again.
493
+ await releaseLease("busy-dispatch", "busy-steward/busy-dispatch@box", lease.lease!.token, { cwd: root });
494
+ expect((await entryOf("busy-steward")).waiting.map((w) => w.run)).toEqual([run.id]);
495
+ });
496
+
497
+ test("when the questions can't be read, the run ledger's state stands and the wait is listed", async () => {
498
+ const op = shipOp("blind-dispatch", "r-42");
499
+ declare("blind-steward", op);
500
+ const run = await waitingRun("blind-steward", op, "r-42");
501
+ expect("error" in (await answerPoint({ cwd: root, id: run.point!.id, answer: "yes", by: ["alice"], on }))).toBe(false);
502
+ const entry = (await readMemberStewards(root, "local", new Date().toISOString(), "chant", null, { readQuestions: async () => null })).stewards.find((s) => s.name === "blind-steward")!;
503
+ expect(entry.waiting).toMatchObject([{ run: run.id, state: "escalated" }]);
504
+ });
505
+ });
@@ -123,6 +123,30 @@ describe("diagram-render-drift (WSP133)", () => {
123
123
  expect(d[0].message).toContain(`recorded sourceHash ${HASH.slice(0, 12)}`);
124
124
  });
125
125
 
126
+ test("a mermaid diagram with no render: WSP132 has nothing to check, and a recorded sourceHash pins the source", async () => {
127
+ const mermaid = (extra: Record<string, unknown> = {}) => ({ name: "flow", title: "Flow", source: "docs/diagrams/flow.mmd", renderer: { tool: "mermaid", version: "11.4.1" }, ...extra });
128
+ const pinned = repo({ "chant.workspace.json": declaration([mermaid({ sourceHash: HASH })]), "docs/diagrams/flow.mmd": SOURCE });
129
+ expect(await found(pinned)).toEqual([]);
130
+ const edited = repo({ "chant.workspace.json": declaration([mermaid({ sourceHash: HASH })]), "docs/diagrams/flow.mmd": "flowchart LR\n a --> b\n" });
131
+ const d = await found(edited);
132
+ expect(d.map((x) => [x.ruleId, x.code])).toEqual([["WSP133", "diagram-render-drift"]]);
133
+ expect(d[0].message).toContain("source docs/diagrams/flow.mmd changed since its sourceHash was recorded");
134
+ expect(d[0].message).toMatch(/; update sourceHash$/);
135
+ const missing = repo({ "chant.workspace.json": declaration([mermaid()]), "docs/README.md": "" });
136
+ expect((await found(missing)).map((x) => x.ruleId)).toEqual(["WSP131"]);
137
+ });
138
+
139
+ test("an excalidraw diagram's sourceHash pins its JSON, and an exported SVG it names must exist", async () => {
140
+ const scene = JSON.stringify({ type: "excalidraw", version: 2, source: "hud", elements: [], appState: {}, files: {} });
141
+ const hash = sha256Hex(Buffer.from(scene, "utf-8"));
142
+ const sketch = (extra: Record<string, unknown> = {}) => ({ name: "sketch", title: "Sketch", source: "docs/diagrams/sketch.excalidraw", renderer: { tool: "excalidraw", version: "0.18.0" }, sourceHash: hash, ...extra });
143
+ expect(await found(repo({ "chant.workspace.json": declaration([sketch()]), "docs/diagrams/sketch.excalidraw": scene }))).toEqual([]);
144
+ const moved = await found(repo({ "chant.workspace.json": declaration([sketch()]), "docs/diagrams/sketch.excalidraw": scene.replace("[]", '[{"id":"a"}]') }));
145
+ expect(moved.map((x) => x.ruleId)).toEqual(["WSP133"]);
146
+ const noSvg = await found(repo({ "chant.workspace.json": declaration([sketch({ render: "docs/diagrams/sketch.svg" })]), "docs/diagrams/sketch.excalidraw": scene }));
147
+ expect(noSvg.map((x) => x.ruleId)).toEqual(["WSP132"]);
148
+ });
149
+
126
150
  test("a missing source is left to WSP131; WSP133 finds nothing to compare", async () => {
127
151
  const root = repo({
128
152
  "chant.workspace.json": declaration([diagram({ sourceHash: HASH })]),
@@ -13,6 +13,11 @@
13
13
  * | WSP132 | `diagram-render-missing` | a diagram's render does not exist in the tree read |
14
14
  * | WSP133 | `diagram-render-drift` | a diagram records a `sourceHash`, and the source's bytes now hash to something else |
15
15
  *
16
+ * A mermaid or excalidraw diagram may name no render (a reader such as hud
17
+ * draws it from its source, with the pinned library): WSP132 then has nothing to
18
+ * check, and WSP133, when the entry records a sourceHash, pins the source
19
+ * itself, so an edit to it fails until the hash is updated.
20
+ *
16
21
  * WSP133 is opt-in per diagram: without a recorded `sourceHash` there is
17
22
  * nothing to compare, so the entry is silently not checked for drift. A
18
23
  * declaration records one by hashing the source when it commits a fresh
@@ -66,13 +71,13 @@ export const DIAGRAM_CHECKS: readonly WorkspaceCheck[] = [
66
71
  {
67
72
  id: WSP_DIAGRAM_RENDER_MISSING,
68
73
  name: "diagram-render-missing",
69
- description: "A diagram's render exists in the tree read.",
74
+ description: "A diagram's render, when it names one, exists in the tree read. Only a mermaid or excalidraw diagram may name none.",
70
75
  severity: "error",
71
76
  configurable: true,
72
77
  check(ctx: WorkspaceCheckContext) {
73
78
  const out: WorkspaceDiagnostic[] = [];
74
79
  for (const d of declaredDiagrams(ctx.declaration)) {
75
- if (ctx.tree.stat(d.render) === "file") continue;
80
+ if (d.render === null || ctx.tree.stat(d.render) === "file") continue;
76
81
  out.push(diagramFinding(this, d, "render", "diagram-render-missing", `${where(d)} diagram ${d.name} names the render ${d.render}, which does not exist${ctx.tree.label}`));
77
82
  }
78
83
  return out;
@@ -92,13 +97,15 @@ export const DIAGRAM_CHECKS: readonly WorkspaceCheck[] = [
92
97
  const bytes = ctx.tree.bytes ? ctx.tree.bytes(d.source) : Buffer.from(ctx.tree.read(d.source), "utf-8");
93
98
  const actual = sha256Hex(bytes);
94
99
  if (actual === d.sourceHash) continue;
100
+ const since = d.render === null ? "its sourceHash was recorded" : `${d.render} was rendered from it`;
101
+ const fix = d.render === null ? "update sourceHash" : "rerun the renderer and commit the result, or update sourceHash";
95
102
  out.push(
96
103
  diagramFinding(
97
104
  this,
98
105
  d,
99
106
  "sourceHash",
100
107
  "diagram-render-drift",
101
- `${where(d)} diagram ${d.name}'s source ${d.source} changed since ${d.render} was rendered from it: recorded sourceHash ${d.sourceHash.slice(0, 12)} does not match the source's current hash ${actual.slice(0, 12)}${ctx.tree.label}; rerun the renderer and commit the result, or update sourceHash`,
108
+ `${where(d)} diagram ${d.name}'s source ${d.source} changed since ${since}: recorded sourceHash ${d.sourceHash.slice(0, 12)} does not match the source's current hash ${actual.slice(0, 12)}${ctx.tree.label}; ${fix}`,
102
109
  ),
103
110
  );
104
111
  }
@@ -592,13 +592,53 @@
592
592
  },
593
593
  "diagram": {
594
594
  "type": "object",
595
- "description": "A diagram artifact the workspace declares (#2764): a rendered image with a source, pinned to the renderer that made it. chant never runs the renderer.",
595
+ "description": "A diagram artifact the workspace declares (#2764): a rendered image with a source, pinned to the renderer that made it. A mermaid or excalidraw diagram may name no render: a reader draws it from its source. chant never runs the renderer.",
596
596
  "required": [
597
597
  "name",
598
598
  "title",
599
- "render",
600
599
  "renderer"
601
600
  ],
601
+ "if": {
602
+ "type": "object",
603
+ "properties": {
604
+ "renderer": {
605
+ "type": "object",
606
+ "properties": {
607
+ "tool": {
608
+ "enum": ["mermaid", "excalidraw"]
609
+ }
610
+ },
611
+ "required": [
612
+ "tool"
613
+ ]
614
+ }
615
+ },
616
+ "required": [
617
+ "renderer"
618
+ ]
619
+ },
620
+ "then": {
621
+ "type": "object",
622
+ "properties": {
623
+ "source": {
624
+ "$ref": "#/$defs/path"
625
+ }
626
+ },
627
+ "required": [
628
+ "source"
629
+ ]
630
+ },
631
+ "else": {
632
+ "type": "object",
633
+ "properties": {
634
+ "render": {
635
+ "$ref": "#/$defs/path"
636
+ }
637
+ },
638
+ "required": [
639
+ "render"
640
+ ]
641
+ },
602
642
  "properties": {
603
643
  "name": {
604
644
  "$ref": "#/$defs/name",
@@ -617,8 +657,11 @@
617
657
  "description": "The source file, from the workspace root, with / separators. Null for an SVG with no source. Default null."
618
658
  },
619
659
  "render": {
620
- "$ref": "#/$defs/path",
621
- "description": "The rendered image, from the workspace root, with / separators."
660
+ "oneOf": [
661
+ { "$ref": "#/$defs/path" },
662
+ { "type": "null" }
663
+ ],
664
+ "description": "The rendered image, from the workspace root, with / separators. Required for d2 and graphviz. A mermaid or excalidraw diagram needs a source instead and may leave render out or null, from chant 0.96.0: a reader such as hud draws it from the source (an excalidraw diagram may still commit an exported SVG for docs)."
622
665
  },
623
666
  "renderer": {
624
667
  "$ref": "#/$defs/diagramRenderer"
@@ -626,7 +669,7 @@
626
669
  "sourceHash": {
627
670
  "type": ["string", "null"],
628
671
  "pattern": "^[0-9a-f]{64}$",
629
- "description": "sha256 hex of the source's bytes when render was last produced from it. chant workspace check compares this against the source in the tree read and reports drift (WSP133) on a mismatch; without it, or without a source, the render is never checked for drift. Default null."
672
+ "description": "sha256 hex of the source's bytes when render was last produced from it, or for a mermaid or excalidraw diagram without a render, when the source was last pinned. chant workspace check compares this against the source in the tree read and reports drift (WSP133) on a mismatch; without it, or without a source, the render is never checked for drift. Default null."
630
673
  }
631
674
  },
632
675
  "patternProperties": {
@@ -643,11 +686,11 @@
643
686
  ],
644
687
  "properties": {
645
688
  "tool": {
646
- "enum": ["d2", "mermaid", "graphviz"]
689
+ "enum": ["d2", "mermaid", "graphviz", "excalidraw"]
647
690
  },
648
691
  "version": {
649
692
  "$ref": "#/$defs/version",
650
- "description": "The exact release the render was made with, such as 0.9.0."
693
+ "description": "The exact release the render was made with, such as 0.9.0. For mermaid or excalidraw, the release of the library a reader draws the source with, such as 11.4.1."
651
694
  },
652
695
  "args": {
653
696
  "type": "array",
@@ -392,6 +392,24 @@ describe("diagram artifacts (#2764)", () => {
392
392
  expect(failure(base([member([{ name: "a", title: "A", source: null, render: "a.svg" }])])).message).toContain('missing required field "renderer"');
393
393
  });
394
394
 
395
+ test("a mermaid diagram needs a source and may name no render; d2 and graphviz still need a render", () => {
396
+ const mermaid = { tool: "mermaid", version: "11.4.1" };
397
+ const d = parse(base([member([{ name: "flow", title: "Flow", source: "docs/diagrams/flow.mmd", renderer: mermaid }])]));
398
+ expect(d.members[0].diagrams[0]).toMatchObject({ source: "docs/diagrams/flow.mmd", render: null, renderer: { ...mermaid, args: [] } });
399
+ expect(parse(base([member([{ name: "flow", title: "Flow", source: "flow.mmd", render: null, renderer: mermaid }])])).members[0].diagrams[0].render).toBeNull();
400
+ expect(parse(base([member([{ name: "flow", title: "Flow", source: "flow.mmd", render: "flow.svg", renderer: mermaid }])])).members[0].diagrams[0].render).toBe("flow.svg");
401
+ expect(failure(base([member([{ name: "flow", title: "Flow", renderer: mermaid }])])).message).toContain('missing required field "source"');
402
+ expect(failure(base([member([{ name: "flow", title: "Flow", source: null, renderer: mermaid }])])).code).toBe("declaration-invalid");
403
+ expect(failure(base([member([{ name: "a", title: "A", source: "a.d2", render: null, renderer }])])).code).toBe("declaration-invalid");
404
+ });
405
+
406
+ test("an excalidraw diagram, like mermaid, needs a source and may name no render or an exported SVG", () => {
407
+ const excalidraw = { tool: "excalidraw", version: "0.18.0" };
408
+ expect(parse(base([member([{ name: "sketch", title: "Sketch", source: "docs/diagrams/sketch.excalidraw", renderer: excalidraw }])])).members[0].diagrams[0]).toMatchObject({ render: null, renderer: { ...excalidraw, args: [] } });
409
+ expect(parse(base([member([{ name: "sketch", title: "Sketch", source: "sketch.excalidraw", render: "sketch.svg", renderer: excalidraw }])])).members[0].diagrams[0].render).toBe("sketch.svg");
410
+ expect(failure(base([member([{ name: "sketch", title: "Sketch", renderer: excalidraw }])])).message).toContain('missing required field "source"');
411
+ });
412
+
395
413
  test("a sourceHash is a 64-character lowercase hex string, or absent", () => {
396
414
  const hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
397
415
  expect(parse(base([member([diagram({ sourceHash: hash })])])).members[0].diagrams[0].sourceHash).toBe(hash);
@@ -140,13 +140,13 @@ export interface LinkDeclaration {
140
140
  }
141
141
 
142
142
  /** A renderer a diagram is pinned to (#2764): a closed list of tools chant does not run. */
143
- export const DIAGRAM_TOOLS = ["d2", "mermaid", "graphviz"] as const;
143
+ export const DIAGRAM_TOOLS = ["d2", "mermaid", "graphviz", "excalidraw"] as const;
144
144
  export type DiagramTool = (typeof DIAGRAM_TOOLS)[number];
145
145
 
146
146
  /** The renderer a diagram's render was made with (#2764). chant never runs it; it is recorded so a reader can. */
147
147
  export interface DiagramRenderer {
148
148
  tool: DiagramTool;
149
- /** The exact release the render was made with, such as "0.9.0". */
149
+ /** The exact release the render was made with, such as "0.9.0". For mermaid or excalidraw, the release of the library a reader draws the source with. */
150
150
  version: string;
151
151
  /** Passed before the input and output paths, in order. */
152
152
  args: string[];
@@ -162,10 +162,10 @@ export interface DiagramDeclaration {
162
162
  title: string;
163
163
  /** From the workspace root, with / separators. Null for an SVG with no source. */
164
164
  source: string | null;
165
- /** From the workspace root, with / separators. */
166
- render: string;
165
+ /** From the workspace root, with / separators. Null only for a mermaid or excalidraw diagram, which a reader draws from its source. */
166
+ render: string | null;
167
167
  renderer: DiagramRenderer;
168
- /** sha256 hex of the source's bytes when the render was last produced, for `chant workspace check`'s drift finding. Null when not recorded, or when source is null: the render is then never checked for drift. */
168
+ /** sha256 hex of the source's bytes when the render was last produced (a mermaid or excalidraw diagram without a render: when the source was last pinned), for `chant workspace check`'s drift finding. Null when not recorded, or when source is null: the render is then never checked for drift. */
169
169
  sourceHash: string | null;
170
170
  /** The member that declares it, or null for the workspace's own. */
171
171
  member: string | null;
@@ -725,13 +725,13 @@ function recordKindsOf(raw: unknown, dir: string, member: string | null, pointer
725
725
  function diagramsOf(raw: unknown, member: string | null, pointer: string): DiagramDeclaration[] {
726
726
  return (
727
727
  (raw as
728
- | { name: string; title: string; source?: string | null; render: string; renderer: { tool: DiagramTool; version: string; args?: string[] }; sourceHash?: string | null }[]
728
+ | { name: string; title: string; source?: string | null; render?: string | null; renderer: { tool: DiagramTool; version: string; args?: string[] }; sourceHash?: string | null }[]
729
729
  | undefined) ?? []
730
730
  ).map((d, i) => ({
731
731
  name: d.name,
732
732
  title: d.title,
733
733
  source: d.source ?? null,
734
- render: d.render,
734
+ render: d.render ?? null,
735
735
  renderer: { tool: d.renderer.tool, version: d.renderer.version, args: [...(d.renderer.args ?? [])] },
736
736
  sourceHash: d.sourceHash ?? null,
737
737
  member,