@intentius/chant 0.93.0 → 0.95.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 (149) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/mcp/workspace-plugins.d.ts +6 -6
  5. package/dist/cli/registry.d.ts +2 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/op/builders.d.ts +14 -3
  8. package/dist/op/builders.d.ts.map +1 -1
  9. package/dist/op/index.d.ts +6 -3
  10. package/dist/op/index.d.ts.map +1 -1
  11. package/dist/op/operator.d.ts +90 -0
  12. package/dist/op/operator.d.ts.map +1 -1
  13. package/dist/op/steward-beside.d.ts +84 -0
  14. package/dist/op/steward-beside.d.ts.map +1 -0
  15. package/dist/op/steward.d.ts +87 -2
  16. package/dist/op/steward.d.ts.map +1 -1
  17. package/dist/workspace/box-intent.d.ts +85 -0
  18. package/dist/workspace/box-intent.d.ts.map +1 -0
  19. package/dist/workspace/box-services.d.ts +31 -0
  20. package/dist/workspace/box-services.d.ts.map +1 -0
  21. package/dist/workspace/chant-migrations.d.ts +5 -0
  22. package/dist/workspace/chant-migrations.d.ts.map +1 -1
  23. package/dist/workspace/checks/boxes.d.ts +14 -1
  24. package/dist/workspace/checks/boxes.d.ts.map +1 -1
  25. package/dist/workspace/checks.d.ts +4 -0
  26. package/dist/workspace/checks.d.ts.map +1 -1
  27. package/dist/workspace/compose-graph.d.ts +11 -0
  28. package/dist/workspace/compose-graph.d.ts.map +1 -1
  29. package/dist/workspace/composites.d.ts +5 -1
  30. package/dist/workspace/composites.d.ts.map +1 -1
  31. package/dist/workspace/decision-points.schema.json +3 -3
  32. package/dist/workspace/declaration.d.ts +43 -0
  33. package/dist/workspace/declaration.d.ts.map +1 -1
  34. package/dist/workspace/declaration.schema.json +62 -1
  35. package/dist/workspace/graph-cache.d.ts +168 -0
  36. package/dist/workspace/graph-cache.d.ts.map +1 -0
  37. package/dist/workspace/graph-cli.d.ts +11 -5
  38. package/dist/workspace/graph-cli.d.ts.map +1 -1
  39. package/dist/workspace/intent-joins.d.ts +5 -5
  40. package/dist/workspace/kind-readers.d.ts +39 -0
  41. package/dist/workspace/kind-readers.d.ts.map +1 -0
  42. package/dist/workspace/kinds.d.ts +29 -0
  43. package/dist/workspace/kinds.d.ts.map +1 -1
  44. package/dist/workspace/member-commands.d.ts +15 -1
  45. package/dist/workspace/member-commands.d.ts.map +1 -1
  46. package/dist/workspace/member-run.d.ts +2 -0
  47. package/dist/workspace/member-run.d.ts.map +1 -1
  48. package/dist/workspace/points-cli.d.ts +2 -0
  49. package/dist/workspace/points-cli.d.ts.map +1 -1
  50. package/dist/workspace/points.d.ts +3 -5
  51. package/dist/workspace/points.d.ts.map +1 -1
  52. package/dist/workspace/reason-codes.d.ts +5 -1
  53. package/dist/workspace/reason-codes.d.ts.map +1 -1
  54. package/dist/workspace/records-cli.d.ts +10 -1
  55. package/dist/workspace/records-cli.d.ts.map +1 -1
  56. package/dist/workspace/records-write.d.ts +4 -2
  57. package/dist/workspace/records-write.d.ts.map +1 -1
  58. package/dist/workspace/records.d.ts +8 -3
  59. package/dist/workspace/records.d.ts.map +1 -1
  60. package/dist/workspace/status-stewards.d.ts +31 -9
  61. package/dist/workspace/status-stewards.d.ts.map +1 -1
  62. package/dist/workspace/status.d.ts +20 -0
  63. package/dist/workspace/status.d.ts.map +1 -1
  64. package/dist/workspace/work-evidence.d.ts +1 -1
  65. package/dist/workspace/work-evidence.d.ts.map +1 -1
  66. package/dist/workspace/workspace-kinds.schema.json +26 -0
  67. package/package.json +1 -1
  68. package/src/cli/commands/carve-bridge.test.ts +7 -3
  69. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  70. package/src/cli/handlers/operator.ts +53 -11
  71. package/src/cli/handlers/run.test.ts +71 -0
  72. package/src/cli/handlers/run.ts +65 -7
  73. package/src/cli/main.ts +21 -9
  74. package/src/cli/mcp/workspace-plugins.ts +6 -6
  75. package/src/cli/mcp/workspace-tools.ts +1 -1
  76. package/src/cli/registry.ts +2 -0
  77. package/src/cli/serve-mcp-workspace.test.ts +6 -6
  78. package/src/cli/static-config-read.test.ts +8 -2
  79. package/src/meta/source-is-text.test.ts +21 -3
  80. package/src/okf.test.ts +6 -1
  81. package/src/op/activities/decide.test.ts +8 -0
  82. package/src/op/builders.ts +14 -3
  83. package/src/op/index.ts +8 -2
  84. package/src/op/operator.ts +264 -16
  85. package/src/op/steward-beside.test.ts +267 -0
  86. package/src/op/steward-beside.ts +219 -0
  87. package/src/op/steward-points.test.ts +112 -1
  88. package/src/op/steward.ts +135 -3
  89. package/src/workspace/box-intent.test.ts +205 -0
  90. package/src/workspace/box-intent.ts +159 -0
  91. package/src/workspace/box-services.test.ts +129 -0
  92. package/src/workspace/box-services.ts +51 -0
  93. package/src/workspace/chant-migrations.ts +5 -0
  94. package/src/workspace/check.schema.json +7 -3
  95. package/src/workspace/checks/boxes.test.ts +3 -1
  96. package/src/workspace/checks/boxes.ts +66 -0
  97. package/src/workspace/checks.ts +12 -1
  98. package/src/workspace/compose-graph.test.ts +1 -0
  99. package/src/workspace/compose-graph.ts +11 -0
  100. package/src/workspace/composites.test.ts +1 -1
  101. package/src/workspace/composites.ts +12 -5
  102. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  103. package/src/workspace/decision-points.schema.json +3 -3
  104. package/src/workspace/declaration.schema.json +62 -1
  105. package/src/workspace/declaration.ts +112 -0
  106. package/src/workspace/declared-kinds.test.ts +26 -0
  107. package/src/workspace/graph-cache.test.ts +343 -0
  108. package/src/workspace/graph-cache.ts +409 -0
  109. package/src/workspace/graph-cli.ts +93 -26
  110. package/src/workspace/graph-contract.test.ts +129 -6
  111. package/src/workspace/graph.schema.json +21 -1
  112. package/src/workspace/intent-joins.test.ts +5 -5
  113. package/src/workspace/intent-joins.ts +5 -5
  114. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  115. package/src/workspace/kind-readers.test.ts +134 -0
  116. package/src/workspace/kind-readers.ts +111 -0
  117. package/src/workspace/kinds.test.ts +49 -0
  118. package/src/workspace/kinds.ts +59 -2
  119. package/src/workspace/member-commands.test.ts +15 -0
  120. package/src/workspace/member-commands.ts +47 -7
  121. package/src/workspace/member-run.ts +10 -2
  122. package/src/workspace/points-cli.ts +3 -0
  123. package/src/workspace/points.schema.json +18 -0
  124. package/src/workspace/points.test.ts +15 -14
  125. package/src/workspace/points.ts +4 -16
  126. package/src/workspace/read-contract.test.ts +8 -0
  127. package/src/workspace/reason-codes.test.ts +7 -7
  128. package/src/workspace/reason-codes.ts +6 -1
  129. package/src/workspace/records-amend.schema.json +1 -0
  130. package/src/workspace/records-cli.ts +34 -13
  131. package/src/workspace/records-contract.test.ts +2 -1
  132. package/src/workspace/records-formats.test.ts +15 -15
  133. package/src/workspace/records-new.schema.json +1 -0
  134. package/src/workspace/records-quorum.test.ts +10 -3
  135. package/src/workspace/records-sessions-write.test.ts +2 -1
  136. package/src/workspace/records-since.test.ts +8 -7
  137. package/src/workspace/records-write.test.ts +101 -1
  138. package/src/workspace/records-write.ts +50 -6
  139. package/src/workspace/records.test.ts +1 -1
  140. package/src/workspace/records.ts +22 -5
  141. package/src/workspace/status-contract.test.ts +32 -1
  142. package/src/workspace/status-stewards.ts +88 -9
  143. package/src/workspace/status.schema.json +66 -4
  144. package/src/workspace/status.ts +32 -0
  145. package/src/workspace/trust/record-seal.test.ts +26 -2
  146. package/src/workspace/work-evidence.schema.json +1 -0
  147. package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
  148. package/src/workspace/work.test.ts +17 -0
  149. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -0,0 +1,409 @@
