@intentius/chant 0.94.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 (110) 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/registry.d.ts +2 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/op/builders.d.ts +14 -3
  7. package/dist/op/builders.d.ts.map +1 -1
  8. package/dist/op/index.d.ts +6 -3
  9. package/dist/op/index.d.ts.map +1 -1
  10. package/dist/op/operator.d.ts +90 -0
  11. package/dist/op/operator.d.ts.map +1 -1
  12. package/dist/op/steward-beside.d.ts +84 -0
  13. package/dist/op/steward-beside.d.ts.map +1 -0
  14. package/dist/op/steward.d.ts +87 -2
  15. package/dist/op/steward.d.ts.map +1 -1
  16. package/dist/workspace/box-services.d.ts +31 -0
  17. package/dist/workspace/box-services.d.ts.map +1 -0
  18. package/dist/workspace/compose-graph.d.ts +11 -0
  19. package/dist/workspace/compose-graph.d.ts.map +1 -1
  20. package/dist/workspace/composites.d.ts +5 -1
  21. package/dist/workspace/composites.d.ts.map +1 -1
  22. package/dist/workspace/declaration.d.ts +37 -0
  23. package/dist/workspace/declaration.d.ts.map +1 -1
  24. package/dist/workspace/declaration.schema.json +57 -1
  25. package/dist/workspace/graph-cache.d.ts +168 -0
  26. package/dist/workspace/graph-cache.d.ts.map +1 -0
  27. package/dist/workspace/graph-cli.d.ts +11 -5
  28. package/dist/workspace/graph-cli.d.ts.map +1 -1
  29. package/dist/workspace/kind-readers.d.ts +39 -0
  30. package/dist/workspace/kind-readers.d.ts.map +1 -0
  31. package/dist/workspace/kinds.d.ts +29 -0
  32. package/dist/workspace/kinds.d.ts.map +1 -1
  33. package/dist/workspace/member-commands.d.ts +15 -1
  34. package/dist/workspace/member-commands.d.ts.map +1 -1
  35. package/dist/workspace/member-run.d.ts +2 -0
  36. package/dist/workspace/member-run.d.ts.map +1 -1
  37. package/dist/workspace/reason-codes.d.ts +3 -1
  38. package/dist/workspace/reason-codes.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +10 -1
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-write.d.ts +4 -2
  42. package/dist/workspace/records-write.d.ts.map +1 -1
  43. package/dist/workspace/records.d.ts +8 -3
  44. package/dist/workspace/records.d.ts.map +1 -1
  45. package/dist/workspace/status-stewards.d.ts +23 -7
  46. package/dist/workspace/status-stewards.d.ts.map +1 -1
  47. package/dist/workspace/status.d.ts +12 -0
  48. package/dist/workspace/status.d.ts.map +1 -1
  49. package/dist/workspace/work-evidence.d.ts +1 -1
  50. package/dist/workspace/work-evidence.d.ts.map +1 -1
  51. package/dist/workspace/workspace-kinds.schema.json +26 -0
  52. package/package.json +1 -1
  53. package/src/cli/commands/carve-bridge.test.ts +7 -3
  54. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  55. package/src/cli/handlers/operator.ts +53 -11
  56. package/src/cli/handlers/run.test.ts +71 -0
  57. package/src/cli/handlers/run.ts +65 -7
  58. package/src/cli/main.ts +17 -6
  59. package/src/cli/mcp/workspace-tools.ts +1 -1
  60. package/src/cli/registry.ts +2 -0
  61. package/src/cli/static-config-read.test.ts +8 -2
  62. package/src/meta/source-is-text.test.ts +21 -3
  63. package/src/okf.test.ts +6 -1
  64. package/src/op/builders.ts +14 -3
  65. package/src/op/index.ts +8 -2
  66. package/src/op/operator.ts +264 -16
  67. package/src/op/steward-beside.test.ts +267 -0
  68. package/src/op/steward-beside.ts +219 -0
  69. package/src/op/steward-points.test.ts +61 -1
  70. package/src/op/steward.ts +135 -3
  71. package/src/workspace/box-services.test.ts +129 -0
  72. package/src/workspace/box-services.ts +51 -0
  73. package/src/workspace/checks/boxes.test.ts +1 -0
  74. package/src/workspace/compose-graph.test.ts +1 -0
  75. package/src/workspace/compose-graph.ts +11 -0
  76. package/src/workspace/composites.test.ts +1 -1
  77. package/src/workspace/composites.ts +12 -5
  78. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  79. package/src/workspace/declaration.schema.json +57 -1
  80. package/src/workspace/declaration.ts +104 -0
  81. package/src/workspace/graph-cache.test.ts +343 -0
  82. package/src/workspace/graph-cache.ts +409 -0
  83. package/src/workspace/graph-cli.ts +93 -26
  84. package/src/workspace/graph-contract.test.ts +129 -6
  85. package/src/workspace/graph.schema.json +21 -1
  86. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  87. package/src/workspace/kind-readers.test.ts +134 -0
  88. package/src/workspace/kind-readers.ts +111 -0
  89. package/src/workspace/kinds.test.ts +49 -0
  90. package/src/workspace/kinds.ts +59 -2
  91. package/src/workspace/member-commands.test.ts +15 -0
  92. package/src/workspace/member-commands.ts +47 -7
  93. package/src/workspace/member-run.ts +10 -2
  94. package/src/workspace/reason-codes.ts +3 -1
  95. package/src/workspace/records-amend.schema.json +1 -0
  96. package/src/workspace/records-cli.ts +20 -12
  97. package/src/workspace/records-contract.test.ts +2 -1
  98. package/src/workspace/records-new.schema.json +1 -0
  99. package/src/workspace/records-quorum.test.ts +10 -3
  100. package/src/workspace/records-sessions-write.test.ts +2 -1
  101. package/src/workspace/records-write.test.ts +101 -1
  102. package/src/workspace/records-write.ts +50 -6
  103. package/src/workspace/records.ts +22 -5
  104. package/src/workspace/status-contract.test.ts +31 -1
  105. package/src/workspace/status-stewards.ts +39 -6
  106. package/src/workspace/status.schema.json +49 -4
  107. package/src/workspace/status.ts +22 -0
  108. package/src/workspace/trust/record-seal.test.ts +26 -2
  109. package/src/workspace/work-evidence.schema.json +1 -0
  110. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -0,0 +1,219 @@
