@intentius/chant 0.94.0 → 0.95.1

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 (114) 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/server.d.ts +10 -4
  5. package/dist/cli/mcp/server.d.ts.map +1 -1
  6. package/dist/cli/registry.d.ts +2 -0
  7. package/dist/cli/registry.d.ts.map +1 -1
  8. package/dist/op/builders.d.ts +14 -3
  9. package/dist/op/builders.d.ts.map +1 -1
  10. package/dist/op/index.d.ts +6 -3
  11. package/dist/op/index.d.ts.map +1 -1
  12. package/dist/op/operator.d.ts +90 -0
  13. package/dist/op/operator.d.ts.map +1 -1
  14. package/dist/op/steward-beside.d.ts +84 -0
  15. package/dist/op/steward-beside.d.ts.map +1 -0
  16. package/dist/op/steward.d.ts +87 -2
  17. package/dist/op/steward.d.ts.map +1 -1
  18. package/dist/workspace/box-services.d.ts +31 -0
  19. package/dist/workspace/box-services.d.ts.map +1 -0
  20. package/dist/workspace/compose-graph.d.ts +11 -0
  21. package/dist/workspace/compose-graph.d.ts.map +1 -1
  22. package/dist/workspace/composites.d.ts +5 -1
  23. package/dist/workspace/composites.d.ts.map +1 -1
  24. package/dist/workspace/declaration.d.ts +37 -0
  25. package/dist/workspace/declaration.d.ts.map +1 -1
  26. package/dist/workspace/declaration.schema.json +57 -1
  27. package/dist/workspace/graph-cache.d.ts +168 -0
  28. package/dist/workspace/graph-cache.d.ts.map +1 -0
  29. package/dist/workspace/graph-cli.d.ts +11 -5
  30. package/dist/workspace/graph-cli.d.ts.map +1 -1
  31. package/dist/workspace/kind-readers.d.ts +39 -0
  32. package/dist/workspace/kind-readers.d.ts.map +1 -0
  33. package/dist/workspace/kinds.d.ts +29 -0
  34. package/dist/workspace/kinds.d.ts.map +1 -1
  35. package/dist/workspace/member-commands.d.ts +15 -1
  36. package/dist/workspace/member-commands.d.ts.map +1 -1
  37. package/dist/workspace/member-run.d.ts +2 -0
  38. package/dist/workspace/member-run.d.ts.map +1 -1
  39. package/dist/workspace/reason-codes.d.ts +3 -1
  40. package/dist/workspace/reason-codes.d.ts.map +1 -1
  41. package/dist/workspace/records-cli.d.ts +10 -1
  42. package/dist/workspace/records-cli.d.ts.map +1 -1
  43. package/dist/workspace/records-write.d.ts +4 -2
  44. package/dist/workspace/records-write.d.ts.map +1 -1
  45. package/dist/workspace/records.d.ts +8 -3
  46. package/dist/workspace/records.d.ts.map +1 -1
  47. package/dist/workspace/status-stewards.d.ts +23 -7
  48. package/dist/workspace/status-stewards.d.ts.map +1 -1
  49. package/dist/workspace/status.d.ts +12 -0
  50. package/dist/workspace/status.d.ts.map +1 -1
  51. package/dist/workspace/work-evidence.d.ts +1 -1
  52. package/dist/workspace/work-evidence.d.ts.map +1 -1
  53. package/dist/workspace/workspace-kinds.schema.json +26 -0
  54. package/package.json +1 -1
  55. package/src/cli/commands/carve-bridge.test.ts +7 -3
  56. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  57. package/src/cli/handlers/operator.ts +53 -11
  58. package/src/cli/handlers/run.test.ts +71 -0
  59. package/src/cli/handlers/run.ts +65 -7
  60. package/src/cli/main.ts +17 -6
  61. package/src/cli/mcp/server.test.ts +14 -0
  62. package/src/cli/mcp/server.ts +10 -4
  63. package/src/cli/mcp/workspace-tools.ts +1 -1
  64. package/src/cli/registry.ts +2 -0
  65. package/src/cli/static-config-read.test.ts +8 -2
  66. package/src/meta/source-is-text.test.ts +21 -3
  67. package/src/okf.test.ts +6 -1
  68. package/src/op/builders.ts +14 -3
  69. package/src/op/index.ts +8 -2
  70. package/src/op/operator.ts +264 -16
  71. package/src/op/steward-beside.test.ts +267 -0
  72. package/src/op/steward-beside.ts +219 -0
  73. package/src/op/steward-points.test.ts +61 -1
  74. package/src/op/steward.ts +135 -3
  75. package/src/workspace/box-services.test.ts +129 -0
  76. package/src/workspace/box-services.ts +51 -0
  77. package/src/workspace/checks/boxes.test.ts +1 -0
  78. package/src/workspace/compose-graph.test.ts +1 -0
  79. package/src/workspace/compose-graph.ts +11 -0
  80. package/src/workspace/composites.test.ts +1 -1
  81. package/src/workspace/composites.ts +12 -5
  82. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  83. package/src/workspace/declaration.schema.json +57 -1
  84. package/src/workspace/declaration.ts +104 -0
  85. package/src/workspace/graph-cache.test.ts +343 -0
  86. package/src/workspace/graph-cache.ts +409 -0
  87. package/src/workspace/graph-cli.ts +93 -26
  88. package/src/workspace/graph-contract.test.ts +129 -6
  89. package/src/workspace/graph.schema.json +21 -1
  90. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  91. package/src/workspace/kind-readers.test.ts +134 -0
  92. package/src/workspace/kind-readers.ts +111 -0
  93. package/src/workspace/kinds.test.ts +49 -0
  94. package/src/workspace/kinds.ts +59 -2
  95. package/src/workspace/member-commands.test.ts +15 -0
  96. package/src/workspace/member-commands.ts +47 -7
  97. package/src/workspace/member-run.ts +10 -2
  98. package/src/workspace/reason-codes.ts +3 -1
  99. package/src/workspace/records-amend.schema.json +1 -0
  100. package/src/workspace/records-cli.ts +20 -12
  101. package/src/workspace/records-contract.test.ts +2 -1
  102. package/src/workspace/records-new.schema.json +1 -0
  103. package/src/workspace/records-quorum.test.ts +10 -3
  104. package/src/workspace/records-sessions-write.test.ts +2 -1
  105. package/src/workspace/records-write.test.ts +101 -1
  106. package/src/workspace/records-write.ts +50 -6
  107. package/src/workspace/records.ts +22 -5
  108. package/src/workspace/status-contract.test.ts +31 -1
  109. package/src/workspace/status-stewards.ts +39 -6
  110. package/src/workspace/status.schema.json +49 -4
  111. package/src/workspace/status.ts +22 -0
  112. package/src/workspace/trust/record-seal.test.ts +26 -2
  113. package/src/workspace/work-evidence.schema.json +1 -0
  114. package/src/workspace/workspace-kinds.schema.json +26 -0