1
+ /**
2
+ * The per-member cache behind `chant workspace graph` (#2876, ws-059).
3
+ *
4
+ * ws-018 made chant the composer for declared workspaces, and a viewer
5
+ * rereads the composed graph on every refresh. Without a cache every read
6
+ * starts every member's chant again, so this module keeps each member's
7
+ * source read on disk and serves it while nothing the read depends on has
8
+ * changed.
9
+ *
10
+ * An entry is served only when every part of its key still matches:
11
+ *
12
+ * 1. The read is a source read. A member command line holding `--live`,
13
+ * `--overlay` or `--traffic` observes an account, which changes with
14
+ * nothing on disk to notice, so it is never cached or even stamped.
15
+ * 2. The member's stamp ({@link memberStamp}) is unchanged. It covers the
16
+ * member's files and the install state its toolchain resolves through.
17
+ * With `--at` the tree is the commit's, which never changes, so the commit
18
+ * id stands in for the files.
19
+ * 3. The toolchain is unchanged: the real path of its `bin/chant` and its
20
+ * package version, plus its source files when it is a checkout rather
21
+ * than an install.
22
+ * 4. The member's command line and the ambient environment are unchanged,
23
+ * since `--env`, build parameters and `buildParams` env mappings all
24
+ * reach the answer. A few variables a shell changes on its own are left
25
+ * out ({@link VOLATILE_ENV}); any other difference is a miss.
26
+ *
27
+ * A stamp that can't be taken caches nothing. So does a stamp that moved
28
+ * between the start and the end of the read, a member whose read failed, and
29
+ * a working-tree read whose newest file is younger than
30
+ * {@link FRESH_WINDOW_MS}: some file systems keep mtimes to the second, so an
31
+ * edit in the same tick as the read could otherwise hide behind an equal
32
+ * stamp.
33
+ *
34
+ * Entries live outside the workspace, in the user's cache directory
35
+ * ({@link graphCacheDir}), because a read never changes the workspace it
36
+ * reads (the conformance kit checks exactly that, #2679). One JSON file per
37
+ * key, written to a temporary name and renamed into place so concurrent
38
+ * readers never see half an entry. Each workspace holds at most
39
+ * {@link MAX_ENTRIES} entries and {@link MAX_BYTES} bytes; the least recently
40
+ * used go first.
41
+ *
42
+ * The key never looks at how a member is read, only at what the read takes
43
+ * in, so a member read through a generated reader project caches by its own
44
+ * directory's stamp like any other.
45
+ */
46
+
47
+ import { createHash, randomBytes } from "node:crypto";
48
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
49
+ import { homedir } from "node:os";
50
+ import { dirname, join, relative, resolve, sep } from "node:path";
51
+ import type { ParsedArgs } from "../cli/registry";
52
+ import { readMemberIr } from "./compose-graph";
53
+ import { memberArgv, type MemberPlan, type RunUnit, type Toolchain, type UnitResult } from "./member-commands";
54
+
55
+ /** The version of the entry format. An entry of any other version is a miss. */
56
+ export const CACHE_FORMAT = 1;
57
+
58
+ /** Overrides where chant keeps caches: `$CHANT_CACHE_DIR`, else `$XDG_CACHE_HOME/chant`, else `~/.cache/chant`. */
59
+ export const CACHE_DIR_ENV = "CHANT_CACHE_DIR";
60
+
61
+ export const MAX_ENTRIES = 512;
62
+ export const MAX_BYTES = 128 * 1024 * 1024;
63
+
64
+ /** A working-tree read whose newest file is younger than this is not stored. */
65
+ export const FRESH_WINDOW_MS = 2000;
66
+
67
+ /** Member flags that make a read observe an account. Such a read is never cached. */
68
+ export const LIVE_FLAGS = ["--live", "--overlay", "--traffic"] as const;
69
+
70
+ /** Environment variables a shell or terminal changes on its own, left out of the key. */
71
+ export const VOLATILE_ENV: readonly (string | RegExp)[] = [
72
+ "_",
73
+ "PWD",
74
+ "OLDPWD",
75
+ "SHLVL",
76
+ "COLUMNS",
77
+ "LINES",
78
+ "WINDOWID",
79
+ /^TERM/,
80
+ /^ITERM_/,
81
+ /^TMUX/,
82
+ /^SSH_/,
83
+ /^VSCODE_/,
84
+ /^npm_/,
85
+ /^VITEST/,
86
+ /^__/,
87
+ CACHE_DIR_ENV,
88
+ ];
89
+
90
+ /** Directories no stamp walks into, whatever their depth. */
91
+ const SKIPPED = new Set(["node_modules", "dist", ".git"]);
92
+
93
+ /** Files, looked up from a member's directory to the file-system root, whose change means the install moved. */
94
+ const INSTALL_FILES = [
95
+ join("node_modules", ".package-lock.json"),
96
+ "package-lock.json",
97
+ "npm-shrinkwrap.json",
98
+ "yarn.lock",
99
+ "pnpm-lock.yaml",
100
+ "bun.lock",
101
+ "bun.lockb",
102
+ ];
103
+
104
+ /** Whether a member command line is a source read, and so may be cached. */
105
+ export function isCacheableArgv(argv: readonly string[]): boolean {
106
+ return !argv.some((a) => LIVE_FLAGS.some((f) => a === f || a.startsWith(`${f}=`)));
107
+ }
108
+
109
+ function sha256(text: string): string {
110
+ return createHash("sha256").update(text).digest("hex");
111
+ }
112
+
113
+ export interface Stamp {
114
+ /** `sha256:<hex>`. */
115
+ value: string;
116
+ /** The newest mtime among the files stamped, in ms; 0 for a revision stamp. */
117
+ newest: number;
118
+ }
119
+
120
+ /**
121
+ * Every regular file under `abs`, as `<relative path>\0<mtime ms>\0<size>`
122
+ * lines sorted by path. Directories named `node_modules`, `dist` or `.git`,
123
+ * every dot-directory, and the directories in `exclude` (relative to `abs`)
124
+ * are left out. Throws when the directory can't be read.
125
+ */
126
+ function fileLines(abs: string, exclude: readonly string[]): { lines: string[]; newest: number } {
127
+ const excluded = new Set(exclude.map((e) => resolve(abs, e)));
128
+ const lines: string[] = [];
129
+ let newest = 0;
130
+ const walk = (at: string): void => {
131
+ for (const e of readdirSync(at, { withFileTypes: true })) {
132
+ const path = join(at, e.name);
133
+ if (e.isDirectory()) {
134
+ if (SKIPPED.has(e.name) || e.name.startsWith(".") || excluded.has(path)) continue;
135
+ walk(path);
136
+ } else if (e.isFile()) {
137
+ const st = statSync(path);
138
+ lines.push(`${relative(abs, path).split(sep).join("/")}\0${st.mtimeMs}\0${st.size}`);
139
+ if (st.mtimeMs > newest) newest = st.mtimeMs;
140
+ }
141
+ }
142
+ };
143
+ walk(abs);
144
+ lines.sort();
145
+ return { lines, newest };
146
+ }
147
+
148
+ /** The install files found from `abs` up to the file-system root, with their mtime and size. */
149
+ function installLines(abs: string): string[] {
150
+ const lines: string[] = [];
151
+ for (let dir = resolve(abs); ; dir = dirname(dir)) {
152
+ for (const name of INSTALL_FILES) {
153
+ const path = join(dir, name);
154
+ try {
155
+ const st = statSync(path);
156
+ if (st.isFile()) lines.push(`${path}\0${st.mtimeMs}\0${st.size}`);
157
+ } catch {
158
+ // Not here.
159
+ }
160
+ }
161
+ if (dirname(dir) === dir) break;
162
+ }
163
+ return lines;
164
+ }
165
+
166
+ /**
167
+ * A member's stamp: its files and the install state around it. `abs` is the
168
+ * member's directory on disk, `exclude` the directories inside it that are
169
+ * other members' (set for member `.`). With `at`, the commit id stands in for
170
+ * the files, since a commit's tree never changes. Returns `undefined` when the
171
+ * stamp can't be taken, and then nothing is cached.
172
+ */
173
+ export function memberStamp(abs: string, exclude: readonly string[] = [], at?: { commit: string; dir: string }): Stamp | undefined {
174
+ try {
175
+ const install = installLines(abs);
176
+ if (at) return { value: `sha256:${sha256(["at", at.commit, at.dir, ...install].join("\n"))}`, newest: 0 };
177
+ const { lines, newest } = fileLines(abs, exclude);
178
+ return { value: `sha256:${sha256(["files", ...lines, "install", ...install].join("\n"))}`, newest };
179
+ } catch {
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ /**
185
+ * What distinguishes one chant from another for the key: the real path of its
186
+ * `bin/chant`, the version its package declares, and, for a chant that isn't
187
+ * installed under a `node_modules` directory (a source checkout), the stamp
188
+ * of its package's `src`, since a checkout changes without a version bump.
189
+ */
190
+ export function toolchainStamp(toolchain: Toolchain): string {
191
+ const pkgDir = dirname(dirname(toolchain.identity));
192
+ let version = "";
193
+ try {
194
+ version = String((JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")) as { version?: unknown }).version ?? "");
195
+ } catch {
196
+ // A bin outside a package: its path alone names it.
197
+ }
198
+ const installed = toolchain.identity.split(sep).includes("node_modules");
199
+ let source = "";
200
+ if (!installed && existsSync(join(pkgDir, "src"))) {
201
+ source = memberStamp(join(pkgDir, "src"))?.value ?? `unstamped:${Date.now()}`;
202
+ }
203
+ return `${toolchain.identity}\0${version}\0${source}`;
204
+ }
205
+
206
+ /** What a kind's reader adds to the key: its graph block, and the installed version of the package supplying it. */
207
+ export function readerStamp(reader: { lexicon: string; config: unknown; packageDir: string }): string {
208
+ let version = "";
209
+ try {
210
+ version = String((JSON.parse(readFileSync(join(reader.packageDir, "package.json"), "utf-8")) as { version?: unknown }).version ?? "");
211
+ } catch {
212
+ // An unreadable manifest keys on the directory alone.
213
+ }
214
+ return JSON.stringify([reader.lexicon, reader.config, reader.packageDir, version]);
215
+ }
216
+
217
+ /** The ambient environment, less {@link VOLATILE_ENV}, as one digest. */
218
+ export function environmentStamp(env: NodeJS.ProcessEnv = process.env): string {
219
+ const keep = Object.keys(env)
220
+ .filter((k) => !VOLATILE_ENV.some((v) => (typeof v === "string" ? v === k : v.test(k))))
221
+ .sort();
222
+ return sha256(keep.map((k) => `${k}=${env[k] ?? ""}`).join("\0"));
223
+ }
224
+
225
+ export interface KeyParts {
226
+ member: string;
227
+ dir: string;
228
+ stamp: string;
229
+ toolchain: string;
230
+ argv: readonly string[];
231
+ env: string;
232
+ }
233
+
234
+ export function cacheKey(parts: KeyParts): string {
235
+ return sha256(JSON.stringify([CACHE_FORMAT, parts.member, parts.dir, parts.stamp, parts.toolchain, parts.argv, parts.env]));
236
+ }
237
+
238
+ /** One stored read. */
239
+ export interface CacheEntry {
240
+ format: number;
241
+ key: string;
242
+ member: string;
243
+ stamp: string;
244
+ /** The chant version the read reported, or null. */
245
+ chant: string | null;
246
+ /** The member's `chant graph --format ir` output. */
247
+ stdout: string;
248
+ }
249
+
250
+ export interface GraphCache {
251
+ dir: string;
252
+ get(key: string): CacheEntry | undefined;
253
+ put(entry: CacheEntry): void;
254
+ }
255
+
256
+ /**
257
+ * The directory holding the cache of the workspace rooted at `root`:
258
+ * `<cache dir>/workspace-graph/<first 16 hex digits of sha256(real path of root)>`,
259
+ * where the cache dir is `$CHANT_CACHE_DIR`, else `$XDG_CACHE_HOME/chant`,
260
+ * else `~/.cache/chant`.
261
+ */
262
+ export function graphCacheDir(root: string, env: NodeJS.ProcessEnv = process.env): string {
263
+ const base = env[CACHE_DIR_ENV] || join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "chant");
264
+ let real = resolve(root);
265
+ try {
266
+ real = realpathSync(real);
267
+ } catch {
268
+ // A root that can't be resolved keys on the path as given.
269
+ }
270
+ return join(base, "workspace-graph", sha256(real).slice(0, 16));
271
+ }
272
+
273
+ /**
274
+ * The cache for the workspace rooted at `root` (absolute, on disk). Nothing is
275
+ * created until the first `put`. Every failure to read or write is a miss or
276
+ * a skipped store, never an error: the cache can only make a read faster.
277
+ */
278
+ export function openGraphCache(root: string, env: NodeJS.ProcessEnv = process.env): GraphCache {
279
+ const dir = graphCacheDir(root, env);
280
+ const file = (key: string) => join(dir, `${key}.json`);
281
+ return {
282
+ dir,
283
+ get(key) {
284
+ try {
285
+ const entry = JSON.parse(readFileSync(file(key), "utf-8")) as CacheEntry;
286
+ if (entry.format !== CACHE_FORMAT || entry.key !== key || typeof entry.stdout !== "string") return undefined;
287
+ const now = new Date();
288
+ utimesSync(file(key), now, now);
289
+ return entry;
290
+ } catch {
291
+ return undefined;
292
+ }
293
+ },
294
+ put(entry) {
295
+ try {
296
+ mkdirSync(dir, { recursive: true });
297
+ const tmp = join(dir, `.${entry.key}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
298
+ writeFileSync(tmp, JSON.stringify(entry));
299
+ renameSync(tmp, file(entry.key));
300
+ prune(dir);
301
+ } catch {
302
+ // A cache that can't be written costs the next read a run, nothing more.
303
+ }
304
+ },
305
+ };
306
+ }
307
+
308
+ /** Drop the least recently used entries until the bounds hold. */
309
+ function prune(dir: string): void {
310
+ const entries = readdirSync(dir)
311
+ .filter((n) => n.endsWith(".json") && !n.startsWith("."))
312
+ .map((n) => {
313
+ try {
314
+ const st = statSync(join(dir, n));
315
+ return { path: join(dir, n), mtime: st.mtimeMs, size: st.size };
316
+ } catch {
317
+ return undefined;
318
+ }
319
+ })
320
+ .filter((e): e is { path: string; mtime: number; size: number } => e !== undefined)
321
+ .sort((a, b) => b.mtime - a.mtime);
322
+ let bytes = 0;
323
+ entries.forEach((e, i) => {
324
+ bytes += e.size;
325
+ if (i >= MAX_ENTRIES || bytes > MAX_BYTES) rmSync(e.path, { force: true });
326
+ });
327
+ }
328
+
329
+ // ── Reading a plan through the cache ─────────────────────────────────────────
330
+
331
+ /** A member read the cache answered, or one it will store once it has run. */
332
+ interface Pending {
333
+ key: string;
334
+ stamp: Stamp;
335
+ abs: string;
336
+ exclude: string[];
337
+ at?: { commit: string; dir: string };
338
+ member: string;
339
+ }
340
+
341
+ export interface CacheSplit {
342
+ /** The members the cache answered, as results a run would have produced. */
343
+ hits: UnitResult[];
344
+ /** Each member's stamp, by unit id: served or about to be read. */
345
+ stamps: Map<string, string>;
346
+ pending: Map<string, Pending>;
347
+ /** When the lookup started; a file younger than this less {@link FRESH_WINDOW_MS} keeps a read out of the cache. */
348
+ started: number;
349
+ }
350
+
351
+ /**
352
+ * Look every member of a `graph` plan up in the cache. `at` is the commit
353
+ * being read, or null for the working tree. Members with a cacheable command
354
+ * line whose stamp can be taken end up in `hits` or in `pending`.
355
+ */
356
+ export function splitCached(plan: MemberPlan, args: ParsedArgs, cache: GraphCache, at: string | null, argvFor: (unit: RunUnit) => string[] = (u) => memberArgv("graph", u, args)): CacheSplit {
357
+ const started = Date.now();
358
+ const env = environmentStamp();
359
+ const hits: UnitResult[] = [];
360
+ const stamps = new Map<string, string>();
361
+ const pending = new Map<string, Pending>();
362
+ for (const group of plan.groups) {
363
+ const tool = toolchainStamp(group.toolchain);
364
+ for (const unit of group.units) {
365
+ if (unit.group) continue;
366
+ const argv = argvFor(unit);
367
+ if (!isCacheableArgv(argv)) continue;
368
+ // A member read through a generated reader project (#2874) is also keyed on
369
+ // the kind's graph block and the version of the package that reads it.
370
+ const reader = unit.reader ? readerStamp(unit.reader) : "";
371
+ const abs = unit.dir === "." ? plan.workspace.root : join(plan.workspace.root, ...unit.dir.split("/"));
372
+ const revision = at ? { commit: at, dir: unit.dir } : undefined;
373
+ const stamp = memberStamp(abs, unit.exclude, revision);
374
+ if (!stamp) continue;
375
+ stamps.set(unit.id, stamp.value);
376
+ const key = cacheKey({ member: unit.member, dir: unit.dir, stamp: stamp.value, toolchain: `${tool}\0${reader}`, argv, env });
377
+ const entry = cache.get(key);
378
+ if (entry) {
379
+ hits.push({ unit, toolchain: group.toolchain, chant: entry.chant, mode: "member-run", id: unit.id, member: unit.member, dir: unit.dir, exclude: unit.exclude, exitCode: 0, stdout: entry.stdout, stderr: "" });
380
+ } else {
381
+ pending.set(unit.id, { key, stamp, abs, exclude: unit.exclude, ...(revision ? { at: revision } : {}), member: unit.member });
382
+ }
383
+ }
384
+ }
385
+ return { hits, stamps, pending, started };
386
+ }
387
+
388
+ /** The plan without the units in `ids`; a toolchain left with no units is dropped. */
389
+ export function withoutUnits(plan: MemberPlan, ids: ReadonlySet<string>): MemberPlan {
390
+ if (ids.size === 0) return plan;
391
+ const groups = plan.groups.map((g) => ({ ...g, units: g.units.filter((u) => !ids.has(u.id)) })).filter((g) => g.units.length > 0);
392
+ return { ...plan, groups };
393
+ }
394
+
395
+ /**
396
+ * Store the reads that may be cached: the member exited 0, printed an IR, and
397
+ * its stamp is the same after the read as before it. A working-tree read of a
398
+ * file younger than the window is not stored.
399
+ */
400
+ export function storeReads(split: CacheSplit, results: readonly UnitResult[], cache: GraphCache): void {
401
+ for (const r of results) {
402
+ const p = split.pending.get(r.id);
403
+ if (!p || r.exitCode !== 0 || "reason" in readMemberIr(r.stdout)) continue;
404
+ if (!p.at && p.stamp.newest > split.started - FRESH_WINDOW_MS) continue;
405
+ const after = memberStamp(p.abs, p.exclude, p.at);
406
+ if (!after || after.value !== p.stamp.value) continue;
407
+ cache.put({ format: CACHE_FORMAT, key: p.key, member: p.member, stamp: p.stamp.value, chant: r.chant, stdout: r.stdout });
408
+ }
409
+ }
@@ -30,10 +30,12 @@ import { tmpdir } from "node:os";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { formatError } from "../cli/format";
32
32
  import type { CommandContext, ParsedArgs } from "../cli/registry";
33
- import { composeWorkspaceGraph, readMemberIr, type ComposeInput, type WorkspaceGraph } from "./compose-graph";
34
- import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
33
+ import { composeWorkspaceGraph, readMemberIr, type ComposedMember, type ComposeInput, type WorkspaceGraph } from "./compose-graph";
34
+ import { openGraphCache, splitCached, storeReads, withoutUnits } from "./graph-cache";
35
+ import { readDeclaration, readerVersion, WORKSPACE_ERROR_CODES, WorkspaceReadError, type ErrorLocation } from "./declaration";
35
36
  import { describePlan, emitDocument, executePlan, memberStatus, planJson, planMembers, type MemberPlan, type Toolchain, type UnitResult } from "./member-commands";
36
37
  import { loadKindRegistry } from "./kinds";
38
+ import { prepareKindReaders, type PreparedReaders } from "./kind-readers";
37
39
  import { recordLinkRows } from "./record-assets";
38
40
  import { RecordReadError } from "./records";
39
41
  import { workingTree } from "./tree";
@@ -45,11 +47,18 @@ export const GRAPH_CONTRACT_VERSION = 1;
45
47
  /** `$id` of the JSON Schema for the document, shipped beside this file. */
46
48
  export const GRAPH_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/graph/v1/graph.schema.json";
47
49
 
48
- /** Why the graph couldn't be read at all: the declaration's codes, `--at`'s included. */
49
- export const GRAPH_ERROR_CODES = WORKSPACE_ERROR_CODES;
50
+ /**
51
+ * Why the graph couldn't be read at all: the declaration's codes, `--at`'s
52
+ * included, and `live-at-revision` for `--live` with `--at` (#2875).
53
+ */
54
+ export const GRAPH_ERROR_CODES = [...WORKSPACE_ERROR_CODES, "live-at-revision"] as const;
55
+ export type GraphErrorCode = (typeof GRAPH_ERROR_CODES)[number];
56
+
57
+ /** Why `--live` and `--at` don't go together (#2875). */
58
+ const LIVE_AT_REVISION = "--live reads the account as it stands now, and --at reads the source at a revision; chant workspace graph takes one of them";
50
59
 
51
60
  const USAGE =
52
- "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
61
+ "chant workspace graph [dir] [--at <rev>] [--member <name>] [--kind <kind file>] [-o <file>] [--env <env>] [--live [--overlay] [--traffic <level>]] [--no-cache] [--dry-run] | chant workspace graph --composites [--at <rev>] [--member <name>] [-o <file>] | chant workspace graph --intent <path[:start-end]> [--at <rev>] [--kind <kind file>...] [--json]";
53
62
 
54
63
  interface Head {
55
64
  $schema: string;
@@ -59,7 +68,7 @@ interface Head {
59
68
 
60
69
  export type GraphDocument =
61
70
  | (Head & { at: string | null } & WorkspaceGraph)
62
- | (Head & { error: { code: WorkspaceErrorCode; message: string; location: ErrorLocation | null } });
71
+ | (Head & { error: { code: GraphErrorCode; message: string; location: ErrorLocation | null } });
63
72
 
64
73
  export interface GraphQuery {
65
74
  /** Where the walk up to the declaration starts. */
@@ -67,7 +76,7 @@ export interface GraphQuery {
67
76
  at?: string;
68
77
  /** Only these members. */
69
78
  members?: string[];
70
- /** The flags each member's `chant graph` gets (`--env`). */
79
+ /** The flags each member's `chant graph` gets (`--env`, and `--live`, `--overlay` and `--traffic`, #2875). */
71
80
  args?: Partial<ParsedArgs>;
72
81
  /** The chant for members with none of their own; the running chant by default. */
73
82
  reader?: Toolchain;
@@ -92,6 +101,8 @@ export interface GraphQuery {
92
101
  * here (#2674).
93
102
  */
94
103
  inTree?: (root: string, members: readonly { name: string; dir: string; kind: string }[]) => Promise<void>;
104
+ /** Read every member, leaving the per-member cache unread and unwritten (`--no-cache`, #2876). */
105
+ noCache?: boolean;
95
106
  }
96
107
 
97
108
  export interface GraphResult {
@@ -149,7 +160,21 @@ export function exportRevision(located: LocatedWorkspace, memberDirs: string[]):
149
160
  return out;
150
161
  }
151
162
 
152
- function compose(plan: MemberPlan, results: UnitResult[], only: string[] | undefined, declarationMembers: { name: string; dir: string; kind: string }[]): { inputs: ComposeInput[]; failed: boolean } {
163
+ function compose(
164
+ plan: MemberPlan,
165
+ results: UnitResult[],
166
+ only: string[] | undefined,
167
+ declarationMembers: { name: string; dir: string; kind: string }[],
168
+ live = false,
169
+ ): { inputs: ComposeInput[]; failed: boolean } {
170
+ // A member that ran was read live when --live was given, and says when (#2875).
171
+ const read = (member: ComposedMember, r: UnitResult): ComposedMember => {
172
+ if (live) {
173
+ member.live = true;
174
+ if (r.finishedAt) member.readAt = r.finishedAt;
175
+ }
176
+ return member;
177
+ };
153
178
  const byId = new Map(results.map((r) => [r.id, r]));
154
179
  const inputs: ComposeInput[] = [];
155
180
  let failed = plan.unreadable.length > 0;
@@ -165,18 +190,18 @@ function compose(plan: MemberPlan, results: UnitResult[], only: string[] | undef
165
190
  if (r.exitCode !== 0) {
166
191
  failed = true;
167
192
  const tail = r.stderr.trim().split("\n").slice(-5).join("\n");
168
- inputs.push({ member: memberStatus(m.name, m.dir, m.kind, "failed", { code: "command-failed", message: `chant graph exited ${r.exitCode}${tail ? `: ${tail}` : ""}` }, r.chant) });
193
+ inputs.push({ member: read(memberStatus(m.name, m.dir, m.kind, "failed", { code: "command-failed", message: `chant graph exited ${r.exitCode}${tail ? `: ${tail}` : ""}` }, r.chant), r) });
169
194
  continue;
170
195
  }
171
- const read = readMemberIr(r.stdout);
172
- if ("reason" in read) {
196
+ const ir = readMemberIr(r.stdout);
197
+ if ("reason" in ir) {
173
198
  failed = true;
174
- inputs.push({ member: memberStatus(m.name, m.dir, m.kind, "failed", read.reason, r.chant) });
199
+ inputs.push({ member: read(memberStatus(m.name, m.dir, m.kind, "failed", ir.reason, r.chant), r) });
175
200
  continue;
176
201
  }
177
- const member = memberStatus(m.name, m.dir, m.kind, "composed", null, r.chant);
178
- member.irVersion = read.irVersion;
179
- inputs.push({ member, ir: read.ir });
202
+ const member = read(memberStatus(m.name, m.dir, m.kind, "composed", null, r.chant), r);
203
+ member.irVersion = ir.irVersion;
204
+ inputs.push({ member, ir: ir.ir });
180
205
  }
181
206
  return { inputs, failed };
182
207
  }
@@ -184,33 +209,54 @@ function compose(plan: MemberPlan, results: UnitResult[], only: string[] | undef
184
209
  /** Plan a graph read without running anything, for `--dry-run`. Throws a {@link WorkspaceReadError}. */
185
210
  export function planGraph(query: GraphQuery): MemberPlan {
186
211
  const located = locateWorkspace(query.cwd, query.at);
187
- readDeclaration(located.tree, "", { rootChant: true });
188
- return planMembers("graph", located.rootOnDisk, { only: query.members, reader: query.reader, tree: located.tree });
212
+ const declaration = readDeclaration(located.tree, "", { rootChant: true });
213
+ const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
214
+ return planMembers("graph", located.rootOnDisk, { only: query.members, reader: query.reader, tree: located.tree, kinds });
189
215
  }
190
216
 
191
217
  /** Read and compose the graph, and build the document. Never throws a {@link WorkspaceReadError}. */
192
218
  export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
193
219
  const head: Head = { $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: GRAPH_CONTRACT_VERSION, chant: readerVersion() };
194
220
  let exported: string | undefined;
221
+ let readers: PreparedReaders | undefined;
222
+ if (query.args?.live && query.at !== undefined) {
223
+ return { doc: { ...head, error: { code: "live-at-revision", message: LIVE_AT_REVISION, location: null } }, failed: true };
224
+ }
195
225
  try {
196
226
  const located = locateWorkspace(query.cwd, query.at);
197
227
  const declaration = readDeclaration(located.tree, "", { rootChant: true });
198
- let plan = planMembers("graph", located.rootOnDisk, { only: query.members, reader: query.reader, tree: located.tree });
199
- if (located.at !== null && plan.groups.length > 0) {
200
- exported = exportRevision(located, plan.groups.flatMap((g) => g.units.map((u) => u.dir)));
201
- plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported) });
202
- }
228
+ // Kinds are read before planning: a package kind with a graph block runs (#2874).
229
+ const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
230
+ let plan = planMembers("graph", located.rootOnDisk, { only: query.members, reader: query.reader, tree: located.tree, kinds });
203
231
  const args = (query.args ?? {}) as ParsedArgs;
204
- const [results, components] = await Promise.all([
205
- executePlan(plan, args),
232
+ // Members whose last source read still stands are answered from the cache (#2876).
233
+ // A composites read runs every member's component graph as well, so it reads everything.
234
+ const cache = query.noCache || query.components ? undefined : openGraphCache(located.rootOnDisk);
235
+ const split = cache ? splitCached(plan, args, cache, located.at) : undefined;
236
+ const served = new Set(split?.hits.map((h) => h.id));
237
+ if (located.at !== null && withoutUnits(plan, served).groups.length > 0) {
238
+ exported = exportRevision(located, withoutUnits(plan, served).groups.flatMap((g) => g.units.map((u) => u.dir)));
239
+ plan = planMembers("graph", exported, { only: query.members, reader: query.reader, tree: workingTree(exported), kinds });
240
+ }
241
+ // Only the members that run get a reader project; a served member needs none.
242
+ const toRun = withoutUnits(plan, served);
243
+ readers = prepareKindReaders(toRun, exported ?? located.rootOnDisk);
244
+ const [ran, components] = await Promise.all([
245
+ executePlan(toRun, args),
206
246
  query.components ? executePlan(plan, args, () => componentGraphArgv(args)) : Promise.resolve(undefined),
207
247
  ]);
248
+ if (cache && split) storeReads(split, ran, cache);
249
+ const results = [...(split?.hits ?? []), ...ran];
208
250
  if (query.inTree) await query.inTree(exported ?? located.rootOnDisk, declaration.members.filter((m) => !query.members?.length || query.members.includes(m.name)));
209
251
  for (const r of components ?? []) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
210
252
  for (const r of results) if (r.stderr.trim() && query.onStderr) query.onStderr(r.stderr.endsWith("\n") ? r.stderr : `${r.stderr}\n`);
211
- const { inputs, failed } = compose(plan, results, query.members, declaration.members);
253
+ const { inputs, failed } = compose(plan, results, query.members, declaration.members, !!args.live);
254
+ for (const { member } of inputs) {
255
+ if (member.status !== "composed") continue;
256
+ member.cached = served.has(member.name);
257
+ member.stamp = split?.stamps.get(member.name) ?? null;
258
+ }
212
259
  // Links (#2539) resolve against the declaration that was read, the revision's for --at, and the kinds installed now.
213
- const kinds = loadKindRegistry(declaration.pins, located.rootOnDisk).registry;
214
260
  const graph = composeWorkspaceGraph({ name: declaration.name, root: located.root }, inputs, { declaration, kinds });
215
261
  let recordsFailed = false;
216
262
  if (query.kind !== undefined) {
@@ -233,6 +279,7 @@ export async function workspaceGraph(query: GraphQuery): Promise<GraphResult> {
233
279
  if (!(err instanceof WorkspaceReadError)) throw err;
234
280
  return { doc: { ...head, error: { code: err.code, message: err.message, location: err.location ?? null } }, failed: true };
235
281
  } finally {
282
+ readers?.cleanup();
236
283
  if (exported) rmSync(dirname(exported), { recursive: true, force: true });
237
284
  }
238
285
  }
@@ -257,6 +304,25 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
257
304
  if (!args.dryRun) return (await import("./composites")).runWorkspaceComposites(ctx, cwd);
258
305
  }
259
306
 
307
+ // The live read (#2875): the flags mean what they mean to a member's own
308
+ // `chant graph`, where --overlay and --traffic only act on a live read and
309
+ // --live needs an environment. Here they are refused rather than ignored.
310
+ if (!args.composites && args.intent === undefined) {
311
+ if ((args.overlay || args.traffic !== undefined) && !args.live) {
312
+ console.error(formatError({ message: "--overlay and --traffic act on a live read; add --live --env <env>", hint: USAGE }));
313
+ return 1;
314
+ }
315
+ if (args.live && args.at !== undefined) {
316
+ emitDocument({ $schema: GRAPH_OUTPUT_SCHEMA_ID, contract: GRAPH_CONTRACT_VERSION, chant: readerVersion(), error: { code: "live-at-revision", message: LIVE_AT_REVISION, location: null } }, args.output);
317
+ console.error(formatError({ message: `live-at-revision: ${LIVE_AT_REVISION}`, hint: USAGE }));
318
+ return 1;
319
+ }
320
+ if (args.live && !args.env) {
321
+ console.error(formatError({ message: "--live needs an environment, as each member's chant graph --live does: --live --env <env>", hint: USAGE }));
322
+ return 1;
323
+ }
324
+ }
325
+
260
326
  // The intent graph over one region (#2651) is its own document.
261
327
  if (args.intent !== undefined) return (await import("./intent-cli")).runWorkspaceIntent(ctx, cwd);
262
328
 
@@ -278,6 +344,7 @@ export async function runWorkspaceGraph(ctx: CommandContext): Promise<number> {
278
344
  at: args.at,
279
345
  members: args.members,
280
346
  args,
347
+ ...(args.noCache ? { noCache: true } : {}),
281
348
  ...(args.kind !== undefined ? { kind: resolve(args.kind) } : {}),
282
349
  onStderr: (text) => process.stderr.write(text),
283
350
  });