1
+ /**
2
+ * An Op a steward runs beside its turns (#2861): how a run of it is started,
3
+ * held and asked for.
4
+ *
5
+ * A local steward runs the Ops it lists one turn at a time (#2750). An Op
6
+ * listed under `beside` (`./steward.ts`) is the steward's too, but a run of it
7
+ * is not a turn: the operator (`./operator.ts`) starts it as a `chant run
8
+ * <op>` process of its own and goes on with its rounds, so converge keeps its
9
+ * cadence while a build of half an hour runs.
10
+ *
11
+ * - The run holds the Op's own lease (`refs/chant/lease/<op>`, the lease a
12
+ * round's tick of any Op takes), renewed every third of its time to live
13
+ * for as long as the run lasts, and released when it ends. That is what
14
+ * makes it one run at a time, whoever starts it: the operator, or a person's
15
+ * `chant run <op>`, which takes this lease and not the steward's turn.
16
+ * - The process the operator starts carries `CHANT_STEWARD`, so its run record
17
+ * names the steward, a decision point it asks is the steward's, and the
18
+ * operator resumes it once the question is answered, by starting another
19
+ * such process. Its work lease holder is `<steward>/<op>@<operator>`, which
20
+ * is how `workspace status` finds it under the steward.
21
+ */
22
+ import { spawn } from "node:child_process";
23
+ import type { ActivityFn, ActivityProfile } from "./activity-registry";
24
+ import { runOpLocally, OpRunFailure } from "./local-executor";
25
+ import type { ActivityStep, OpConfig } from "./types";
26
+ import { parseDuration } from "./duration";
27
+ import { acquireLease, releaseLease, DEFAULT_LEASE_TTL_MS, type LeaseRecord } from "../lifecycle/lease";
28
+ import { readinessKeys } from "./steward";
29
+
30
+ /** Why the operator started a run beside the turns. */
31
+ export type BesideWhy = "cron" | "ready" | "resumed" | "approved";
32
+
33
+ /** What the operator asks a launcher to start. */
34
+ export interface BesideStart {
35
+ /** The Op. */
36
+ op: OpConfig;
37
+ /** The steward whose run it is. */
38
+ steward: string;
39
+ /** The operator's own `--env`, passed on so the run reads the steward's form the same way. */
40
+ env?: string;
41
+ /** The holder the run claims the Op's lease and any work lease as: `<steward>/<op>@<operator>`. */
42
+ holder: string;
43
+ /** The project directory. */
44
+ cwd: string;
45
+ /** How long the Op's lease lasts unless renewed. */
46
+ leaseTtlMs?: number;
47
+ }
48
+
49
+ /** How a started run ended: its exit code (0 ok, 3 gated or waiting, 1 failed), or the error that kept it from running. */
50
+ export interface BesideExit {
51
+ code: number | null;
52
+ error?: string;
53
+ }
54
+
55
+ /** A run started beside the turns. */
56
+ export interface BesideHandle {
57
+ /** Settles when the run ends; never rejects. */
58
+ done: Promise<BesideExit>;
59
+ /** Ask the run to stop, as Ctrl-C does: it stops its steps and releases its leases. */
60
+ stop(): void;
61
+ }
62
+
63
+ /** Starts a run beside the turns. The default is {@link spawnBesideRun}. */
64
+ export type BesideLauncher = (start: BesideStart) => BesideHandle;
65
+
66
+ /** The exit code `chant run` gives a run that stopped at a gate or on an open decision point. */
67
+ const WAITING_EXIT = 3;
68
+
69
+ /**
70
+ * Start `chant run <op>` as a process of its own: the same node, loader and
71
+ * entry script this process runs as, in the project directory, with
72
+ * `CHANT_STEWARD` naming the steward and `--holder` naming the holder.
73
+ */
74
+ export function spawnBesideRun(start: BesideStart): BesideHandle {
75
+ const args = [
76
+ ...process.execArgv,
77
+ process.argv[1],
78
+ "run",
79
+ start.op.name,
80
+ "--holder",
81
+ start.holder,
82
+ ...(start.env ? ["--env", start.env] : []),
83
+ ];
84
+ const child = spawn(process.execPath, args, {
85
+ cwd: start.cwd,
86
+ env: { ...process.env, CHANT_STEWARD: start.steward },
87
+ stdio: ["ignore", "inherit", "inherit"],
88
+ });
89
+ const done = new Promise<BesideExit>((resolve) => {
90
+ child.once("error", (err) => resolve({ code: null, error: err.message }));
91
+ child.once("exit", (code, signal) => resolve(signal ? { code: null, error: `stopped by ${signal}` } : { code }));
92
+ });
93
+ return {
94
+ done,
95
+ // `chant run` stops its Op and releases its leases on SIGINT.
96
+ stop: () => {
97
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGINT");
98
+ },
99
+ };
100
+ }
101
+
102
+ /**
103
+ * A launcher that runs the Op in this process, as an async task, under the
104
+ * same lease a `chant run` of it takes. For tests, and for an embedder whose
105
+ * activities are its own: the run does not enter the steward's turn (that is
106
+ * a property of the process, and the operator's own turns share it), so its
107
+ * record names the steward and a decision point it asks does not.
108
+ */
109
+ export function inProcessBesideLauncher(
110
+ activities: Map<string, ActivityFn>,
111
+ profiles: Record<string, ActivityProfile>,
112
+ ): BesideLauncher {
113
+ return (start) => {
114
+ const controller = new AbortController();
115
+ const done = (async (): Promise<BesideExit> => {
116
+ const held = await holdBesideLease(start.op.name, start.holder, { cwd: start.cwd, ttlMs: start.leaseTtlMs });
117
+ if (!held.acquired) return { code: 1, error: `the lease of "${start.op.name}" is held by ${held.heldBy ?? "another holder"}` };
118
+ try {
119
+ const result = await runOpLocally(start.op, activities, profiles, controller.signal, {
120
+ steward: start.steward,
121
+ ledger: { cwd: start.cwd },
122
+ work: { holder: start.holder },
123
+ });
124
+ return { code: result.status === "gated" || result.status === "waiting" ? WAITING_EXIT : 0 };
125
+ } catch (err) {
126
+ return { code: 1, error: err instanceof OpRunFailure ? `Op "${start.op.name}" failed` : err instanceof Error ? err.message : String(err) };
127
+ } finally {
128
+ await held.release();
129
+ }
130
+ })();
131
+ return { done, stop: () => controller.abort() };
132
+ };
133
+ }
134
+
135
+ /** The Op's own lease, held for a run beside the turns. */
136
+ export type HeldBesideLease =
137
+ | { acquired: true; lease: LeaseRecord; release: () => Promise<void> }
138
+ | { acquired: false; heldBy?: string };
139
+
140
+ /**
141
+ * Take the Op's own lease for one run beside the steward's turns, and renew
142
+ * it every third of its time to live until `release` is called. A run of half
143
+ * an hour keeps it; a process that dies stops renewing and the lease expires.
144
+ * Refused when another holder has it: a run of the Op is in flight.
145
+ */
146
+ export async function holdBesideLease(
147
+ op: string,
148
+ holder: string,
149
+ opts: { cwd?: string; ttlMs?: number } = {},
150
+ ): Promise<HeldBesideLease> {
151
+ const ttlMs = opts.ttlMs ?? DEFAULT_LEASE_TTL_MS;
152
+ const first = await acquireLease(op, holder, { cwd: opts.cwd, ttlMs });
153
+ if (!first.acquired) return { acquired: false, ...(first.heldBy?.holder ? { heldBy: first.heldBy.holder } : {}) };
154
+ const lease = first.lease!;
155
+ // A renewal that fails is retried on the next beat; one the lease refuses
156
+ // means another holder took it after it expired, and there is nothing to renew.
157
+ const timer = setInterval(() => {
158
+ void acquireLease(op, holder, { cwd: opts.cwd, ttlMs, mode: "renew", token: lease.token }).catch(() => undefined);
159
+ }, Math.max(1, Math.floor(ttlMs / 3)));
160
+ timer.unref?.();
161
+ let released = false;
162
+ return {
163
+ acquired: true,
164
+ lease,
165
+ release: async () => {
166
+ if (released) return;
167
+ released = true;
168
+ clearInterval(timer);
169
+ await releaseLease(op, holder, lease.token, { cwd: opts.cwd }).catch(() => false);
170
+ },
171
+ };
172
+ }
173
+
174
+ /** How long a ready step may run when it names no `timeout`. */
175
+ export const DEFAULT_READY_TIMEOUT_MS = 120_000;
176
+
177
+ /** What one ready step said: whether there is work and its keys, or why it could not say. */
178
+ export type ReadyAnswer = { ready: boolean; keys: string[] } | { error: string };
179
+
180
+ /**
181
+ * Run a steward's `ready` step for an Op beside its turns (#2861) and read
182
+ * its answer with {@link readinessKeys}. The step is one activity call, with
183
+ * its own `timeout` or {@link DEFAULT_READY_TIMEOUT_MS}; it runs outside any
184
+ * run, so it writes no record, and it is the author's to keep it a read.
185
+ */
186
+ export async function askReady(
187
+ step: ActivityStep,
188
+ activities: Map<string, ActivityFn>,
189
+ signal?: AbortSignal,
190
+ ): Promise<ReadyAnswer> {
191
+ const fn = activities.get(step.fn);
192
+ if (!fn) return { error: `its ready step's activity "${step.fn}" is not loaded` };
193
+ const timeoutMs = step.timeout ? parseDuration(step.timeout) : DEFAULT_READY_TIMEOUT_MS;
194
+ const controller = new AbortController();
195
+ const onAbort = () => controller.abort();
196
+ signal?.addEventListener("abort", onAbort, { once: true });
197
+ let timer: ReturnType<typeof setTimeout> | undefined;
198
+ try {
199
+ const call = Promise.resolve(fn({ ...(step.args ?? {}) }, controller.signal));
200
+ call.catch(() => {});
201
+ const result = await Promise.race([
202
+ call,
203
+ new Promise<never>((_, reject) => {
204
+ timer = setTimeout(() => {
205
+ controller.abort();
206
+ reject(new Error(`timed out after ${timeoutMs}ms`));
207
+ }, timeoutMs);
208
+ }),
209
+ ]);
210
+ const answer = readinessKeys(result);
211
+ if (!answer) return { error: "its ready step answered neither true, false, null, a string nor a list" };
212
+ return answer;
213
+ } catch (err) {
214
+ return { error: err instanceof Error ? err.message : String(err) };
215
+ } finally {
216
+ if (timer) clearTimeout(timer);
217
+ signal?.removeEventListener("abort", onAbort);
218
+ }
219
+ }
@@ -19,7 +19,8 @@ import { readMemberStewards } from "../workspace/status-stewards";
19
19
  import { readRunLedger } from "../lifecycle/run-ledger";
