@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
|
@@ -29,6 +29,7 @@ import type { CommandContext } from "../cli/registry";
|
|
|
29
29
|
import type { ReasonCode } from "./reason-codes";
|
|
30
30
|
import { gitRoot, workingTreeSource, type RecordSource } from "./record-source";
|
|
31
31
|
import {
|
|
32
|
+
computeQuorum,
|
|
32
33
|
loadRecordKind,
|
|
33
34
|
parseFrontMatter,
|
|
34
35
|
readRecords,
|
|
@@ -41,7 +42,8 @@ import {
|
|
|
41
42
|
type RecordEntry,
|
|
42
43
|
type RecordWarning,
|
|
43
44
|
} from "./records";
|
|
44
|
-
import { declaredKindFiles, pinRoot, realpathOr } from "./records-cli";
|
|
45
|
+
import { declaredKindFiles, pinRoot, quorumOptionsFor, realpathOr } from "./records-cli";
|
|
46
|
+
import { policyAtBase, resolveBase } from "./trust/provenance";
|
|
45
47
|
import { WorkspaceReadError } from "./declaration";
|
|
46
48
|
import { findSessionKinds, headCommit, sessionKindsFor } from "./session-kinds";
|
|
47
49
|
import { workingTree } from "./tree";
|
|
@@ -69,6 +71,7 @@ export const NEW_ERROR_CODES = [
|
|
|
69
71
|
"record-sign-failed",
|
|
70
72
|
"source-harvest-not-proposed",
|
|
71
73
|
"record-state-not-initial",
|
|
74
|
+
"ratify-quorum-not-met",
|
|
72
75
|
...RECORD_REASON_CODES,
|
|
73
76
|
] as const satisfies readonly ReasonCode[];
|
|
74
77
|
|
|
@@ -82,6 +85,7 @@ export const AMEND_ERROR_CODES = [
|
|
|
82
85
|
"record-closed",
|
|
83
86
|
"amend-supersede-instead",
|
|
84
87
|
"record-sign-failed",
|
|
88
|
+
"ratify-quorum-not-met",
|
|
85
89
|
...RECORD_REASON_CODES,
|
|
86
90
|
] as const satisfies readonly ReasonCode[];
|
|
87
91
|
|
|
@@ -386,6 +390,11 @@ export function overlay(base: RecordSource, path: string, text: string): RecordS
|
|
|
386
390
|
* record's warnings.
|
|
387
391
|
*/
|
|
388
392
|
export async function validateWrite(o: Opened, before: RecordEntry[], path: string, text: string, base: RecordSource = o.source): Promise<RecordWarning[]> {
|
|
393
|
+
return (await validatedEntry(o, before, path, text, base)).warnings;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** {@link validateWrite}, returning the written record as the read found it. */
|
|
397
|
+
export async function validatedEntry(o: Opened, before: RecordEntry[], path: string, text: string, base: RecordSource = o.source): Promise<RecordEntry> {
|
|
389
398
|
const after = await readAll(o, overlay(base, path, text));
|
|
390
399
|
const written = after.find((e) => e.path === path);
|
|
391
400
|
if (!written) throw new RecordWriteError("record-path-unmatched", `${path} is not a file the kind ${o.view.name} reads`);
|
|
@@ -398,7 +407,30 @@ export async function validateWrite(o: Opened, before: RecordEntry[], path: stri
|
|
|
398
407
|
const gained = e.reasons.find((r) => !had.get(e.path)?.has(`${r.code}\0${r.message}`));
|
|
399
408
|
if (gained) throw new RecordWriteError(gained.code, `writing ${path} would make ${e.path} invalid: ${gained.message}`);
|
|
400
409
|
}
|
|
401
|
-
return written
|
|
410
|
+
return written;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Refuse a written record in the kind's ratified state (`reviews.ratified`,
|
|
415
|
+
* #2873) whose quorum is not met. The quorum is computed on the record as
|
|
416
|
+
* written, the way `records --json` computes it: the need from the
|
|
417
|
+
* declaration in the working tree, and agents and seals from the policy at
|
|
418
|
+
* base. A met quorum with open concerns is met, and the concerns stay listed.
|
|
419
|
+
*/
|
|
420
|
+
async function refuseRatifyBelowQuorum(o: Opened, written: RecordEntry, id: string): Promise<void> {
|
|
421
|
+
const ratified = o.loaded.kind.reviews?.ratified;
|
|
422
|
+
if (ratified === undefined || written.state !== ratified) return;
|
|
423
|
+
const top = gitRoot(o.root);
|
|
424
|
+
const policy = policyAtBase(top ?? o.root, top ? resolveBase(top) : { commit: null, from: null });
|
|
425
|
+
const tree = workingTree(o.workspaceRoot === "." ? o.root : join(o.root, ...o.workspaceRoot.split("/")));
|
|
426
|
+
const options = await quorumOptionsFor(o.loaded, tree, policy);
|
|
427
|
+
const q = options ? computeQuorum(o.loaded.kind, written, options) : null;
|
|
428
|
+
if (q === null || q.met) return;
|
|
429
|
+
const notCounted = q.notCounted.length > 0 ? ` (not counted: ${q.notCounted.map((v) => `${v.reviewer}, ${v.reason!.code}`).join("; ")})` : "";
|
|
430
|
+
throw new RecordWriteError(
|
|
431
|
+
"ratify-quorum-not-met",
|
|
432
|
+
`${id} can't be ${ratified} yet: its quorum needs ${q.need} agreeing verdicts that count, and it has ${q.agreed}${notCounted}. Add verdicts with chant workspace records review ${id} --verdict agree --by <reviewer>, then set ${o.loaded.kind.stateField} to ${ratified} again`,
|
|
433
|
+
);
|
|
402
434
|
}
|
|
403
435
|
|
|
404
436
|
export function findRecord(entries: RecordEntry[], id: string, kind: string): RecordEntry & { data: Record<string, unknown> } {
|
|
@@ -726,7 +758,9 @@ export async function newRecord(opts: NewRecordOptions & ChannelOptions): Promis
|
|
|
726
758
|
let text = renderRecord(data, title ? `\n# ${title}\n` : "");
|
|
727
759
|
let seal: AuthorSeal | undefined;
|
|
728
760
|
if (opts.sign !== undefined) ({ text, seal } = await sealAuthor(o, text, data, id, opts.sign, opts.cwd));
|
|
729
|
-
const
|
|
761
|
+
const written = await validatedEntry(o, before, path, text);
|
|
762
|
+
await refuseRatifyBelowQuorum(o, written, id);
|
|
763
|
+
const warnings = written.warnings;
|
|
730
764
|
if (!opts.dryRun) writeFileSync(abs(o, path), text, { flag: "wx" });
|
|
731
765
|
return {
|
|
732
766
|
$schema: RECORDS_NEW_SCHEMA_ID,
|
|
@@ -823,16 +857,26 @@ export async function amendRecord(opts: AmendRecordOptions & ChannelOptions): Pr
|
|
|
823
857
|
if (opts.sign !== undefined) {
|
|
824
858
|
({ text, seal } = await sealAuthor(o, text, merged, opts.id, opts.sign, opts.cwd));
|
|
825
859
|
merged[RECORD_SEAL_FIELD] = seal;
|
|
826
|
-
} else if (
|
|
860
|
+
} else if (
|
|
861
|
+
sealable &&
|
|
862
|
+
(recordTextDigest(text, digestFields(kind), kind.format) !== recordTextDigest(current, digestFields(kind), kind.format) ||
|
|
863
|
+
(kind.stateField !== undefined && stableJson(old[kind.stateField]) !== stableJson(merged[kind.stateField])))
|
|
864
|
+
) {
|
|
865
|
+
// The seal signs the state as well as the digest, and a kind with reviews.ratified leaves the state out of the digest (#2873).
|
|
827
866
|
const signer = (old[RECORD_SEAL_FIELD] as { signer?: unknown } | null)?.signer;
|
|
828
867
|
delete merged[RECORD_SEAL_FIELD];
|
|
829
868
|
const dropped = removeField(text, RECORD_SEAL_FIELD, merged);
|
|
830
869
|
if (dropped === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: the ${RECORD_SEAL_FIELD} block can't be removed without changing the rest of the file`);
|
|
831
870
|
text = dropped;
|
|
832
|
-
sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
|
|
871
|
+
sealDropped = `${opts.id} was sealed${typeof signer === "string" ? ` by ${signer}` : ""}, and the amendment moves its digest or its state, so the seal was removed: seal it again with records amend ${opts.id} --sign`;
|
|
833
872
|
}
|
|
834
873
|
if (stableJson(old[RECORD_SEAL_FIELD]) !== stableJson(merged[RECORD_SEAL_FIELD])) changed.push(RECORD_SEAL_FIELD);
|
|
835
|
-
|
|
874
|
+
let warnings = target.warnings;
|
|
875
|
+
if (changed.length > 0) {
|
|
876
|
+
const written = await validatedEntry(o, before, target.path, text);
|
|
877
|
+
if (kind.stateField !== undefined && changed.includes(kind.stateField)) await refuseRatifyBelowQuorum(o, written, opts.id);
|
|
878
|
+
warnings = written.warnings;
|
|
879
|
+
}
|
|
836
880
|
if (!opts.dryRun && changed.length > 0) writeFileSync(abs(o, target.path), text);
|
|
837
881
|
return {
|
|
838
882
|
$schema: RECORDS_AMEND_SCHEMA_ID,
|
package/src/workspace/records.ts
CHANGED
|
@@ -166,10 +166,16 @@ export const RECORD_SEAL_FIELD = "seal";
|
|
|
166
166
|
/**
|
|
167
167
|
* The top-level fields {@link recordTextDigest} leaves out for a kind: its
|
|
168
168
|
* reviews list and {@link RECORD_SEAL_FIELD} when it has a reviews list, and
|
|
169
|
-
* nothing when it has none (#2672, #2688).
|
|
169
|
+
* nothing when it has none (#2672, #2688). A kind that names a ratified
|
|
170
|
+
* state (`reviews.ratified`, #2873) leaves its state field out too, so
|
|
171
|
+
* moving a record to that state keeps the verdicts that ratified it
|
|
172
|
+
* counting.
|
|
170
173
|
*/
|
|
171
|
-
export function digestFields(kind: Pick<RecordKind, "reviews">): string[] | null {
|
|
172
|
-
|
|
174
|
+
export function digestFields(kind: Pick<RecordKind, "reviews" | "stateField">): string[] | null {
|
|
175
|
+
if (!kind.reviews) return null;
|
|
176
|
+
const fields = [kind.reviews.field, RECORD_SEAL_FIELD];
|
|
177
|
+
if (kind.reviews.ratified !== undefined && kind.stateField !== undefined) fields.push(kind.stateField);
|
|
178
|
+
return fields;
|
|
173
179
|
}
|
|
174
180
|
|
|
175
181
|
/** What a verdict's seal establishes (#2687). See `trust/seal.ts`. */
|
|
@@ -330,8 +336,14 @@ export const recordKindSchema = z
|
|
|
330
336
|
* entry holds `reviewer`, `verdict` (agree, dissent or abstain) and
|
|
331
337
|
* optionally `digest`, `note`, `proposes`, `addressed_by` and
|
|
332
338
|
* `withdrawn_on`. Optional.
|
|
339
|
+
*
|
|
340
|
+
* `ratified` names the state a record enters only once its quorum is met
|
|
341
|
+
* (#2873): `records new` and `records amend` refuse it below the quorum,
|
|
342
|
+
* with ratify-quorum-not-met. With it the digest leaves the state field
|
|
343
|
+
* out, so the move to that state keeps the verdicts counting. It must be
|
|
344
|
+
* one of `states`.
|
|
333
345
|
*/
|
|
334
|
-
reviews: z.object({ field: z.string().min(1), decider: z.string().min(1) }).strict().optional(),
|
|
346
|
+
reviews: z.object({ field: z.string().min(1), decider: z.string().min(1), ratified: z.string().min(1).optional() }).strict().optional(),
|
|
335
347
|
/**
|
|
336
348
|
* The front-matter field a new record's proposer is named on, when the
|
|
337
349
|
* kind opts in (#2756). `records new --by` and the MCP `records-new`
|
|
@@ -448,6 +460,10 @@ export const recordKindSchema = z
|
|
|
448
460
|
message: "a kind without states cannot have proposedBy: there is no first state for a record to open in",
|
|
449
461
|
path: ["proposedBy"],
|
|
450
462
|
})
|
|
463
|
+
.refine((k) => k.reviews?.ratified === undefined || (k.states ?? []).includes(k.reviews.ratified), {
|
|
464
|
+
message: "reviews.ratified must be one of states: it names the state a record enters once its quorum is met",
|
|
465
|
+
path: ["reviews", "ratified"],
|
|
466
|
+
})
|
|
451
467
|
.refine((k) => (k.closedStates ?? []).every((s) => (k.states ?? []).includes(s)), {
|
|
452
468
|
message: "every closed state must be listed in states",
|
|
453
469
|
path: ["closedStates"],
|
|
@@ -731,7 +747,8 @@ function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined
|
|
|
731
747
|
* kind with a reviews list the list is the reviews field and `seal`, the
|
|
732
748
|
* record's author seal (#2688; {@link digestFields}), so sealing a record
|
|
733
749
|
* leaves its digest where it was too. A record with no `seal` hashes exactly
|
|
734
|
-
* as it did before author seals existed.
|
|
750
|
+
* as it did before author seals existed. A kind with `reviews.ratified` adds
|
|
751
|
+
* its state field (#2873).
|
|
735
752
|
*
|
|
736
753
|
* The rule, which a hand-editor can follow with a text editor and
|
|
737
754
|
* `sha256sum`:
|
|
@@ -312,10 +312,38 @@ describe("chant workspace status on built workspaces", () => {
|
|
|
312
312
|
],
|
|
313
313
|
isolation: null,
|
|
314
314
|
intent: null,
|
|
315
|
+
services: [],
|
|
315
316
|
},
|
|
316
317
|
]);
|
|
317
318
|
});
|
|
318
319
|
|
|
320
|
+
test("each member's box lists the services its block declares, every field present (#2880)", async () => {
|
|
321
|
+
const root = repo({
|
|
322
|
+
"chant.workspace.json": declaration([
|
|
323
|
+
{
|
|
324
|
+
name: "box",
|
|
325
|
+
dir: "box",
|
|
326
|
+
kind: "other",
|
|
327
|
+
because: "the box's steward and its Ops",
|
|
328
|
+
box: {
|
|
329
|
+
services: [
|
|
330
|
+
{ name: "app", cmd: "${HOME}/box/run-app.sh", duration: "3s", health: "http://127.0.0.1:5173/health" },
|
|
331
|
+
{ name: "door", cmd: "${HOME}/box/run-door.sh", needs: ["app"], httpPort: 8080 },
|
|
332
|
+
{ name: "site", cmd: "${HOME}/box/run-site.sh", optional: true },
|
|
333
|
+
],
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
]),
|
|
337
|
+
});
|
|
338
|
+
const doc = result(await workspaceStatus({ cwd: root, env: "prod" }));
|
|
339
|
+
expectValid(doc);
|
|
340
|
+
expect(doc.members[0].box?.services).toEqual([
|
|
341
|
+
{ name: "app", cmd: "${HOME}/box/run-app.sh", needs: [], httpPort: null, duration: "3s", health: "http://127.0.0.1:5173/health", optional: false },
|
|
342
|
+
{ name: "door", cmd: "${HOME}/box/run-door.sh", needs: ["app"], httpPort: 8080, duration: null, health: null, optional: false },
|
|
343
|
+
{ name: "site", cmd: "${HOME}/box/run-site.sh", needs: [], httpPort: null, duration: null, health: null, optional: true },
|
|
344
|
+
]);
|
|
345
|
+
});
|
|
346
|
+
|
|
319
347
|
test("every failure validates with its code", async () => {
|
|
320
348
|
const empty = repo({});
|
|
321
349
|
const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-status-nogit-")));
|
|
@@ -600,8 +628,9 @@ describe("stewards in chant workspace status --json (#2731)", () => {
|
|
|
600
628
|
lastTick: null,
|
|
601
629
|
changesCheckout: false,
|
|
602
630
|
workLease: null,
|
|
631
|
+
beside: null,
|
|
603
632
|
},
|
|
604
|
-
{ name: "box-release", schedule: null, env: "local", lastRun: null, lastTick: null, changesCheckout: false, workLease: null },
|
|
633
|
+
{ name: "box-release", schedule: null, env: "local", lastRun: null, lastTick: null, changesCheckout: false, workLease: null, beside: null },
|
|
605
634
|
{
|
|
606
635
|
name: "box-dispatch",
|
|
607
636
|
schedule: { cron: "*/5 * * * *", overlap: "skip" },
|
|
@@ -622,6 +651,7 @@ describe("stewards in chant workspace status --json (#2731)", () => {
|
|
|
622
651
|
},
|
|
623
652
|
],
|
|
624
653
|
},
|
|
654
|
+
beside: null,
|
|
625
655
|
},
|
|
626
656
|
]);
|
|
627
657
|
// #2749: nothing the steward runs waits on a decision point.
|
|
@@ -26,13 +26,17 @@
|
|
|
26
26
|
* `chant operator --steward` holds it or until it expires;
|
|
27
27
|
* - an Op's `workLease.held` is the work leases its turns hold (#2748): those
|
|
28
28
|
* whose holder is `<steward>/<op>@...` (`stewardWorkHolder`), read from the
|
|
29
|
-
* 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.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
import { existsSync, realpathSync } from "node:fs";
|
|
33
37
|
import { dirname, join, relative, resolve } from "node:path";
|
|
34
38
|
import { discoverOps, discoverStewards } from "../op/discover";
|
|
35
|
-
import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
39
|
+
import { stewardBesideFor, stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
36
40
|
import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
|
|
37
41
|
import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
|
|
38
42
|
import { leaseRef } from "../lifecycle/lease";
|
|
@@ -123,6 +127,21 @@ export interface StatusStewardOp {
|
|
|
123
127
|
* the steward's turns of this Op hold, a live one while a turn runs.
|
|
124
128
|
*/
|
|
125
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;
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
/** A work lease a steward's turn holds (#2748). */
|
|
@@ -152,7 +171,7 @@ export interface StatusSteward {
|
|
|
152
171
|
*/
|
|
153
172
|
capabilities: { name: string; broker: string | null; declared: boolean }[];
|
|
154
173
|
/** The local steward's own lease, or null when no local operator has held it. */
|
|
155
|
-
lease:
|
|
174
|
+
lease: StatusStewardLease | null;
|
|
156
175
|
ops: StatusStewardOp[];
|
|
157
176
|
/**
|
|
158
177
|
* The open decision points the steward waits on (#2749): each Op whose
|
|
@@ -206,10 +225,11 @@ function isChantProject(dir: string): boolean {
|
|
|
206
225
|
return existsSync(join(dir, "chant.config.ts")) || existsSync(join(dir, "chant.config.json"));
|
|
207
226
|
}
|
|
208
227
|
|
|
209
|
-
|
|
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> {
|
|
210
230
|
try {
|
|
211
231
|
const { prefix } = await resolveMemberLedger(memberDir);
|
|
212
|
-
const sha = await readRefSha(leaseRef(
|
|
232
|
+
const sha = await readRefSha(leaseRef(leaseName, prefix), { cwd: memberDir });
|
|
213
233
|
if (!sha) return null;
|
|
214
234
|
const record = JSON.parse((await readBlobBySha(sha, { cwd: memberDir })) ?? "") as Record<string, unknown>;
|
|
215
235
|
if (typeof record.holder !== "string" || typeof record.expiresAt !== "string" || typeof record.acquiredAt !== "string") return null;
|
|
@@ -224,6 +244,18 @@ async function readStewardLease(name: string, memberDir: string, now: string): P
|
|
|
224
244
|
}
|
|
225
245
|
}
|
|
226
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
|
+
|
|
227
259
|
/** The work leases `steward`'s turns of `op` hold, from the ledger of the Op's kind or the member's. */
|
|
228
260
|
async function readHeldWorkLeases(steward: string, op: OpConfig, memberDir: string, now: string): Promise<StatusStewardWorkLease[]> {
|
|
229
261
|
try {
|
|
@@ -314,6 +346,7 @@ export async function readMemberStewards(
|
|
|
314
346
|
workLease: op.workLease
|
|
315
347
|
? { kind: op.workLease.kind ?? null, held: await readHeldWorkLeases(declaration.name, op, memberDir, now) }
|
|
316
348
|
: null,
|
|
349
|
+
beside: await besideOf(declaration, op.name, memberDir, now),
|
|
317
350
|
});
|
|
318
351
|
}
|
|
319
352
|
stewards.push({
|
|
@@ -326,7 +359,7 @@ export async function readMemberStewards(
|
|
|
326
359
|
const declared = box?.capabilities.find((c) => c.name === name);
|
|
327
360
|
return { name, broker: declared?.broker ?? null, declared: declared !== undefined };
|
|
328
361
|
}),
|
|
329
|
-
lease: await
|
|
362
|
+
lease: await readLeaseRef(stewardLeaseName(declaration.name), memberDir, now),
|
|
330
363
|
ops,
|
|
331
364
|
waiting,
|
|
332
365
|
});
|
|
@@ -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 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.",
|
|
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": [
|
|
@@ -377,8 +405,25 @@
|
|
|
377
405
|
},
|
|
378
406
|
"box": {
|
|
379
407
|
"type": "object",
|
|
380
|
-
"required": ["capabilities", "isolation", "intent"],
|
|
408
|
+
"required": ["capabilities", "isolation", "intent", "services"],
|
|
381
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
|
+
},
|
|
382
427
|
"intent": {
|
|
383
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.",
|
|
384
429
|
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/intent" }]
|
package/src/workspace/status.ts
CHANGED
|
@@ -186,6 +186,19 @@ export interface StatusBox {
|
|
|
186
186
|
* is null when no decision record has the id (WSP126).
|
|
187
187
|
*/
|
|
188
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;
|
|
189
202
|
}
|
|
190
203
|
|
|
191
204
|
/**
|
|
@@ -409,6 +422,15 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
409
422
|
capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
|
|
410
423
|
isolation: isolation.get(m.name) ?? null,
|
|
411
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
|
+
})),
|
|
412
434
|
},
|
|
413
435
|
stewards: stewards.stewards,
|
|
414
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
|
});
|
|
@@ -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
|
}
|