@intentius/chant 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/dist/cli/handlers/operator.d.ts.map +1 -1
  2. package/dist/cli/handlers/run.d.ts.map +1 -1
  3. package/dist/cli/main.d.ts.map +1 -1
  4. package/dist/cli/registry.d.ts +2 -0
  5. package/dist/cli/registry.d.ts.map +1 -1
  6. package/dist/op/builders.d.ts +14 -3
  7. package/dist/op/builders.d.ts.map +1 -1
  8. package/dist/op/index.d.ts +6 -3
  9. package/dist/op/index.d.ts.map +1 -1
  10. package/dist/op/operator.d.ts +90 -0
  11. package/dist/op/operator.d.ts.map +1 -1
  12. package/dist/op/steward-beside.d.ts +84 -0
  13. package/dist/op/steward-beside.d.ts.map +1 -0
  14. package/dist/op/steward.d.ts +87 -2
  15. package/dist/op/steward.d.ts.map +1 -1
  16. package/dist/workspace/box-services.d.ts +31 -0
  17. package/dist/workspace/box-services.d.ts.map +1 -0
  18. package/dist/workspace/compose-graph.d.ts +11 -0
  19. package/dist/workspace/compose-graph.d.ts.map +1 -1
  20. package/dist/workspace/composites.d.ts +5 -1
  21. package/dist/workspace/composites.d.ts.map +1 -1
  22. package/dist/workspace/declaration.d.ts +37 -0
  23. package/dist/workspace/declaration.d.ts.map +1 -1
  24. package/dist/workspace/declaration.schema.json +57 -1
  25. package/dist/workspace/graph-cache.d.ts +168 -0
  26. package/dist/workspace/graph-cache.d.ts.map +1 -0
  27. package/dist/workspace/graph-cli.d.ts +11 -5
  28. package/dist/workspace/graph-cli.d.ts.map +1 -1
  29. package/dist/workspace/kind-readers.d.ts +39 -0
  30. package/dist/workspace/kind-readers.d.ts.map +1 -0
  31. package/dist/workspace/kinds.d.ts +29 -0
  32. package/dist/workspace/kinds.d.ts.map +1 -1
  33. package/dist/workspace/member-commands.d.ts +15 -1
  34. package/dist/workspace/member-commands.d.ts.map +1 -1
  35. package/dist/workspace/member-run.d.ts +2 -0
  36. package/dist/workspace/member-run.d.ts.map +1 -1
  37. package/dist/workspace/reason-codes.d.ts +3 -1
  38. package/dist/workspace/reason-codes.d.ts.map +1 -1
  39. package/dist/workspace/records-cli.d.ts +10 -1
  40. package/dist/workspace/records-cli.d.ts.map +1 -1
  41. package/dist/workspace/records-write.d.ts +4 -2
  42. package/dist/workspace/records-write.d.ts.map +1 -1
  43. package/dist/workspace/records.d.ts +8 -3
  44. package/dist/workspace/records.d.ts.map +1 -1
  45. package/dist/workspace/status-stewards.d.ts +23 -7
  46. package/dist/workspace/status-stewards.d.ts.map +1 -1
  47. package/dist/workspace/status.d.ts +12 -0
  48. package/dist/workspace/status.d.ts.map +1 -1
  49. package/dist/workspace/work-evidence.d.ts +1 -1
  50. package/dist/workspace/work-evidence.d.ts.map +1 -1
  51. package/dist/workspace/workspace-kinds.schema.json +26 -0
  52. package/package.json +1 -1
  53. package/src/cli/commands/carve-bridge.test.ts +7 -3
  54. package/src/cli/handlers/operator-steward-signal.e2e.test.ts +97 -0
  55. package/src/cli/handlers/operator.ts +53 -11
  56. package/src/cli/handlers/run.test.ts +71 -0
  57. package/src/cli/handlers/run.ts +65 -7
  58. package/src/cli/main.ts +17 -6
  59. package/src/cli/mcp/workspace-tools.ts +1 -1
  60. package/src/cli/registry.ts +2 -0
  61. package/src/cli/static-config-read.test.ts +8 -2
  62. package/src/meta/source-is-text.test.ts +21 -3
  63. package/src/okf.test.ts +6 -1
  64. package/src/op/builders.ts +14 -3
  65. package/src/op/index.ts +8 -2
  66. package/src/op/operator.ts +264 -16
  67. package/src/op/steward-beside.test.ts +267 -0
  68. package/src/op/steward-beside.ts +219 -0
  69. package/src/op/steward-points.test.ts +61 -1
  70. package/src/op/steward.ts +135 -3
  71. package/src/workspace/box-services.test.ts +129 -0
  72. package/src/workspace/box-services.ts +51 -0
  73. package/src/workspace/checks/boxes.test.ts +1 -0
  74. package/src/workspace/compose-graph.test.ts +1 -0
  75. package/src/workspace/compose-graph.ts +11 -0
  76. package/src/workspace/composites.test.ts +1 -1
  77. package/src/workspace/composites.ts +12 -5
  78. package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +4 -2
  79. package/src/workspace/declaration.schema.json +57 -1
  80. package/src/workspace/declaration.ts +104 -0
  81. package/src/workspace/graph-cache.test.ts +343 -0
  82. package/src/workspace/graph-cache.ts +409 -0
  83. package/src/workspace/graph-cli.ts +93 -26
  84. package/src/workspace/graph-contract.test.ts +129 -6
  85. package/src/workspace/graph.schema.json +21 -1
  86. package/src/workspace/kind-readers.e2e.test.ts +68 -0
  87. package/src/workspace/kind-readers.test.ts +134 -0
  88. package/src/workspace/kind-readers.ts +111 -0
  89. package/src/workspace/kinds.test.ts +49 -0
  90. package/src/workspace/kinds.ts +59 -2
  91. package/src/workspace/member-commands.test.ts +15 -0
  92. package/src/workspace/member-commands.ts +47 -7
  93. package/src/workspace/member-run.ts +10 -2
  94. package/src/workspace/reason-codes.ts +3 -1
  95. package/src/workspace/records-amend.schema.json +1 -0
  96. package/src/workspace/records-cli.ts +20 -12
  97. package/src/workspace/records-contract.test.ts +2 -1
  98. package/src/workspace/records-new.schema.json +1 -0
  99. package/src/workspace/records-quorum.test.ts +10 -3
  100. package/src/workspace/records-sessions-write.test.ts +2 -1
  101. package/src/workspace/records-write.test.ts +101 -1
  102. package/src/workspace/records-write.ts +50 -6
  103. package/src/workspace/records.ts +22 -5
  104. package/src/workspace/status-contract.test.ts +31 -1
  105. package/src/workspace/status-stewards.ts +39 -6
  106. package/src/workspace/status.schema.json +49 -4
  107. package/src/workspace/status.ts +22 -0
  108. package/src/workspace/trust/record-seal.test.ts +26 -2
  109. package/src/workspace/work-evidence.schema.json +1 -0
  110. package/src/workspace/workspace-kinds.schema.json +26 -0
@@ -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.warnings;
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 warnings = await validateWrite(o, before, path, text);
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 (sealable && recordTextDigest(text, digestFields(kind), kind.format) !== recordTextDigest(current, digestFields(kind), kind.format)) {
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
- const warnings = changed.length === 0 ? target.warnings : await validateWrite(o, before, target.path, text);
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,
@@ -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
- return kind.reviews ? [kind.reviews.field, RECORD_SEAL_FIELD] : null;
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: { holder: string; acquiredAt: string; expiresAt: string; live: boolean } | null;
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
- async function readStewardLease(name: string, memberDir: string, now: string): Promise<StatusSteward["lease"]> {
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(stewardLeaseName(name), prefix), { cwd: memberDir });
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 readStewardLease(declaration.name, memberDir, now),
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" }]
@@ -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: "ratified" });
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
  });
@@ -181,6 +181,7 @@
181
181
  "record-closed",
182
182
  "amend-supersede-instead",
183
183
  "record-sign-failed",
184
+ "ratify-quorum-not-met",
184
185
  "record-unparseable",
185
186
  "record-schema-invalid",
186
187
  "record-id-duplicate",
@@ -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
  }