@intentius/chant 0.93.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/workspace-plugins.d.ts +6 -6
- 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-intent.d.ts +85 -0
- package/dist/workspace/box-intent.d.ts.map +1 -0
- package/dist/workspace/box-services.d.ts +31 -0
- package/dist/workspace/box-services.d.ts.map +1 -0
- package/dist/workspace/chant-migrations.d.ts +5 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/checks/boxes.d.ts +14 -1
- package/dist/workspace/checks/boxes.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +4 -0
- package/dist/workspace/checks.d.ts.map +1 -1
- 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/decision-points.schema.json +3 -3
- package/dist/workspace/declaration.d.ts +43 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +62 -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/intent-joins.d.ts +5 -5
- 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/points-cli.d.ts +2 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points.d.ts +3 -5
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +5 -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 +31 -9
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +20 -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 +21 -9
- package/src/cli/mcp/workspace-plugins.ts +6 -6
- package/src/cli/mcp/workspace-tools.ts +1 -1
- package/src/cli/registry.ts +2 -0
- package/src/cli/serve-mcp-workspace.test.ts +6 -6
- 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/activities/decide.test.ts +8 -0
- 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 +112 -1
- package/src/op/steward.ts +135 -3
- package/src/workspace/box-intent.test.ts +205 -0
- package/src/workspace/box-intent.ts +159 -0
- package/src/workspace/box-services.test.ts +129 -0
- package/src/workspace/box-services.ts +51 -0
- package/src/workspace/chant-migrations.ts +5 -0
- package/src/workspace/check.schema.json +7 -3
- package/src/workspace/checks/boxes.test.ts +3 -1
- package/src/workspace/checks/boxes.ts +66 -0
- package/src/workspace/checks.ts +12 -1
- 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/decision-points.schema.json +3 -3
- package/src/workspace/declaration.schema.json +62 -1
- package/src/workspace/declaration.ts +112 -0
- package/src/workspace/declared-kinds.test.ts +26 -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/intent-joins.test.ts +5 -5
- package/src/workspace/intent-joins.ts +5 -5
- 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/points-cli.ts +3 -0
- package/src/workspace/points.schema.json +18 -0
- package/src/workspace/points.test.ts +15 -14
- package/src/workspace/points.ts +4 -16
- package/src/workspace/read-contract.test.ts +8 -0
- package/src/workspace/reason-codes.test.ts +7 -7
- package/src/workspace/reason-codes.ts +6 -1
- package/src/workspace/records-amend.schema.json +1 -0
- package/src/workspace/records-cli.ts +34 -13
- package/src/workspace/records-contract.test.ts +2 -1
- package/src/workspace/records-formats.test.ts +15 -15
- 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-since.test.ts +8 -7
- package/src/workspace/records-write.test.ts +101 -1
- package/src/workspace/records-write.ts +50 -6
- package/src/workspace/records.test.ts +1 -1
- package/src/workspace/records.ts +22 -5
- package/src/workspace/status-contract.test.ts +32 -1
- package/src/workspace/status-stewards.ts +88 -9
- package/src/workspace/status.schema.json +66 -4
- package/src/workspace/status.ts +32 -0
- package/src/workspace/trust/record-seal.test.ts +26 -2
- package/src/workspace/work-evidence.schema.json +1 -0
- package/src/workspace/{work-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
- package/src/workspace/work.test.ts +17 -0
- package/src/workspace/workspace-kinds.schema.json +26 -0
|
@@ -16,19 +16,27 @@
|
|
|
16
16
|
* is the Op's own `labels.Env` or `local`;
|
|
17
17
|
* - `waiting` lists the open decision points the steward waits on (#2749):
|
|
18
18
|
* each Op whose newest run is the steward's own and stopped on a question,
|
|
19
|
-
* as the run ledger records it. The question's state now is `points`'s
|
|
19
|
+
* as the run ledger records it. The question's state now is `points`'s.
|
|
20
|
+
* A run is the steward's when its record names it (`steward`, written for
|
|
21
|
+
* a run started in the steward's turn or under `CHANT_STEWARD`), so this
|
|
22
|
+
* also covers the member's other Ops, those no steward lists, that such a
|
|
23
|
+
* run belongs to, such as an Op the steward's process starts itself;
|
|
20
24
|
* - `lease` is the local steward's own lease ref
|
|
21
25
|
* (`refs/chant/lease/[<prefix>]_stewards/<name>`), present while a
|
|
22
26
|
* `chant operator --steward` holds it or until it expires;
|
|
23
27
|
* - an Op's `workLease.held` is the work leases its turns hold (#2748): those
|
|
24
28
|
* whose holder is `<steward>/<op>@...` (`stewardWorkHolder`), read from the
|
|
25
|
-
* ledger of the Op's work kind, or the member's own
|
|
29
|
+
* ledger of the Op's work kind, or the member's own;
|
|
30
|
+
* - an Op the steward runs beside its turns (#2861) has `beside`: whether it
|
|
31
|
+
* declares a ready step, and the Op's own lease
|
|
32
|
+
* (`refs/chant/lease/[<prefix>]<op>`), which a run of it holds while it runs.
|
|
33
|
+
* Its waiting run and its work leases are listed as for any of its Ops.
|
|
26
34
|
*/
|
|
27
35
|
|
|
28
36
|
import { existsSync, realpathSync } from "node:fs";
|
|
29
37
|
import { dirname, join, relative, resolve } from "node:path";
|
|
30
|
-
import { discoverStewards } from "../op/discover";
|
|
31
|
-
import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
38
|
+
import { discoverOps, discoverStewards } from "../op/discover";
|
|
39
|
+
import { stewardBesideFor, stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
32
40
|
import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
|
|
33
41
|
import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
|
|
34
42
|
import { leaseRef } from "../lifecycle/lease";
|
|
@@ -119,6 +127,21 @@ export interface StatusStewardOp {
|
|
|
119
127
|
* the steward's turns of this Op hold, a live one while a turn runs.
|
|
120
128
|
*/
|
|
121
129
|
workLease: { kind: string | null; held: StatusStewardWorkLease[] } | null;
|
|
130
|
+
/**
|
|
131
|
+
* For an Op the steward runs beside its turns (#2861): whether a ready step
|
|
132
|
+
* says when to start it, and the Op's own lease, which a run of it holds
|
|
133
|
+
* while it runs (null when none has held it). Null for an Op run as one of
|
|
134
|
+
* the steward's turns.
|
|
135
|
+
*/
|
|
136
|
+
beside: { ready: boolean; lease: StatusStewardLease | null } | null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A lease ref as status reads it: locally, never fetched. `live` is false once `expiresAt` has passed. */
|
|
140
|
+
export interface StatusStewardLease {
|
|
141
|
+
holder: string;
|
|
142
|
+
acquiredAt: string;
|
|
143
|
+
expiresAt: string;
|
|
144
|
+
live: boolean;
|
|
122
145
|
}
|
|
123
146
|
|
|
124
147
|
/** A work lease a steward's turn holds (#2748). */
|
|
@@ -148,11 +171,13 @@ export interface StatusSteward {
|
|
|
148
171
|
*/
|
|
149
172
|
capabilities: { name: string; broker: string | null; declared: boolean }[];
|
|
150
173
|
/** The local steward's own lease, or null when no local operator has held it. */
|
|
151
|
-
lease:
|
|
174
|
+
lease: StatusStewardLease | null;
|
|
152
175
|
ops: StatusStewardOp[];
|
|
153
176
|
/**
|
|
154
177
|
* The open decision points the steward waits on (#2749): each Op whose
|
|
155
|
-
* newest run is the steward's own and stopped on a question.
|
|
178
|
+
* newest run is the steward's own and stopped on a question. Its declared
|
|
179
|
+
* Ops come first, in `ops` order, then any other Op of the member whose
|
|
180
|
+
* newest run names the steward, by Op name.
|
|
156
181
|
*/
|
|
157
182
|
waiting: (StatusStewardWait & { op: string; run: string })[];
|
|
158
183
|
}
|
|
@@ -200,10 +225,11 @@ function isChantProject(dir: string): boolean {
|
|
|
200
225
|
return existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"));
|
|
201
226
|
}
|
|
202
227
|
|
|
203
|
-
|
|
228
|
+
/** A lease ref of the member, by its lease name (`_stewards/<name>`, or an Op's). */
|
|
229
|
+
async function readLeaseRef(leaseName: string, memberDir: string, now: string): Promise<StatusStewardLease | null> {
|
|
204
230
|
try {
|
|
205
231
|
const { prefix } = await resolveMemberLedger(memberDir);
|
|
206
|
-
const sha = await readRefSha(leaseRef(
|
|
232
|
+
const sha = await readRefSha(leaseRef(leaseName, prefix), { cwd: memberDir });
|
|
207
233
|
if (!sha) return null;
|
|
208
234
|
const record = JSON.parse((await readBlobBySha(sha, { cwd: memberDir })) ?? "") as Record<string, unknown>;
|
|
209
235
|
if (typeof record.holder !== "string" || typeof record.expiresAt !== "string" || typeof record.acquiredAt !== "string") return null;
|
|
@@ -218,6 +244,18 @@ async function readStewardLease(name: string, memberDir: string, now: string): P
|
|
|
218
244
|
}
|
|
219
245
|
}
|
|
220
246
|
|
|
247
|
+
/** An Op's `beside` entry (#2861): null for an Op run as one of the steward's turns. */
|
|
248
|
+
async function besideOf(
|
|
249
|
+
declaration: Parameters<typeof stewardBesideFor>[0],
|
|
250
|
+
op: string,
|
|
251
|
+
memberDir: string,
|
|
252
|
+
now: string,
|
|
253
|
+
): Promise<StatusStewardOp["beside"]> {
|
|
254
|
+
const beside = stewardBesideFor(declaration, op);
|
|
255
|
+
if (!beside) return null;
|
|
256
|
+
return { ready: beside.ready !== null && beside.ready !== undefined, lease: await readLeaseRef(op, memberDir, now) };
|
|
257
|
+
}
|
|
258
|
+
|
|
221
259
|
/** The work leases `steward`'s turns of `op` hold, from the ledger of the Op's kind or the member's. */
|
|
222
260
|
async function readHeldWorkLeases(steward: string, op: OpConfig, memberDir: string, now: string): Promise<StatusStewardWorkLease[]> {
|
|
223
261
|
try {
|
|
@@ -308,6 +346,7 @@ export async function readMemberStewards(
|
|
|
308
346
|
workLease: op.workLease
|
|
309
347
|
? { kind: op.workLease.kind ?? null, held: await readHeldWorkLeases(declaration.name, op, memberDir, now) }
|
|
310
348
|
: null,
|
|
349
|
+
beside: await besideOf(declaration, op.name, memberDir, now),
|
|
311
350
|
});
|
|
312
351
|
}
|
|
313
352
|
stewards.push({
|
|
@@ -320,10 +359,50 @@ export async function readMemberStewards(
|
|
|
320
359
|
const declared = box?.capabilities.find((c) => c.name === name);
|
|
321
360
|
return { name, broker: declared?.broker ?? null, declared: declared !== undefined };
|
|
322
361
|
}),
|
|
323
|
-
lease: await
|
|
362
|
+
lease: await readLeaseRef(stewardLeaseName(declaration.name), memberDir, now),
|
|
324
363
|
ops,
|
|
325
364
|
waiting,
|
|
326
365
|
});
|
|
327
366
|
}
|
|
367
|
+
if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards, found, reasons);
|
|
328
368
|
return { stewards, reasons };
|
|
329
369
|
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The waiting runs of the member's Ops that no steward lists, each under the
|
|
373
|
+
* steward its record names. A steward's process can start an Op itself
|
|
374
|
+
* with `CHANT_STEWARD` set (studio#137), and the run ledger then records
|
|
375
|
+
* the run as the steward's though the declaration does not list the Op.
|
|
376
|
+
* Import failures are left out here: `discoverStewards` read the same files
|
|
377
|
+
* and reported them as `stewards-unreadable`.
|
|
378
|
+
*/
|
|
379
|
+
async function addUndeclaredWaits(
|
|
380
|
+
memberDir: string,
|
|
381
|
+
stewards: StatusSteward[],
|
|
382
|
+
found: Awaited<ReturnType<typeof discoverStewards>>["stewards"],
|
|
383
|
+
reasons: MemberStewards["reasons"],
|
|
384
|
+
): Promise<void> {
|
|
385
|
+
const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
|
|
386
|
+
let ops: OpConfig[];
|
|
387
|
+
try {
|
|
388
|
+
ops = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => d.config).filter((op) => !declared.has(op.name));
|
|
389
|
+
} catch {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const byName = new Map(stewards.map((s) => [s.name, s]));
|
|
393
|
+
for (const op of ops.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
394
|
+
const opEnv = runEnvOf(op);
|
|
395
|
+
try {
|
|
396
|
+
const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
|
|
397
|
+
const steward = newest?.steward ? byName.get(newest.steward) : undefined;
|
|
398
|
+
if (steward && newest?.status === "waiting" && newest.point) {
|
|
399
|
+
steward.waiting.push({ op: op.name, run: newest.id, ...waitOf(newest.point) });
|
|
400
|
+
}
|
|
401
|
+
} catch (err) {
|
|
402
|
+
reasons.push({
|
|
403
|
+
code: "steward-runs-unreadable",
|
|
404
|
+
message: `${opEnv}/runs__${op.name}.jsonl: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
@@ -200,11 +200,11 @@
|
|
|
200
200
|
]
|
|
201
201
|
},
|
|
202
202
|
"ops": {
|
|
203
|
-
"description": "The Ops it runs, in declaration order.",
|
|
203
|
+
"description": "The Ops it runs, in declaration order: its turns' Ops, then those it runs beside its turns (#2861).",
|
|
204
204
|
"type": "array",
|
|
205
205
|
"items": {
|
|
206
206
|
"type": "object",
|
|
207
|
-
"required": ["name", "schedule", "env", "lastRun", "lastTick", "changesCheckout", "workLease"],
|
|
207
|
+
"required": ["name", "schedule", "env", "lastRun", "lastTick", "changesCheckout", "workLease", "beside"],
|
|
208
208
|
"properties": {
|
|
209
209
|
"name": { "type": "string" },
|
|
210
210
|
"schedule": {
|
|
@@ -330,12 +330,40 @@
|
|
|
330
330
|
}
|
|
331
331
|
}
|
|
332
332
|
]
|
|
333
|
+
},
|
|
334
|
+
"beside": {
|
|
335
|
+
"description": "For an Op the steward runs beside its turns (#2861), in a process of its own under the Op's own lease: ready is whether a ready step says when the operator starts it, and lease is the Op's lease ref (refs/chant/lease/<op>), held by a run of it while it runs (holder <steward>/<op>@<operator> for a run the operator started), read locally and never fetched, or null when none has held it. live is false once expiresAt has passed. Null for an Op the steward runs as one of its turns.",
|
|
336
|
+
"oneOf": [
|
|
337
|
+
{ "type": "null" },
|
|
338
|
+
{
|
|
339
|
+
"type": "object",
|
|
340
|
+
"required": ["ready", "lease"],
|
|
341
|
+
"properties": {
|
|
342
|
+
"ready": { "type": "boolean" },
|
|
343
|
+
"lease": {
|
|
344
|
+
"oneOf": [
|
|
345
|
+
{ "type": "null" },
|
|
346
|
+
{
|
|
347
|
+
"type": "object",
|
|
348
|
+
"required": ["holder", "acquiredAt", "expiresAt", "live"],
|
|
349
|
+
"properties": {
|
|
350
|
+
"holder": { "type": "string" },
|
|
351
|
+
"acquiredAt": { "type": "string" },
|
|
352
|
+
"expiresAt": { "type": "string" },
|
|
353
|
+
"live": { "type": "boolean" }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
]
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
]
|
|
333
361
|
}
|
|
334
362
|
}
|
|
335
363
|
}
|
|
336
364
|
},
|
|
337
365
|
"waiting": {
|
|
338
|
-
"description": "The open decision points the steward waits on (#2749): one for each
|
|
366
|
+
"description": "The open decision points the steward waits on (#2749): one for each Op whose newest run is the steward's own (its ledger record names the steward) and stopped on a question: the steward's own Ops in ops order, then any other Op of the member, such as one the steward's process starts itself (studio#137), by name. The state is the question's when the run stopped; `chant workspace points --open` has its state now, and hud asks a person there. The steward never answers one, and runs an Op it lists again on the first round after it is answered (an Op beside its turns, in a process of its own, #2861).",
|
|
339
367
|
"type": "array",
|
|
340
368
|
"items": {
|
|
341
369
|
"allOf": [
|
|
@@ -362,10 +390,44 @@
|
|
|
362
390
|
"since": { "type": "string", "description": "When the run stopped on it." }
|
|
363
391
|
}
|
|
364
392
|
},
|
|
393
|
+
"intent": {
|
|
394
|
+
"type": "object",
|
|
395
|
+
"required": ["id", "state", "question", "choice", "answer", "decided_by", "decided_on"],
|
|
396
|
+
"properties": {
|
|
397
|
+
"id": { "type": "string", "description": "The record's id, as the box block names it." },
|
|
398
|
+
"state": { "type": ["string", "null"], "description": "The record's state as written, such as proposed or decided, or null when no decision record has the id." },
|
|
399
|
+
"question": { "type": ["string", "null"], "description": "The question the record answers." },
|
|
400
|
+
"choice": { "description": "The record's choice as written, such as { option, reason }, or null while the record is proposed." },
|
|
401
|
+
"answer": { "type": ["string", "null"], "description": "The chosen option's label, from options[]'s entry whose id is choice.option, or null while no option is chosen (#2855). Added by chant 0.94.0." },
|
|
402
|
+
"decided_by": { "type": ["string", "null"], "description": "Who decided it, or null while it is proposed." },
|
|
403
|
+
"decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." }
|
|
404
|
+
}
|
|
405
|
+
},
|
|
365
406
|
"box": {
|
|
366
407
|
"type": "object",
|
|
367
|
-
"required": ["capabilities", "isolation"],
|
|
408
|
+
"required": ["capabilities", "isolation", "intent", "services"],
|
|
368
409
|
"properties": {
|
|
410
|
+
"services": {
|
|
411
|
+
"description": "The services the box block declares (#2880), in declaration order, or empty when it declares none. A reader has them before any converge tick; a steward's ConvergeOp's lastTick carries each one's verdict. cmd is as declared: its ${VAR} references are left for the process that applies it to expand. Added by chant 0.95.0.",
|
|
412
|
+
"type": "array",
|
|
413
|
+
"items": {
|
|
414
|
+
"type": "object",
|
|
415
|
+
"required": ["name", "cmd", "needs", "httpPort", "duration", "health", "optional"],
|
|
416
|
+
"properties": {
|
|
417
|
+
"name": { "type": "string" },
|
|
418
|
+
"cmd": { "type": "string", "description": "The command the supervisor runs, as declared." },
|
|
419
|
+
"needs": { "type": "array", "items": { "type": "string" }, "description": "Services of the same box that start first. Empty when none." },
|
|
420
|
+
"httpPort": { "type": ["integer", "null"], "description": "The port the supervisor routes the sprite's URL to, or null." },
|
|
421
|
+
"duration": { "type": ["string", "null"], "description": "How long the service must stay up after a create or start, such as 3s, or null for the supervisor's default." },
|
|
422
|
+
"health": { "type": ["string", "null"], "description": "A URL that answers 200 while the service works, or null when the supervisor's state decides." },
|
|
423
|
+
"optional": { "type": "boolean", "description": "True for a service something else creates by name, such as a site a release makes." }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
"intent": {
|
|
428
|
+
"description": "The decision record the box block names as the box's intent (#2850), or null when it names none. A box starts as a question: the record is proposed with a null choice, and decided when the person who answered it amends it. Read from the working tree, from the declared record kinds named decision. When no decision record has the id, every field but id is null and chant workspace check fails (WSP126). Added by chant 0.94.0.",
|
|
429
|
+
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/intent" }]
|
|
430
|
+
},
|
|
369
431
|
"isolation": {
|
|
370
432
|
"description": "The box's isolation resolved from its identity, its host, the member's name and its slot (#2727), or null when the block declares no host. A runtime that plants or starts the box sets these values instead of choosing its own. The same for every environment.",
|
|
371
433
|
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/isolation" }]
|
package/src/workspace/status.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { readReleasePlan, type ReleasePlan } from "../lifecycle/plan-ledger";
|
|
|
43
43
|
import { listWorkLeases, type WorkLeaseState } from "../lifecycle/work-lease";
|
|
44
44
|
import { findWorkspaceRoot } from "../project-root";
|
|
45
45
|
import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
|
|
46
|
+
import { resolveBoxIntents, unresolvedIntent, type BoxIntent } from "./box-intent";
|
|
46
47
|
import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
|
|
47
48
|
import type { ReasonCode } from "./reason-codes";
|
|
48
49
|
import { GATE_REASON_CODES, readMemberGates, type GateLedgerReader, type StatusGate, type StatusGateLedger } from "./status-gates";
|
|
@@ -178,6 +179,26 @@ export interface StatusBox {
|
|
|
178
179
|
capabilities: { name: string; broker: string | null; scope: string[] }[];
|
|
179
180
|
/** The box's ports, state paths and cookie names, resolved from its identity, or null when the block declares no host (#2727). */
|
|
180
181
|
isolation: ResolvedIsolation | null;
|
|
182
|
+
/**
|
|
183
|
+
* The decision record the block names as the box's intent (#2850): its id,
|
|
184
|
+
* state, question, choice, answer (the chosen option's label), decided_by
|
|
185
|
+
* and decided_on. Null when the block names none; every field but the id
|
|
186
|
+
* is null when no decision record has the id (WSP126).
|
|
187
|
+
*/
|
|
188
|
+
intent: BoxIntent | null;
|
|
189
|
+
/** The services the block declares (#2880), in file order, so a reader has them before any converge tick. Empty when it declares none. */
|
|
190
|
+
services: StatusBoxService[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A declared box service as `status --json` prints it (#2880): every field present, null or false when not declared. */
|
|
194
|
+
export interface StatusBoxService {
|
|
195
|
+
name: string;
|
|
196
|
+
cmd: string;
|
|
197
|
+
needs: string[];
|
|
198
|
+
httpPort: number | null;
|
|
199
|
+
duration: string | null;
|
|
200
|
+
health: string | null;
|
|
201
|
+
optional: boolean;
|
|
181
202
|
}
|
|
182
203
|
|
|
183
204
|
/**
|
|
@@ -377,6 +398,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
377
398
|
const now = query.now ?? new Date().toISOString();
|
|
378
399
|
|
|
379
400
|
const isolation = new Map(resolveBoxes(declaration).map((b) => [b.member.name, b.isolation]));
|
|
401
|
+
const intents = new Map((await resolveBoxIntents(declaration, found.dir)).map((i) => [i.member, i.record?.intent ?? unresolvedIntent(i.id)]));
|
|
380
402
|
const members: StatusMember[] = [];
|
|
381
403
|
for (const m of declaration.members) {
|
|
382
404
|
const environments: StatusEnvironment[] = [];
|
|
@@ -399,6 +421,16 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
399
421
|
: {
|
|
400
422
|
capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
|
|
401
423
|
isolation: isolation.get(m.name) ?? null,
|
|
424
|
+
intent: intents.get(m.name) ?? null,
|
|
425
|
+
services: m.box.services.map((s) => ({
|
|
426
|
+
name: s.name,
|
|
427
|
+
cmd: s.cmd,
|
|
428
|
+
needs: [...s.needs],
|
|
429
|
+
httpPort: s.httpPort,
|
|
430
|
+
duration: s.duration,
|
|
431
|
+
health: s.health,
|
|
432
|
+
optional: s.optional,
|
|
433
|
+
})),
|
|
402
434
|
},
|
|
403
435
|
stewards: stewards.stewards,
|
|
404
436
|
stewardReasons: stewards.reasons,
|
|
@@ -199,7 +199,7 @@ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () =
|
|
|
199
199
|
const doc = await amend(r, { evidence: [] });
|
|
200
200
|
if ("error" in doc) throw new Error(doc.error.message);
|
|
201
201
|
expect(doc.changed).toEqual(["evidence", "seal"]);
|
|
202
|
-
expect(doc.sealDropped).toMatch(/ws-003 was sealed by lex00, and the amendment moves its digest, so the seal was removed: seal it again with records amend ws-003 --sign/);
|
|
202
|
+
expect(doc.sealDropped).toMatch(/ws-003 was sealed by lex00, and the amendment moves its digest or its state, so the seal was removed: seal it again with records amend ws-003 --sign/);
|
|
203
203
|
expect(readFileSync(join(r.dir, FILE), "utf-8")).not.toMatch(/^seal:/m);
|
|
204
204
|
// Changing only the reviews leaves the digest, so the seal stays.
|
|
205
205
|
await amend(r, {}, lex);
|
|
@@ -215,6 +215,30 @@ describe.skipIf(!hasSshKeygen)("author seals under a signers file at base", () =
|
|
|
215
215
|
expect(warningCodes(rec)).toContain("record-unattested");
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
test("a move of state alone drops the seal, which signs the state though the digest leaves it out (#2873)", async () => {
|
|
219
|
+
const { r, lex } = workspace("state");
|
|
220
|
+
await amend(r, {}, lex);
|
|
221
|
+
const digest = (await record(r)).digest;
|
|
222
|
+
const doc = await amend(r, { state: "superseded" });
|
|
223
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
224
|
+
expect(doc.changed).toEqual(["state", "seal"]);
|
|
225
|
+
expect(doc.sealDropped).toMatch(/moves its digest or its state/);
|
|
226
|
+
expect((await record(r)).digest).toBe(digest);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("ratifying counts only verdicts whose seal verifies against the signers at base (#2873)", async () => {
|
|
230
|
+
const { r, alice, mallory } = workspace("ratify");
|
|
231
|
+
for (const k of [alice, mallory]) {
|
|
232
|
+
const by = k === alice ? "alice@example.test" : "mallory";
|
|
233
|
+
const doc = await reviewRecord({ kind: KIND, id: "ws-003", verdict: "agree", by, sign: k.file, cwd: r.dir });
|
|
234
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
235
|
+
}
|
|
236
|
+
const refused = await amend(r, { state: "ratified" });
|
|
237
|
+
expect("error" in refused && refused.error.code).toBe("ratify-quorum-not-met");
|
|
238
|
+
expect("error" in refused && refused.error.message).toMatch(/needs 2 agreeing verdicts that count, and it has 1 \(not counted: mallory, review-unattested\)/);
|
|
239
|
+
expect(readFileSync(join(r.dir, FILE), "utf-8")).toMatch(/^state: "decided"$/m);
|
|
240
|
+
});
|
|
241
|
+
|
|
218
242
|
test("a stale seal left by hand fails, and an edited state fails", async () => {
|
|
219
243
|
const { r, lex } = workspace("stale");
|
|
220
244
|
await amend(r, {}, lex);
|
|
@@ -308,7 +332,7 @@ describe.skipIf(!hasSshKeygen)("refusals", () => {
|
|
|
308
332
|
expect("error" in from && from.error.code).toBe("write-input-invalid");
|
|
309
333
|
expect(readFileSync(join(r.dir, FILE), "utf-8")).toBe(before);
|
|
310
334
|
|
|
311
|
-
await amend(r, { state: "
|
|
335
|
+
await amend(r, { state: "superseded" });
|
|
312
336
|
const closed = await amend(r, {}, lex);
|
|
313
337
|
expect("error" in closed && closed.error.code).toBe("record-closed");
|
|
314
338
|
});
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The work kind's `ready`/`blockedBy` against
|
|
3
|
-
* #2734)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* unit is open against it; a contract that is itself met leaves the queue.
|
|
2
|
+
* The work kind's `ready`/`blockedBy` against a reference ready-queue rule
|
|
3
|
+
* (ws-055, #2734), ported from chud's `readyQueue`
|
|
4
|
+
* (packages/runtime/src/ready.mjs at jhgaylor/chud 43afcf1): a contract
|
|
5
|
+
* enters the ready set once it is approved and in force, every `depends_on`
|
|
6
|
+
* id is `met` (drivers.mjs's `memberState`), and no unit is open against it;
|
|
7
|
+
* a contract that is itself met leaves the queue.
|
|
9
8
|
*
|
|
10
9
|
* Of those rules, the work kind's `ready` and `blockedBy` (work.ts) can
|
|
11
10
|
* express two directly, with no new field:
|
|
@@ -17,22 +16,21 @@
|
|
|
17
16
|
* retired contract does.
|
|
18
17
|
* - a record that is itself already `done` (or `dropped`, or merely
|
|
19
18
|
* `in-progress`) is not `ready`: `ready` requires `state === "open"`, the
|
|
20
|
-
* same "already met/already running leaves the queue" rule
|
|
21
|
-
* states for a contract that is itself met.
|
|
19
|
+
* same "already met/already running leaves the queue" rule the reference
|
|
20
|
+
* rule states for a contract that is itself met.
|
|
22
21
|
*
|
|
23
|
-
* The rules
|
|
24
|
-
* (memberState's draft/failing cases), re-checking that a `met`
|
|
25
|
-
* newest evidence still passes, and refusing a contract while a
|
|
26
|
-
* against it -- have no equivalent in a `needs` link. The full
|
|
27
|
-
* every
|
|
22
|
+
* The rules the reference rule adds on top -- approval before a dependency
|
|
23
|
+
* counts (memberState's draft/failing cases), re-checking that a `met`
|
|
24
|
+
* dependency's newest evidence still passes, and refusing a contract while a
|
|
25
|
+
* unit is open against it -- have no equivalent in a `needs` link. The full
|
|
26
|
+
* table of every rule and its home (a `needs` link, the runner, or "not
|
|
28
27
|
* applicable") is on INTENTIUS/chant#2734.
|
|
29
28
|
*
|
|
30
|
-
* This fixture mirrors a small
|
|
31
|
-
* `queryRecords`'s `ready`/`blockedBy` against a minimal, local
|
|
32
|
-
* re-implementation of the expressible subset of
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* mismatch.
|
|
29
|
+
* This fixture mirrors a small reference contract graph as work items and
|
|
30
|
+
* checks `queryRecords`'s `ready`/`blockedBy` against a minimal, local
|
|
31
|
+
* re-implementation of the expressible subset of that rule (met depends_on,
|
|
32
|
+
* self not already met), so a regression in either the work kind's readiness
|
|
33
|
+
* formula or this reading of the reference rule shows up as a mismatch.
|
|
36
34
|
*/
|
|
37
35
|
|
|
38
36
|
import { readFileSync } from "node:fs";
|
|
@@ -63,13 +61,13 @@ function work(id: string, fields: Record<string, unknown>): string {
|
|
|
63
61
|
}
|
|
64
62
|
|
|
65
63
|
/**
|
|
66
|
-
* The expressible subset of
|
|
64
|
+
* The expressible subset of the reference rule, on a graph shaped like
|
|
67
65
|
* `depends_on`: ready once the item is `open` (not already met, not already
|
|
68
|
-
* running) and every dependency is `done` (
|
|
69
|
-
* is `dropped` never becomes done, so it blocks forever --
|
|
70
|
-
*
|
|
66
|
+
* running) and every dependency is `done` (the reference rule's "met"). A
|
|
67
|
+
* dependency that is `dropped` never becomes done, so it blocks forever --
|
|
68
|
+
* the same effect the reference rule gets from a retired dependency.
|
|
71
69
|
*/
|
|
72
|
-
function
|
|
70
|
+
function referenceReadyIsh(items: { id: string; state: string; needs: string[] }[]): { ready: Set<string>; blockedBy: Map<string, string[]> } {
|
|
73
71
|
const stateOf = new Map(items.map((i) => [i.id, i.state]));
|
|
74
72
|
const ready = new Set<string>();
|
|
75
73
|
const blockedBy = new Map<string, string[]>();
|
|
@@ -90,13 +88,13 @@ beforeAll(() => {
|
|
|
90
88
|
"decisions/decision.schema.json": readFileSync(join(REF, "decisions", "decision.schema.json"), "utf-8"),
|
|
91
89
|
"work/work.kind.mjs": readFileSync(join(REF, "work", "work.kind.mjs"), "utf-8"),
|
|
92
90
|
"work/work.schema.json": readFileSync(join(REF, "work", "work.schema.json"), "utf-8"),
|
|
93
|
-
// W-001: already done, so it leaves the ready queue --
|
|
94
|
-
// contract that is itself met is done, not ready".
|
|
91
|
+
// W-001: already done, so it leaves the ready queue -- the reference
|
|
92
|
+
// rule's "a contract that is itself met is done, not ready".
|
|
95
93
|
"work/W-001-base.md": work("W-001", { state: "done", closed_on: "2026-09-24", evidence: [{ title: "shipped", url: "https://example.com/w1" }] }),
|
|
96
94
|
// W-002: needs W-001, which is done -- both rules agree this is ready.
|
|
97
95
|
"work/W-002-after-base.md": work("W-002", { needs: ["W-001"] }),
|
|
98
96
|
// W-003: needs W-004, which is dropped and so never turns done -- blocked
|
|
99
|
-
// forever, the same as a retired dependency in
|
|
97
|
+
// forever, the same as a retired dependency in the reference rule.
|
|
100
98
|
"work/W-003-needs-dropped.md": work("W-003", { needs: ["W-004"] }),
|
|
101
99
|
"work/W-004-dropped.md": work("W-004", { state: "dropped", closed_on: "2026-09-24" }),
|
|
102
100
|
// W-005: needs W-002, which is open (not done yet) -- blocked.
|
|
@@ -111,22 +109,22 @@ beforeAll(() => {
|
|
|
111
109
|
});
|
|
112
110
|
afterAll(cleanScratch);
|
|
113
111
|
|
|
114
|
-
describe("work kind ready/blockedBy vs.
|
|
112
|
+
describe("work kind ready/blockedBy vs. a reference ready-queue rule, expressible rules (#2734)", () => {
|
|
115
113
|
test("matches on every item, for the rules a needs link can express", async () => {
|
|
116
114
|
const doc = await queryRecords({ kind: WORK, cwd: root });
|
|
117
115
|
if ("error" in doc) throw new Error(doc.error.message);
|
|
118
116
|
expect(doc.summary).toEqual({ total: 7, valid: 7, invalid: 0, superseded: 0 });
|
|
119
117
|
|
|
120
118
|
const items = doc.records.map((r) => ({ id: r.id!, state: r.state!, needs: (r.data as { needs?: string[] }).needs ?? [] }));
|
|
121
|
-
const
|
|
119
|
+
const reference = referenceReadyIsh(items);
|
|
122
120
|
|
|
123
121
|
for (const r of doc.records) {
|
|
124
122
|
const id = r.id!;
|
|
125
|
-
expect(r.ready, `${id}.ready`).toBe(
|
|
123
|
+
expect(r.ready, `${id}.ready`).toBe(reference.ready.has(id));
|
|
126
124
|
expect(
|
|
127
125
|
(r.blockedBy ?? []).map((b) => b.id),
|
|
128
126
|
`${id}.blockedBy`,
|
|
129
|
-
).toEqual(
|
|
127
|
+
).toEqual(reference.blockedBy.get(id));
|
|
130
128
|
}
|
|
131
129
|
});
|
|
132
130
|
|
|
@@ -370,6 +370,13 @@ describe("writing work items on a copy of the reference workspace (#2683)", () =
|
|
|
370
370
|
expect("error" in doc && doc.error.code).toBe("record-schema-invalid");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
test("records new refuses an ask source with no said or by (#2851)", async () => {
|
|
374
|
+
const noSaid = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { ask: { by: "morgan", at: "2026-09-25T10:00:00Z", via: "hud" } } }), dryRun: true, cwd: copy });
|
|
375
|
+
expect("error" in noSaid && noSaid.error.code).toBe("record-schema-invalid");
|
|
376
|
+
const noBy = await newRecord({ kind: "work/work.kind.mjs", fields: fresh({ source: { ask: { said: "Can we add a dark mode toggle?", at: "2026-09-25T10:00:00Z", via: "hud" } } }), dryRun: true, cwd: copy });
|
|
377
|
+
expect("error" in noBy && noBy.error.code).toBe("record-schema-invalid");
|
|
378
|
+
});
|
|
379
|
+
|
|
373
380
|
test("records new with a fresh prefix takes --prefix W", async () => {
|
|
374
381
|
const doc = await newRecord({ kind: "work/work.kind.mjs", fields: fresh(), prefix: "W", dryRun: true, cwd: copy });
|
|
375
382
|
expect(doc).toMatchObject({ id: "W-004" });
|
|
@@ -388,4 +395,14 @@ describe("writing work items on a copy of the reference workspace (#2683)", () =
|
|
|
388
395
|
const doc = await reviewRecord({ kind: "work/work.kind.mjs", id: "W-003", verdict: "agree", by: "alice", cwd: copy });
|
|
389
396
|
expect("error" in doc && doc.error.code).toBe("review-unsupported");
|
|
390
397
|
});
|
|
398
|
+
|
|
399
|
+
test("records new with an ask source round-trips through records --json (#2851)", async () => {
|
|
400
|
+
const ask = { said: "Can we add a dark mode toggle to the wireframe?", by: "morgan", at: "2026-09-25T10:00:00Z", via: "hud", session: "hud-9f2" };
|
|
401
|
+
const doc = await newRecord({ kind: resolveWriteKind("work", copy), fields: fresh({ source: { ask } }), cwd: copy });
|
|
402
|
+
if ("error" in doc) throw new Error(`${doc.error.code}: ${doc.error.message}`);
|
|
403
|
+
const read = await queryRecords({ kind: "work/work.kind.mjs", cwd: copy });
|
|
404
|
+
records.expectValid(read);
|
|
405
|
+
if ("error" in read) throw new Error(read.error.message);
|
|
406
|
+
expect(read.records.find((r) => r.id === doc.id)).toMatchObject({ valid: true, data: { source: { ask } } });
|
|
407
|
+
});
|
|
391
408
|
});
|
|
@@ -72,6 +72,9 @@
|
|
|
72
72
|
"maxLength": 255,
|
|
73
73
|
"pattern": "^[^\\u0000-\\u001f]+$"
|
|
74
74
|
}
|
|
75
|
+
},
|
|
76
|
+
"graph": {
|
|
77
|
+
"$ref": "#/$defs/graph"
|
|
75
78
|
}
|
|
76
79
|
},
|
|
77
80
|
"patternProperties": {
|
|
@@ -128,6 +131,29 @@
|
|
|
128
131
|
"type": "string",
|
|
129
132
|
"description": "A file name, with no directory part. A * matches any run of characters.",
|
|
130
133
|
"pattern": "^(?!\\.\\.?$)[^/\\\\]+$"
|
|
134
|
+
},
|
|
135
|
+
"graph": {
|
|
136
|
+
"type": "object",
|
|
137
|
+
"description": "How chant workspace graph reads a member of this kind (#2874). chant writes a reader project of its own outside the workspace, with a chant.config.json that declares lexicon and puts config under that lexicon's key, and runs the member's chant graph there. In every string key and value of config, {member} becomes the member's name, {dir} its absolute directory and {workspace} the absolute workspace root. Without this field a member of the kind is listed with kind-not-run.",
|
|
138
|
+
"required": [
|
|
139
|
+
"lexicon",
|
|
140
|
+
"config"
|
|
141
|
+
],
|
|
142
|
+
"properties": {
|
|
143
|
+
"lexicon": {
|
|
144
|
+
"type": "string",
|
|
145
|
+
"pattern": "^[a-z][a-z0-9-]{0,39}$",
|
|
146
|
+
"description": "The lexicon that reads the member, by name. The package that supplies the kind must be @intentius/chant-lexicon-<lexicon>, so the lexicon chant loads is the one the declaration pins."
|
|
147
|
+
},
|
|
148
|
+
"config": {
|
|
149
|
+
"type": "object",
|
|
150
|
+
"description": "The lexicon's config namespace for the reader project, such as terraform's roots. Placeholders are substituted; nothing else is interpreted."
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"patternProperties": {
|
|
154
|
+
"^x-": true
|
|
155
|
+
},
|
|
156
|
+
"additionalProperties": false
|
|
131
157
|
}
|
|
132
158
|
}
|
|
133
159
|
}
|