package/src/op/steward.ts CHANGED
@@ -55,9 +55,36 @@
55
55
  * (`workLease`, #2748): its leased steps get a worktree of their own on
56
56
  * `chant/work/<item>`. `declareSteward` refuses such an Op without the lease,
57
57
  * and a scheduled Op whose lease leaves the item to the run.
58
+ *
59
+ * ## Beside the turns
60
+ *
61
+ * An Op listed under `beside` (#2861) is the steward's too, but its runs are
62
+ * not turns. A build that takes half an hour would otherwise hold every other
63
+ * Op of the steward, converge included, for its whole length. The local
64
+ * operator starts a run of it as a `chant run <op>` process of its own, with
65
+ * `CHANT_STEWARD` set so the run is still the steward's, and goes on with its
66
+ * rounds. One run at a time: the run holds the Op's own lease
67
+ * (`refs/chant/lease/<op>`), renewed while it runs, and never the turn lease.
68
+ * The operator starts one on the Op's cron, when its `ready` step says there
69
+ * is work, or to resume a run of the steward's that waited on a question now
70
+ * answered (or a gate now approved).
71
+ *
72
+ * ```ts
73
+ * export const steward = declareSteward({
74
+ * name: "box-steward",
75
+ * ops: [converge, release],
76
+ * beside: [{ op: dispatch, ready: shell("node ops/ready.mjs", { json: true }) }],
77
+ * });
78
+ * ```
79
+ *
80
+ * `ops` on the normalised declaration is every Op the steward runs, beside
81
+ * ones last, so a reader that only asks "whose Op is this" (discovery's
82
+ * two-writers check, `chant run`, `workspace status`) needs nothing new.
83
+ * `beside` names the ones that run beside the turns.
58
84
  */
59
85
 
60
- import type { OpConfig } from "./types";
86
+ import type { ActivityStep, OpConfig } from "./types";
87
+ import { collectStepOutputRefs } from "./step-output-ref";
61
88
  import { isValidCronExpression, cronSyntaxMessage } from "./cron";
62
89
  import { workLeaseNeedsRunItem, workLeaseProblems } from "./work-lease-decl";
63
90
 
@@ -83,11 +110,35 @@ export const STEWARD_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
83
110
  */
84
111
  export type StewardOpInput = OpConfig | { props: unknown };
85
112
 
113
+ /**
114
+ * An Op that runs beside the steward's turns (#2861): the Op alone, or the
115
+ * Op with the step that says when there is work for it.
116
+ */
117
+ export type StewardBesideInput = StewardOpInput | { op: StewardOpInput; ready?: ActivityStep };
118
+
119
+ /** An Op that runs beside the steward's turns, normalised. */
120
+ export interface StewardBeside {
121
+ /** The Op's name; its config is in the declaration's `ops`. */
122
+ readonly op: string;
123
+ /**
124
+ * The step the operator runs each round, while no run of the Op is in
125
+ * flight, to ask whether there is work: see {@link readinessKeys}. Null
126
+ * for an Op started only on its cron, to resume a run, or by hand.
127
+ */
128
+ readonly ready: ActivityStep | null;
129
+ }
130
+
86
131
  export interface StewardDeclarationConfig {
87
132
  /** The steward's name. On Fountain, the Agent's and the Teammate's. */
88
133
  name: string;
89
134
  /** The Ops it runs. A scheduled one runs on its cron; any other runs when asked. */
90
135
  ops: StewardOpInput[];
136
+ /**
137
+ * Ops it runs beside its turns (#2861), each in a process of its own under
138
+ * the Op's own lease, so a long run holds none of `ops` up. See the module
139
+ * doc.
140
+ */
141
+ beside?: StewardBesideInput[];
91
142
  /** Where it runs. Default `local`. */
92
143
  form?: StewardFormSpec;
93
144
  /**
@@ -105,7 +156,13 @@ export interface StewardDeclarationConfig {
105
156
  export interface StewardDeclaration {
106
157
  readonly kind: typeof STEWARD_KIND;
107
158
  readonly name: string;
159
+ /** Every Op it runs: its turns' Ops, then those that run beside them. */
108
160
  readonly ops: readonly OpConfig[];
161
+ /**
162
+ * The Ops of `ops` that run beside its turns (#2861). Read it through
163
+ * {@link stewardBesideOf}: a declaration made by an older core has none.
164
+ */
165
+ readonly beside?: readonly StewardBeside[];
109
166
  readonly form: { readonly default: StewardForm; readonly environments: Readonly<Record<string, StewardForm>> };
110
167
  /** Brokered box capabilities its Ops use (#2726). Empty when it names none. */
111
168
  readonly capabilities: readonly string[];
@@ -140,11 +197,22 @@ export function normaliseStewardForm(steward: string, spec: StewardFormSpec | un
140
197
  return { default: checkForm(steward, spec.default, "form.default"), environments };
141
198
  }
142
199
 
200
+ /** A `beside` entry's Op and ready step, whichever of the two forms it was given in. */
201
+ function besideEntry(entry: StewardBesideInput): { op: OpConfig; ready: ActivityStep | null } {
202
+ const e = entry as { op?: unknown; ready?: ActivityStep; phases?: unknown; props?: unknown };
203
+ if (e && typeof e === "object" && e.op !== undefined && e.phases === undefined && e.props === undefined) {
204
+ return { op: stewardOpConfig(e.op as StewardOpInput), ready: e.ready ?? null };
205
+ }
206
+ return { op: stewardOpConfig(entry as StewardOpInput), ready: null };
207
+ }
208
+
143
209
  /**
144
210
  * Declare a steward. Refuses what would make it more than one writer or a
145
211
  * promise it can't keep: an Op listed twice, a schedule whose overlap isn't
146
212
  * `skip` (a fire while a turn runs is dropped in both forms), a cron that
147
- * doesn't parse, and a name a lease ref can't hold.
213
+ * doesn't parse, and a name a lease ref can't hold. For an Op beside the
214
+ * turns (#2861), also a `ready` that is not one activity step, or that reads
215
+ * another step's output (it runs on its own, outside any run).
148
216
  */
149
217
  export function declareSteward(config: StewardDeclarationConfig): StewardDeclaration {
150
218
  const name = config.name;
@@ -153,7 +221,8 @@ export function declareSteward(config: StewardDeclarationConfig): StewardDeclara
153
221
  `Steward ${JSON.stringify(name)}: a steward's name is letters, digits, ".", "_" and "-", starting with a letter or digit`,
154
222
  );
155
223
  }
156
- const ops = config.ops.map(stewardOpConfig);
224
+ const besides = (config.beside ?? []).map(besideEntry);
225
+ const ops = [...config.ops.map(stewardOpConfig), ...besides.map((b) => b.op)];
157
226
  const seen = new Set<string>();
158
227
  for (const op of ops) {
159
228
  if (!op || typeof op.name !== "string" || !Array.isArray(op.phases)) {
@@ -172,6 +241,22 @@ export function declareSteward(config: StewardDeclarationConfig): StewardDeclara
172
241
  `Name the item, candidates, or the step whose output picks it.`,
173
242
  );
174
243
  }
244
+ const beside = besides.find((b) => b.op === op);
245
+ if (beside?.ready) {
246
+ const ready = beside.ready;
247
+ if (!ready || ready.kind !== "activity" || typeof ready.fn !== "string") {
248
+ throw new Error(`Steward "${name}": op "${op.name}": ready is one activity step, such as shell("...", { json: true })`);
249
+ }
250
+ if (collectStepOutputRefs(ready.args ?? {}).length > 0) {
251
+ throw new Error(`Steward "${name}": op "${op.name}": its ready step reads another step's output, and it runs outside any run`);
252
+ }
253
+ if (workLeaseNeedsRunItem(op)) {
254
+ throw new Error(
255
+ `Steward "${name}": op "${op.name}" is started when its ready step says so, but its workLease names no item, and such a run can't be given one. ` +
256
+ `Name the item, candidates, or the step whose output picks it.`,
257
+ );
258
+ }
259
+ }
175
260
  const schedule = op.schedule;
176
261
  if (!schedule) continue;
177
262
  if (!isValidCronExpression(schedule.cron)) {
@@ -199,6 +284,7 @@ export function declareSteward(config: StewardDeclarationConfig): StewardDeclara
199
284
  kind: STEWARD_KIND,
200
285
  name,
201
286
  ops: Object.freeze([...ops]),
287
+ beside: Object.freeze(besides.map((b) => Object.freeze({ op: b.op.name, ready: b.ready }))),
202
288
  form: normaliseStewardForm(name, config.form),
203
289
  capabilities: Object.freeze(capabilities),
204
290
  vault,
@@ -219,6 +305,52 @@ export function isStewardDeclaration(value: unknown): value is StewardDeclaratio
219
305
  );
220
306
  }
221
307
 
308
+ /** The Ops a steward runs beside its turns (#2861); none for a declaration without the field. */
309
+ export function stewardBesideOf(steward: StewardDeclaration): readonly StewardBeside[] {
310
+ return Array.isArray(steward.beside) ? steward.beside : [];
311
+ }
312
+
313
+ /** The entry for `op` when the steward runs it beside its turns (#2861), or undefined. */
314
+ export function stewardBesideFor(steward: StewardDeclaration, op: string): StewardBeside | undefined {
315
+ return stewardBesideOf(steward).find((b) => b.op === op);
316
+ }
317
+
318
+ /** The Ops a steward runs as its turns, one at a time: `ops` without the beside ones. */
319
+ export function stewardTurnOps(steward: StewardDeclaration): OpConfig[] {
320
+ const beside = new Set(stewardBesideOf(steward).map((b) => b.op));
321
+ return steward.ops.filter((op) => !beside.has(op.name));
322
+ }
323
+
324
+ /**
325
+ * What a `ready` step's result says (#2861): the work keys it names, or null
326
+ * when its result is not one of the shapes below. The value read is the
327
+ * result's `json` when it has one (a `shell` step with `json: true`), or the
328
+ * result itself.
329
+ *
330
+ * - an array: one key per entry (a string as is, anything else as its JSON);
331
+ * empty means no work;
332
+ * - a non-empty string: one key; `""` means no work;
333
+ * - `true` means work, with no key; `false` and `null` mean none.
334
+ *
335
+ * The operator starts a run when a key is one it has not started a run for
336
+ * yet, so a run that ends with the same work still ready is not started
337
+ * again for it. `true` has no key, so it starts a run whenever none is in
338
+ * flight.
339
+ */
340
+ export function readinessKeys(result: unknown): { ready: boolean; keys: string[] } | null {
341
+ const value = result && typeof result === "object" && !Array.isArray(result) && "json" in result
342
+ ? (result as { json: unknown }).json
343
+ : result;
344
+ if (value === true) return { ready: true, keys: [] };
345
+ if (value === false || value === null || value === undefined) return { ready: false, keys: [] };
346
+ if (typeof value === "string") return value === "" ? { ready: false, keys: [] } : { ready: true, keys: [value] };
347
+ if (Array.isArray(value)) {
348
+ const keys = value.map((v) => (typeof v === "string" ? v : JSON.stringify(v)));
349
+ return { ready: keys.length > 0, keys };
350
+ }
351
+ return null;
352
+ }
353
+
222
354
  /** The form a steward takes in `env` (`local` when none is named). */
223
355
  export function stewardFormFor(steward: StewardDeclaration, env: string = DEFAULT_STEWARD_ENV): StewardForm {
224
356
  return steward.form.environments[env] ?? steward.form.default;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * #2880: a box block declares its services. The declaration reads them, a
3
+ * `needs` naming no service of the block or forming a cycle, a name given
4
+ * twice and a second httpPort each fail the read (declaration-invalid), so
5
+ * `chant workspace check` fails with WSP001, and `readBoxServices` gives the
6
+ * list of the member a process runs in.
7
+ */
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { afterAll, describe, expect, test } from "vitest";
14
+ import { runDeclarationChecks } from "./checks";
15
+ import { parseDeclaration, WorkspaceReadError } from "./declaration";
16
+ import { readBoxServices } from "./box-services";
17
+
18
+ const scratch: string[] = [];
19
+ afterAll(() => {
20
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
21
+ });
22
+
23
+ function repo(files: Record<string, string>): string {
24
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-box-services-")));
25
+ scratch.push(root);
26
+ execFileSync("git", ["init", "-q"], { cwd: root });
27
+ for (const [path, text] of Object.entries(files)) {
28
+ mkdirSync(dirname(join(root, path)), { recursive: true });
29
+ writeFileSync(join(root, path), text);
30
+ }
31
+ return root;
32
+ }
33
+
34
+ const declaration = (services: unknown[]) =>
35
+ JSON.stringify(
36
+ {
37
+ name: "acme",
38
+ schema: 1,
39
+ members: [
40
+ { name: "app", dir: "app", kind: "other", because: "the app" },
41
+ { name: "box", dir: "box", kind: "other", because: "the box's steward and its Ops", box: { services } },
42
+ ],
43
+ },
44
+ null,
45
+ 2,
46
+ );
47
+
48
+ const parse = (services: unknown[]) => parseDeclaration(declaration(services), "chant.workspace.json");
49
+
50
+ /** The error a declaration's read fails with, as its code and message. */
51
+ function readFailure(services: unknown[]): { code: string; message: string; line: number | undefined } {
52
+ try {
53
+ parse(services);
54
+ } catch (err) {
55
+ if (err instanceof WorkspaceReadError) return { code: err.code, message: err.message, line: err.location?.line };
56
+ throw err;
57
+ }
58
+ throw new Error("the declaration read");
59
+ }
60
+
61
+ const TWO = [
62
+ { name: "app", cmd: "${HOME}/box/run-app.sh", duration: "3s", health: "http://127.0.0.1:5173/health" },
63
+ { name: "hud", cmd: "${HOME}/box/run-daemon.sh", needs: ["app"], httpPort: 8080 },
64
+ ];
65
+
66
+ describe("the services of a box block (#2880)", () => {
67
+ test("two services, one needing the other, read with every field", () => {
68
+ expect(parse(TWO).members[1].box?.services).toEqual([
69
+ { name: "app", cmd: "${HOME}/box/run-app.sh", needs: [], httpPort: null, duration: "3s", health: "http://127.0.0.1:5173/health", optional: false, pointer: "/members/1/box/services/0" },
70
+ { name: "hud", cmd: "${HOME}/box/run-daemon.sh", needs: ["app"], httpPort: 8080, duration: null, health: null, optional: false, pointer: "/members/1/box/services/1" },
71
+ ]);
72
+ expect(parseDeclaration(JSON.stringify({ name: "a", schema: 1, members: [{ name: "b", dir: "b", kind: "other", because: "x", box: {} }] }), "chant.workspace.json").members[0].box?.services).toEqual([]);
73
+ });
74
+
75
+ test("a needs naming no service of the block is declaration-invalid, at the entry", () => {
76
+ const f = readFailure([TWO[0], { ...TWO[1], needs: ["api"] }]);
77
+ expect(f.code).toBe("declaration-invalid");
78
+ expect(f.message).toBe(`member box's box service hud needs "api", which the block does not declare; declared services: app, hud`);
79
+ });
80
+
81
+ test("needs that form a cycle are declaration-invalid, and so is a service that needs itself", () => {
82
+ const cycle = readFailure([
83
+ { name: "a", cmd: "a", needs: ["c"] },
84
+ { name: "b", cmd: "b", needs: ["a"] },
85
+ { name: "c", cmd: "c", needs: ["b"] },
86
+ ]);
87
+ expect(cycle).toMatchObject({ code: "declaration-invalid", message: "member box's box services need each other in a cycle: a -> c -> b -> a" });
88
+ expect(readFailure([{ name: "a", cmd: "a", needs: ["a"] }]).message).toMatch(/cycle: a -> a$/);
89
+ });
90
+
91
+ test("a name given twice, and a second httpPort, are declaration-invalid", () => {
92
+ expect(readFailure([TWO[0], { ...TWO[0], cmd: "other" }])).toMatchObject({ code: "declaration-invalid", message: expect.stringMatching(/declares the service app twice; the first is at \/members\/1\/box\/services\/0/) });
93
+ expect(readFailure([{ ...TWO[0], httpPort: 5173 }, TWO[1]]).message).toMatch(/gives both app \(port 5173\) and hud \(port 8080\) an httpPort/);
94
+ });
95
+
96
+ test("the schema refuses a service with no cmd, an unknown field, a malformed duration or a health that is no URL", () => {
97
+ expect(readFailure([{ name: "app" }]).code).toBe("declaration-invalid");
98
+ expect(readFailure([{ ...TWO[0], restart: "always" }]).message).toMatch(/unknown field "restart"/);
99
+ expect(readFailure([{ ...TWO[0], duration: "3 seconds" }]).code).toBe("declaration-invalid");
100
+ expect(readFailure([{ ...TWO[0], health: "/health" }]).code).toBe("declaration-invalid");
101
+ });
102
+
103
+ test("chant workspace check passes the valid block and fails the invalid one with WSP001 declaration-invalid", async () => {
104
+ const good = repo({ "chant.workspace.json": declaration(TWO), "app/.keep": "", "box/.keep": "" });
105
+ expect((await runDeclarationChecks(good)).diagnostics.filter((d) => d.ruleId === "WSP001")).toEqual([]);
106
+ const bad = repo({ "chant.workspace.json": declaration([TWO[0], { ...TWO[1], needs: ["api"] }]), "app/.keep": "", "box/.keep": "" });
107
+ const found = (await runDeclarationChecks(bad)).diagnostics;
108
+ expect(found.map((d) => [d.ruleId, d.severity, d.code])).toEqual([["WSP001", "error", "declaration-invalid"]]);
109
+ expect(found[0].message).toMatch(/needs "api", which the block does not declare/);
110
+ });
111
+ });
112
+
113
+ describe("readBoxServices (#2880)", () => {
114
+ test("reads the services of the member whose directory holds the working directory", () => {
115
+ const root = repo({ "chant.workspace.json": declaration(TWO), "app/.keep": "", "box/ops/.keep": "" });
116
+ const read = readBoxServices(join(root, "box", "ops"));
117
+ expect(read.member).toBe("box");
118
+ expect(read.root).toBe(root);
119
+ expect(read.services.map((s) => [s.name, s.needs])).toEqual([["app", []], ["hud", ["app"]]]);
120
+ });
121
+
122
+ test("a member with no box block, a directory no member holds, and no workspace are errors", () => {
123
+ const root = repo({ "chant.workspace.json": declaration(TWO), "app/.keep": "", "box/.keep": "", "elsewhere/.keep": "" });
124
+ expect(() => readBoxServices(join(root, "app"))).toThrow(/member app \(app\) has no box block/);
125
+ expect(() => readBoxServices(join(root, "elsewhere"))).toThrow(/no member of the workspace/);
126
+ const none = repo({ "x/.keep": "" });
127
+ expect(() => readBoxServices(join(none, "x"))).toThrow(/no chant.workspace.json/);
128
+ });
129
+ });
@@ -0,0 +1,51 @@
1
+ /**
2
+ * The services a box declares, read for the process that runs in the box
3
+ * (#2880).
4
+ *
5
+ * A box member's block in the declaration lists the services the box runs
6
+ * under its supervisor (`BoxService` in `declaration.ts`). The fly lexicon's
7
+ * `spriteServicesObserve`, `spriteServiceRestart` and `spriteApplyServices`
8
+ * take `box: true` to read that list instead of one passed to them: the list
9
+ * of the member whose directory holds the working directory. They import
10
+ * this module when they run, so it stays small: it finds the workspace root,
11
+ * reads the declaration from the working tree and picks the member.
12
+ */
13
+
14
+ import { realpathSync } from "node:fs";
15
+ import { relative } from "node:path";
16
+ import { findWorkspaceRoot } from "../project-root";
17
+ import { ownerOf, readDeclaration, WorkspaceReadError, type BoxService } from "./declaration";
18
+ import { workingTree } from "./tree";
19
+
20
+ export type { BoxService } from "./declaration";
21
+
22
+ /** The box block's services, and whose they are. */
23
+ export interface DeclaredBoxServices {
24
+ /** The workspace root, absolute. */
25
+ root: string;
26
+ /** The member whose directory holds the working directory. */
27
+ member: string;
28
+ /** In file order. */
29
+ services: BoxService[];
30
+ }
31
+
32
+ /**
33
+ * The services of the box block of the member whose directory holds `cwd`.
34
+ * Throws a {@link WorkspaceReadError} when there is no workspace or its
35
+ * declaration can't be read, and an Error when no member holds `cwd` or the
36
+ * member has no box block. A block with no `services` gives an empty list.
37
+ */
38
+ export function readBoxServices(cwd: string = process.cwd()): DeclaredBoxServices {
39
+ const found = findWorkspaceRoot(cwd);
40
+ if (!found) {
41
+ throw new WorkspaceReadError("declaration-missing", `no chant.workspace.json or .jsonc between ${cwd} and the git root, so there is no box block to read services from`);
42
+ }
43
+ const root = realpathSync(found.dir);
44
+ const declaration = readDeclaration(workingTree(root));
45
+ const path = relative(root, realpathSync(cwd)).split("\\").join("/") || ".";
46
+ const owner = ownerOf(declaration, [], path);
47
+ if (!owner || !("member" in owner)) throw new Error(`no member of the workspace at ${root} holds ${cwd}, so there is no box block to read services from`);
48
+ const m = owner.member;
49
+ if (!m.box) throw new Error(`member ${m.name} (${m.dir}) has no box block in ${declaration.file}, so it declares no services`);
50
+ return { root, member: m.name, services: m.box.services };
51
+ }
@@ -157,6 +157,7 @@ describe("the box block in the declaration", () => {
157
157
  pointer: "/members/0/box",
158
158
  isolation: null,
159
159
  intent: null,
160
+ services: [],
160
161
  capabilities: [
161
162
  { name: "fountain", broker: "lobby", scope: ["agent", "vault"], pointer: "/members/0/box/capabilities/0" },
162
163
  { name: "inference", broker: null, scope: [], pointer: "/members/0/box/capabilities/1" },
@@ -17,6 +17,7 @@ const member = (name: string, dir: string): ComposedMember => ({
17
17
  reason: null,
18
18
  chant: "0.80.0",
19
19
  irVersion: 1,
20
+ live: false,
20
21
  });
21
22
 
22
23
  const web: GraphIR = {
@@ -76,6 +76,17 @@ export interface ComposedMember {
76
76
  chant: string | null;
77
77
  /** The IR version the member printed; `null` when it printed none and was upgraded as version 1. */
78
78
  irVersion: number | null;
79
+ /** Whether the member was read with `--live` (#2875): its graph is the account as it stands, not its source. */
80
+ live: boolean;
81
+ /** For a live read, when the member's read finished, as an ISO time. */
82
+ readAt?: string;
83
+ /**
84
+ * Whether the per-member cache answered this read (#2876). Set on composed
85
+ * members by `chant workspace graph`.
86
+ */
87
+ cached?: boolean;
88
+ /** The member's stamp (#2876): what the cache keys the read on, or null when none could be taken. */
89
+ stamp?: string | null;
79
90
  /** Whole-read facts the member's IR carried (`meta`, `pipeline`), kept apart from the composed sections. */
80
91
  meta?: Record<string, unknown>;
81
92
  pipeline?: unknown;
@@ -113,7 +113,7 @@ describe("composites output schema", () => {
113
113
  test("lists exactly the reason and error codes the code can return", () => {
114
114
  expect(schema.$defs.reason.properties.code.enum).toEqual([...COMPOSITES_REASON_CODES]);
115
115
  expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...COMPOSITES_ERROR_CODES]);
116
- expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES);
116
+ expect(COMPOSITES_ERROR_CODES).toEqual(GRAPH_ERROR_CODES.filter((c) => c !== "live-at-revision"));
117
117
  expect(schema.$defs.member.properties.reason.oneOf[1].properties!.code.enum).toEqual([...MEMBER_RUN_REASON_CODES]);
118
118
  expect(schema.$defs.member.properties.runtimeReasons.items.properties.code.enum).toEqual([...COMPOSITES_RUNTIME_REASON_CODES]);
119
119
  expect(schema.$defs.member.properties.environmentReasons.items.properties.code.enum).toEqual([...COMPOSITES_ENVIRONMENT_REASON_CODES]);
@@ -44,10 +44,10 @@ import type { CommandContext } from "../cli/registry";
44
44
  import { joinLabel, type JoinLabel } from "../join-key";
45
45
  import type { ComposedMember, MemberReason, WorkspaceGraph } from "./compose-graph";
46
46
  import { readMemberIr } from "./compose-graph";
47
- import { GRAPH_ERROR_CODES, workspaceGraph, type GraphQuery } from "./graph-cli";
47
+ import { workspaceGraph, type GraphQuery } from "./graph-cli";
48
48
  import type { LinkRow } from "./links";
49
49
  import { emitDocument, type UnitResult } from "./member-commands";
50
- import { readerVersion, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
50
+ import { readerVersion, WORKSPACE_ERROR_CODES, type ErrorLocation, type WorkspaceErrorCode } from "./declaration";
51
51
  import type { ReasonCode } from "./reason-codes";
52
52
  import { componentEnvironments, ENVIRONMENT_REASON_CODES, memberEnvironments, readLedgerEnvironments, type ComponentEnvironment, type EnvironmentReason, type LedgerEnvironmentReader, type MemberEnvironments } from "./environments";
53
53
  import { componentRuntimes, readRuntimesIn, RUNTIME_REASON_CODES, type ComponentRuntime, type MemberRuntimes, type PluginLoader, type RuntimeReason } from "./runtimes";
@@ -58,8 +58,12 @@ export const COMPOSITES_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/w
58
58
  /** The read-contract version the document follows. */
59
59
  export const COMPOSITES_CONTRACT_VERSION = 1;
60
60
 
61
- /** Why the composites couldn't be read at all: the graph's codes. */
62
- export const COMPOSITES_ERROR_CODES = GRAPH_ERROR_CODES;
61
+ /**
62
+ * Why the composites couldn't be read at all: the declaration's codes, as for
63
+ * the graph. The composites never read live, so the graph's own
64
+ * `live-at-revision` (#2875) can't reach them.
65
+ */
66
+ export const COMPOSITES_ERROR_CODES = WORKSPACE_ERROR_CODES;
63
67
 
64
68
  /** Why the list is empty, or why no instance has a component. Closed: part of the read contract. */
65
69
  export const COMPOSITES_REASON_CODES = [
@@ -262,12 +266,15 @@ export async function workspaceComposites(
262
266
  const { loadPlugin, readLedgerEnvironments: readLedger = readLedgerEnvironments, ...graphQuery } = query;
263
267
  const { doc: graph, failed, components: runs } = await workspaceGraph({
264
268
  ...graphQuery,
269
+ // The composites read source only; a live flag never reaches the members.
270
+ ...(graphQuery.args ? { args: { ...graphQuery.args, live: false, overlay: false, traffic: undefined } } : {}),
265
271
  components: true,
266
272
  inTree: async (root, members) => {
267
273
  runtimes = await readRuntimesIn(root, members, loadPlugin);
268
274
  },
269
275
  });
270
- if ("error" in graph) return { doc: { ...head, error: graph.error }, failed: true };
276
+ // With no live read, the graph fails only with a declaration code.
277
+ if ("error" in graph) return { doc: { ...head, error: { ...graph.error, code: graph.error.code as WorkspaceErrorCode } }, failed: true };
271
278
 
272
279
  const byMember = new Map((runs ?? []).map((r) => [r.unit.member, r]));
273
280
  const members: CompositesMember[] = [];
@@ -33,8 +33,10 @@ export const recordKind = {
33
33
  // (#2773): chant workspace check --changes reports change-out-of-scope.
34
34
  outOfScope: { field: "out_of_scope" },
35
35
  // Verdicts, and the field naming the decider, for each record's digest and
36
- // quorum (#2671, #2672).
37
- reviews: { field: "reviews", decider: "decided_by" },
36
+ // quorum (#2671, #2672). A decision becomes ratified only once its quorum
37
+ // is met: records new and amend refuse it below, and the digest leaves the
38
+ // state out, so ratifying keeps the verdicts counting (#2873).
39
+ reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },
38
40
  // records new --by and the MCP records-new tool's by name a proposal's
39
41
  // proposer here, apart from decided_by, which stays null until the
40
42
  // decision is decided (#2756).
@@ -402,13 +402,20 @@
402
402
  },
403
403
  "box": {
404
404
  "type": "object",
405
- "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
405
+ "description": "The member is a box, or holds a box's declarations (#2726). A box holds no credential: each capability it needs outside itself is reached through a broker, a runtime such as a lobby or a door, which holds the credential and enforces the scope. chant workspace check fails when a file in the member's directory carries a literal secret (WSP121) and when a capability names no broker (WSP122). chant workspace status --json lists the capabilities, so a broker can read the scopes. Its host, slot, ports, state and cookies state the box's isolation (#2727): chant derives each value from the box's identity, its host, the member's name and its slot, and prints them in chant workspace status --json, so a runtime reads them instead of choosing its own. chant workspace check fails when two boxes on a host resolve to the same value (WSP123) and when a box hard-codes a machine path (WSP124). A member that builds the fountain lexicon's Box declares the callback token fountain gives its persistent sandbox as the capability fountain-callback, brokered by fountain, with scope owner, or chant workspace check fails (WSP125, #2780). Its intent names the decision record that says what the box is for (#2850): chant workspace status --json reports the record's state and answer, and chant workspace check fails when no decision record has the id (WSP126) and warns when the record constrains no member or path of this workspace (WSP127, #2857). Its services list what the box runs under its supervisor (#2880), which the fly lexicon observes, restarts and applies through sprite-env, and which chant workspace status --json prints. Added in schema 1 by chant 0.91.0, so a declaration that uses it sets minReader to 0.91.0 or newer.",
406
406
  "properties": {
407
407
  "intent": {
408
408
  "type": "string",
409
409
  "minLength": 1,
410
410
  "description": "The id of the decision record that says what the box is for (#2850), a record of a declared kind named decision. A new box starts as a question: the record is proposed with a null choice until the person who answers it decides it. The record constrains a member or path of this workspace, with member:<a member's name> or a path: entry at, above or inside a member's directory. It need not be this member: the intent can constrain the app the box runs when that app is another member (#2857). When it constrains this member, chant workspace graph --intent shows it for the box's files. Added by chant 0.94.0, so a declaration that uses it sets minReader to 0.94.0 or newer."
411
411
  },
412
+ "services": {
413
+ "type": "array",
414
+ "description": "The services the box runs under its supervisor, sprite-env on a sprite (#2880). Each name is given once, every needs entry names a service of this list, the needs form no cycle, and at most one service sets httpPort; a declaration that breaks one of these can't be read (declaration-invalid). The fly lexicon's spriteServicesObserve and spriteServiceRestart read the list with box: true, and spriteApplyServices with box: true creates and replaces the services through sprite-env. None when omitted. Added by chant 0.95.0, so a declaration that uses it sets minReader to 0.95.0 or newer.",
415
+ "items": {
416
+ "$ref": "#/$defs/boxService"
417
+ }
418
+ },
412
419
  "capabilities": {
413
420
  "type": "array",
414
421
  "description": "Each capability the box reaches outside itself, named once. None when omitted.",
@@ -481,6 +488,55 @@
481
488
  },
482
489
  "additionalProperties": false
483
490
  },
491
+ "boxService": {
492
+ "type": "object",
493
+ "description": "A service a box runs under its supervisor (#2880).",
494
+ "required": [
495
+ "name",
496
+ "cmd"
497
+ ],
498
+ "properties": {
499
+ "name": {
500
+ "$ref": "#/$defs/kindName",
501
+ "description": "The service's name in the supervisor. Unique within the box."
502
+ },
503
+ "cmd": {
504
+ "type": "string",
505
+ "minLength": 1,
506
+ "description": "The command the supervisor runs (sprite-env services create --cmd). ${HOME} and other ${VAR} references are expanded from the environment of the process that applies the list, so the declaration holds no machine path."
507
+ },
508
+ "needs": {
509
+ "type": "array",
510
+ "uniqueItems": true,
511
+ "description": "Names of services of the same box that start before this one (sprite-env services create --needs).",
512
+ "items": {
513
+ "$ref": "#/$defs/kindName"
514
+ }
515
+ },
516
+ "httpPort": {
517
+ "$ref": "#/$defs/port",
518
+ "description": "The port the supervisor routes the sprite's URL to (sprite-env services create --http-port). At most one service of a box sets it."
519
+ },
520
+ "duration": {
521
+ "type": "string",
522
+ "pattern": "^[0-9]+(\\.[0-9]+)?(ms|s|m)$",
523
+ "description": "How long the service must stay up after it is created or started, such as 3s (sprite-env services create --duration)."
524
+ },
525
+ "health": {
526
+ "type": "string",
527
+ "pattern": "^https?://",
528
+ "description": "A URL that answers 200 while the service works. spriteServicesObserve probes it, and spriteServiceRestart waits for it after a restart. Without it, the supervisor's state decides."
529
+ },
530
+ "optional": {
531
+ "type": "boolean",
532
+ "description": "True for a service something else creates by name, such as a site a release Op makes: spriteApplyServices creates it only when its only names it, and spriteServicesObserve skips it while the supervisor has no such service."
533
+ }
534
+ },
535
+ "patternProperties": {
536
+ "^x-": true
537
+ },
538
+ "additionalProperties": false
539
+ },
484
540
  "capability": {
485
541
  "type": "object",
486
542
  "description": "A capability a box needs and does not hold the credential for, such as inference, fountain or a third-party API (#2726).",