@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.
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +2 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +14 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +6 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/operator.d.ts +90 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/steward-beside.d.ts +84 -0
- package/dist/op/steward-beside.d.ts.map +1 -0
- package/dist/op/steward.d.ts +87 -2
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +11 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +5 -1
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/declaration.d.ts +37 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +57 -1
- package/dist/workspace/graph-cache.d.ts +168 -0
- package/dist/workspace/graph-cache.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +11 -5
- package/dist/workspace/graph-cli.d.ts.map +1 -1
- package/dist/workspace/kind-readers.d.ts +39 -0
- package/dist/workspace/kind-readers.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +29 -0
- package/dist/workspace/kinds.d.ts.map +1 -1
- package/dist/workspace/member-commands.d.ts +15 -1
- package/dist/workspace/member-commands.d.ts.map +1 -1
- package/dist/workspace/member-run.d.ts +2 -0
- package/dist/workspace/member-run.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +3 -1
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +10 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +4 -2
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -3
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +23 -7
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +12 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-evidence.d.ts +1 -1
- package/dist/workspace/work-evidence.d.ts.map +1 -1
- package/dist/workspace/workspace-kinds.schema.json +26 -0
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +7 -3
- package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
- package/src/cli/handlers/operator.ts +53 -11
- package/src/cli/handlers/run.test.ts +71 -0
- package/src/cli/handlers/run.ts +65 -7
- package/src/cli/main.ts +17 -6
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/static-config-read.test.ts +8 -2
- package/src/meta/source-is-text.test.ts +21 -3
- package/src/okf.test.ts +6 -1
- package/src/op/builders.ts +14 -3
- package/src/op/index.ts +8 -2
- package/src/op/operator.ts +264 -16
- package/src/op/steward-beside.test.ts +267 -0
- package/src/op/steward-beside.ts +219 -0
- package/src/op/steward-points.test.ts +61 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/checks/boxes.test.ts +1 -0
- package/src/workspace/compose-graph.test.ts +1 -0
- package/src/workspace/compose-graph.ts +11 -0
- package/src/workspace/composites.test.ts +1 -1
- package/src/workspace/composites.ts +12 -5
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
- package/src/workspace/declaration.schema.json +57 -1
- package/src/workspace/declaration.ts +104 -0
- package/src/workspace/graph-cache.test.ts +343 -0
- package/src/workspace/graph-cache.ts +409 -0
- package/src/workspace/graph-cli.ts +93 -26
- package/src/workspace/graph-contract.test.ts +129 -6
- package/src/workspace/graph.schema.json +21 -1
- package/src/workspace/kind-readers.e2e.test.ts +68 -0
- package/src/workspace/kind-readers.test.ts +134 -0
- package/src/workspace/kind-readers.ts +111 -0
- package/src/workspace/kinds.test.ts +49 -0
- package/src/workspace/kinds.ts +59 -2
- package/src/workspace/member-commands.test.ts +15 -0
- package/src/workspace/member-commands.ts +47 -7
- package/src/workspace/member-run.ts +10 -2
- package/src/workspace/reason-codes.ts +3 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +20 -12
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-new.schema.json +1 -0
- package/src/workspace/records-quorum.test.ts +10 -3
- package/src/workspace/records-sessions-write.test.ts +2 -1
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +31 -1
- package/src/workspace/status-stewards.ts +39 -6
- package/src/workspace/status.schema.json +49 -4
- package/src/workspace/status.ts +22 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- 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
|
|
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
|
+
});
|