@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.
- 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/mcp/server.d.ts +10 -4
- package/dist/cli/mcp/server.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/server.test.ts +14 -0
- package/src/cli/mcp/server.ts +10 -4
- 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
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
|
+
});
|
|
@@ -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" },
|
|
@@ -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 {
|
|
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
|
-
/**
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).",
|