20
20
  import type { ActivityFn, ActivityProfile } from "./activity-registry";
21
21
  import { runOpLocally } from "./local-executor";
22
- import { formatRoundLine, runOperatorRound } from "./operator";
22
+ import { createBesideState, formatRoundLine, runOperatorRound, waitForBesideRuns } from "./operator";
23
+ import { inProcessBesideLauncher } from "./steward-beside";
23
24
  import { declareSteward } from "./steward";
24
25
  import { askPointInRun, isPointWait, PointWait, type BrokeredModelAsk } from "./steward-points";
25
26
  import { enterStewardTurn, resetStewardTurn, STEWARD_ENV } from "./steward-turn";
@@ -362,3 +363,62 @@ describe("a waiting run of an Op the steward does not list (studio#137)", () =>
362
363
  expect(listed).not.toContain("factory-stranger");
363
364
  });
364
365
  });
366
+
367
+ describe("a waiting run of an Op beside the steward's turns (#2861)", () => {
368
+ test("status lists it under the steward with its lease, and the operator resumes it beside the turns once a person answers", async () => {
369
+ const build = shipOp("box-build", "r-30");
370
+ const steward = declareSteward({
371
+ name: "build-steward",
372
+ ops: [],
373
+ beside: [{ op: build, ready: { kind: "activity", fn: "readyShip", args: {} } }],
374
+ capabilities: ["inference"],
375
+ });
376
+ const log: string[] = [];
377
+ const acts = new Map<string, ActivityFn>([
378
+ ...shipActivities(log),
379
+ ["askShip", async (args) => (await askPointInRun({ cwd: root, point: "ship-now", inputs: { release: args.release }, subject: String(args.release), on })).answer],
380
+ ["readyShip", async () => ["r-30"]],
381
+ ]);
382
+ const besideState = createBesideState();
383
+ const launchBeside = inProcessBesideLauncher(acts, PROFILES);
384
+ const round = () => runOperatorRound({ cwd: root, steward, activities: acts, profiles: PROFILES, holder: "box", besideState, launchBeside });
385
+
386
+ // The ready step names work: the run starts beside the turns, asks, and waits.
387
+ const first = await round();
388
+ expect(first).toEqual([expect.objectContaining({ kind: "beside-started", op: "box-build", why: "ready", keys: ["r-30"], holder: "build-steward/box-build@box" })]);
389
+ await waitForBesideRuns(besideState);
390
+ const waiting = (await readRunLedger("local", "box-build", { cwd: root })).records.at(-1)!;
391
+ expect(waiting).toMatchObject({ status: "waiting", steward: "build-steward" });
392
+ const question = waiting.point!.id;
393
+
394
+ // workspace status lists it under the steward, as one of its Ops, with its wait.
395
+ mkdirSync(join(root, "ops"), { recursive: true });
396
+ writeFileSync(join(root, "chant.config.json"), "{}\n");
397
+ writeFileSync(join(root, "ops", "build-steward.op.ts"), `export const steward = ${JSON.stringify(steward)};\n`);
398
+ const status = await readMemberStewards(root, "local", "2027-01-01T00:01:00Z");
399
+ const entry = status.stewards.find((s) => s.name === "build-steward")!;
400
+ const stewardShape = contract({ $schema: statusSchema.$schema, $id: "urn:test:status-steward-beside", $defs: statusSchema.$defs, $ref: "#/$defs/steward" });
401
+ stewardShape.expectValid(entry);
402
+ expect(entry.ops.map((o) => [o.name, o.beside])).toEqual([["box-build", { ready: true, lease: null }]]);
403
+ expect(entry.waiting).toEqual([
404
+ { op: "box-build", run: waiting.id, id: question, point: "ship-now", state: "escalated", path: waiting.point!.path, subject: "r-30", since: waiting.point!.since },
405
+ ]);
406
+
407
+ // Still open: the same work is not started again, and the round says what it waits on.
408
+ const second = await round();
409
+ expect(second).toEqual([
410
+ { kind: "beside-ended", op: "box-build", env: "local", code: 3 },
411
+ { kind: "waiting-on-point", op: "box-build", env: "local", point: "ship-now", question, state: "escalated" },
412
+ ]);
413
+
414
+ // A person answers; the next round starts the run again beside the turns.
415
+ const answered = await answerPoint({ cwd: root, id: question, answer: "yes", by: ["alice"], on });
416
+ expect("error" in answered).toBe(false);
417
+ const third = await round();
418
+ expect(third).toEqual([expect.objectContaining({ kind: "beside-started", op: "box-build", why: "resumed", resumed: question })]);
419
+ await waitForBesideRuns(besideState);
420
+ expect(log).toEqual(["ship"]);
421
+ expect((await readRunLedger("local", "box-build", { cwd: root })).records.at(-1)).toMatchObject({ status: "ok", steward: "build-steward" });
422
+ expect((await readMemberStewards(root, "local", "2027-01-01T00:11:00Z")).stewards.find((s) => s.name === "build-steward")!.waiting).toEqual([]);
423
+ });
424
+ });
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
+ });