@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
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
* kinds without a lifecycle, a supersedes field of bare ids, schema files a
|
|
4
4
|
* schema `$ref`s, and content-addressed ids.
|
|
5
5
|
*
|
|
6
|
-
* The fixtures are shaped like
|
|
7
|
-
* evidence named by the hash of its
|
|
8
|
-
* Markdown contracts, with draft-07
|
|
9
|
-
* `defs.schema.json`), trimmed to the fields
|
|
10
|
-
* through a kind file alone: no reader code.
|
|
6
|
+
* The fixtures are shaped like a development-model plugin's records (ported
|
|
7
|
+
* from chud, jhgaylor/chud#78: units, evidence named by the hash of its
|
|
8
|
+
* bytes, sessions, driver closures and Markdown contracts, with draft-07
|
|
9
|
+
* schemas that `$ref` a shared `defs.schema.json`), trimmed to the fields
|
|
10
|
+
* that matter here. Each one reads through a kind file alone: no reader code.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
@@ -29,7 +29,7 @@ const validateOutput = new Ajv2020({ strict: true, allErrors: true }).compile(sc
|
|
|
29
29
|
let root: string;
|
|
30
30
|
beforeEach(() => {
|
|
31
31
|
root = realpathSync(mkdtempSync(join(tmpdir(), "chant-record-formats-")));
|
|
32
|
-
|
|
32
|
+
writeAcmeModel();
|
|
33
33
|
});
|
|
34
34
|
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
35
35
|
|
|
@@ -72,9 +72,9 @@ async function failure(kind: string): Promise<{ code: string; message: string }>
|
|
|
72
72
|
const codes = (r: { reasons: Array<{ code: string }> }) => r.reasons.map((x) => x.code);
|
|
73
73
|
const warned = (r: { warnings: Array<{ code: string }> }) => r.warnings.map((x) => x.code);
|
|
74
74
|
|
|
75
|
-
// ──
|
|
75
|
+
// ── acme's model, as kind files ──────────────────────────────────────────────
|
|
76
76
|
|
|
77
|
-
const BASE = "https://schemas.example.test/
|
|
77
|
+
const BASE = "https://schemas.example.test/acme-runtime/v0";
|
|
78
78
|
const ref = (name: string) => ({ $ref: `defs.schema.json#/definitions/${name}` });
|
|
79
79
|
|
|
80
80
|
const DEFS = {
|
|
@@ -106,7 +106,7 @@ const UNIT_SCHEMA = {
|
|
|
106
106
|
scope: { type: "array", minItems: 1, items: ref("text") },
|
|
107
107
|
base_commit: ref("gitId"),
|
|
108
108
|
result: { type: "object", required: ["commit", "ref"], properties: { commit: { type: ["string", "null"] }, ref: { type: ["string", "null"] } } },
|
|
109
|
-
// A bare hash, as
|
|
109
|
+
// A bare hash, as acme writes today, or a pin chant checks.
|
|
110
110
|
evidence: {
|
|
111
111
|
type: "array",
|
|
112
112
|
items: { anyOf: [ref("sha256"), { type: "object", required: ["path", "sha256"], properties: { path: ref("text"), sha256: ref("sha256") } }] },
|
|
@@ -178,7 +178,7 @@ const CONTRACT_SCHEMA = {
|
|
|
178
178
|
},
|
|
179
179
|
};
|
|
180
180
|
|
|
181
|
-
/** A kind file: data only, the way
|
|
181
|
+
/** A kind file: data only, the way acme's model package would ship it. */
|
|
182
182
|
function kindFile(kind: Record<string, unknown>): string {
|
|
183
183
|
return `export const recordKind = ${JSON.stringify(kind, null, 2)};\n`;
|
|
184
184
|
}
|
|
@@ -235,7 +235,7 @@ const KINDS: Record<string, Record<string, unknown>> = {
|
|
|
235
235
|
},
|
|
236
236
|
};
|
|
237
237
|
|
|
238
|
-
function
|
|
238
|
+
function writeAcmeModel(): void {
|
|
239
239
|
write("design/schemas/defs.schema.json", json(DEFS));
|
|
240
240
|
write("design/schemas/unit.schema.json", json(UNIT_SCHEMA));
|
|
241
241
|
write("design/schemas/evidence.schema.json", json(EVIDENCE_SCHEMA));
|
|
@@ -304,7 +304,7 @@ test("the output schema lists exactly the formats a kind file may name", () => {
|
|
|
304
304
|
});
|
|
305
305
|
|
|
306
306
|
describe("format json", () => {
|
|
307
|
-
test("
|
|
307
|
+
test("acme's units, sessions and driver closures read through kind files alone", async () => {
|
|
308
308
|
write("design/units/U-0001.json", json(unit("U-0001", { outcome: "done", closed_at: "2026-09-24T11:00:00.000Z" })));
|
|
309
309
|
write("design/units/U-0002.json", json(unit("U-0002")));
|
|
310
310
|
write("design/sessions/S-0001.json", json(SESSION));
|
|
@@ -407,7 +407,7 @@ describe("the write commands through a declared kind (#2680)", () => {
|
|
|
407
407
|
test.each(["unit", "evidence"])("refuse the declared %s kind with write-usage-invalid", async (kind) => {
|
|
408
408
|
write(
|
|
409
409
|
"chant.workspace.json",
|
|
410
|
-
json({ name: "
|
|
410
|
+
json({ name: "acme", schema: 1, members: [{ name: "design", dir: "design", kind: "other", because: "acme's records" }], records: [{ kind: `design/${kind}.kind.mjs` }] }),
|
|
411
411
|
);
|
|
412
412
|
write("design/units/U-0001.json", json(unit("U-0001")));
|
|
413
413
|
write("fields.json", "{}\n");
|
|
@@ -510,7 +510,7 @@ describe("kinds without a lifecycle", () => {
|
|
|
510
510
|
const contract = (id: string, status: string, supersedes?: string | string[]) =>
|
|
511
511
|
`---\nid: "${id}"\nstatus: "${status}"\n${supersedes === undefined ? "" : `supersedes: ${JSON.stringify(supersedes)}\n`}---\n\n# ${id}\n`;
|
|
512
512
|
|
|
513
|
-
test("supersedes as one id, as
|
|
513
|
+
test("supersedes as one id, as acme's contracts write it, derives supersededBy under approval ranks", async () => {
|
|
514
514
|
write("design/contracts/C-001-first.md", contract("C-001", "approved"));
|
|
515
515
|
write("design/contracts/C-002-second.md", contract("C-002", "approved", "C-001"));
|
|
516
516
|
write("design/contracts/C-003-draft.md", contract("C-003", "draft", "C-002"));
|
|
@@ -588,7 +588,7 @@ describe("schema.refs", () => {
|
|
|
588
588
|
// ── 4. Content-addressed ids ─────────────────────────────────────────────────
|
|
589
589
|
|
|
590
590
|
describe("idFrom sha256", () => {
|
|
591
|
-
test("
|
|
591
|
+
test("acme's evidence reads with the hash of its bytes as its id, in the tree and under --at", async () => {
|
|
592
592
|
const h = writeEvidence();
|
|
593
593
|
const bytes = readFileSync(join(root, "design", "evidence", `${h}.json`));
|
|
594
594
|
expect(createHash("sha256").update(bytes).digest("hex")).toBe(h);
|
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
16
16
|
import { workingTreeSource } from "./record-source";
|
|
17
|
-
import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest, type QuorumOptions, type RecordEntry } from "./records";
|
|
17
|
+
import { computeQuorum, DEFAULT_QUORUM, loadRecordKind, normalisePrincipal, readRecords, recordTextDigest as rawDigest, type QuorumOptions, type RecordEntry } from "./records";
|
|
18
18
|
|
|
19
19
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
20
20
|
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
@@ -34,6 +34,13 @@ function review(reviewer: string, verdict: string, extra: Record<string, string>
|
|
|
34
34
|
.join("\n");
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A decision's digest: the decision kind names a ratified state, so its
|
|
39
|
+
* state line leaves the digest with the reviews and seal blocks (#2873).
|
|
40
|
+
* The rule tests below hold for that list as for the reviews block alone.
|
|
41
|
+
*/
|
|
42
|
+
const recordTextDigest = (text: string, fields: string | readonly string[] | null = ["reviews", "seal", "state"]) => rawDigest(text, fields);
|
|
43
|
+
|
|
37
44
|
const BARE = recordTextDigest(SAMPLE);
|
|
38
45
|
|
|
39
46
|
describe("recordTextDigest", () => {
|
|
@@ -41,7 +48,7 @@ describe("recordTextDigest", () => {
|
|
|
41
48
|
expect(BARE).toMatch(/^[0-9a-f]{64}$/);
|
|
42
49
|
const lines = SAMPLE.split("\n");
|
|
43
50
|
const without = lines.filter((l) => l !== "reviews: []").join("\n");
|
|
44
|
-
expect(
|
|
51
|
+
expect(rawDigest(SAMPLE)).toBe(rawDigest(without, null));
|
|
45
52
|
});
|
|
46
53
|
|
|
47
54
|
test("does not move when a verdict is added, changed or removed", () => {
|
|
@@ -78,7 +85,7 @@ describe("recordTextDigest", () => {
|
|
|
78
85
|
const file = join(dir, "ws-003.md");
|
|
79
86
|
writeFileSync(file, withReviews(`${review("alice", "agree")}\n${review("bob", "dissent")}`));
|
|
80
87
|
// The recipe in docs/src/content/docs/cli/workspace-records.mdx.
|
|
81
|
-
const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^reviews[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
|
|
88
|
+
const awk = `awk 'NR==1&&$0=="---"{fm=1;print;next} fm&&$0=="---"{fm=0;skip=0;print;next} fm&&/^(reviews|seal|state)[ \\t]*:/{skip=1;next} fm&&skip&&/^([ \\t#-]|$)/{next} {skip=0;print}' "${file}" | shasum -a 256`;
|
|
82
89
|
const out = execFileSync("sh", ["-c", awk], { encoding: "utf-8" });
|
|
83
90
|
expect(out.split(" ")[0]).toBe(BARE);
|
|
84
91
|
} finally {
|
|
@@ -164,7 +164,8 @@ describe("records review --session (#2693)", () => {
|
|
|
164
164
|
const root = declared();
|
|
165
165
|
const { path } = await opened(root);
|
|
166
166
|
const decision = join(root, "decisions", "ref-001-how-the-app-is-deployed.md");
|
|
167
|
-
|
|
167
|
+
// The decision kind names a ratified state, so the state leaves the digest too (#2873).
|
|
168
|
+
const digest = recordTextDigest(readFileSync(decision, "utf-8"), ["reviews", "seal", "state"]);
|
|
168
169
|
|
|
169
170
|
const dry = await reviewRecord({ kind: DECISIONS_KIND, id: "ref-001", verdict: "agree", by: "alice", session: "S-0002", dryRun: true, cwd: root });
|
|
170
171
|
review.expectValid(dry);
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* and the comparison. The fixture is a repository with commits for a review
|
|
4
4
|
* session's open and close: between them the session closes with two
|
|
5
5
|
* verdicts, ref-001 gains the two reviews that name it and is ratified,
|
|
6
|
-
* a new decision
|
|
6
|
+
* a new decision (ref-004, after the reference workspace's own ref-003)
|
|
7
|
+
* supersedes ref-002, and ref-002's pin moves. Every document
|
|
7
8
|
* validates against records-since.schema.json.
|
|
8
9
|
*/
|
|
9
10
|
|
|
@@ -56,8 +57,8 @@ function sessionHistory(): { root: string; before: string; open: string; close:
|
|
|
56
57
|
const text = readFileSync(ref002, "utf-8");
|
|
57
58
|
writeFileSync(ref002, text.replace(/sha256: "[0-9a-f]{64}"/, `sha256: "${"a".repeat(64)}"`));
|
|
58
59
|
writeFileSync(
|
|
59
|
-
join(root, "decisions", "ref-
|
|
60
|
-
text.replace(/^id: .*$/m, 'id: "ref-
|
|
60
|
+
join(root, "decisions", "ref-004-a-successor.md"),
|
|
61
|
+
text.replace(/^id: .*$/m, 'id: "ref-004"').replace(/^supersedes: \[\]$/m, 'supersedes:\n - decision: "ref-002"'),
|
|
61
62
|
);
|
|
62
63
|
const close = commitAll(root, "close S-0002");
|
|
63
64
|
return { root, before, open, close };
|
|
@@ -85,11 +86,11 @@ describe("records --since (#2673)", () => {
|
|
|
85
86
|
expect(doc.at).toBe(h.close);
|
|
86
87
|
expect(doc.kind).toEqual({ name: "decision", schema: "urn:intentius:chant:decision:1", file: "decisions/decision.kind.mjs" });
|
|
87
88
|
expect(doc.changes).toEqual([
|
|
88
|
-
{ change: "new", id: "ref-
|
|
89
|
+
{ change: "new", id: "ref-004", path: "decisions/ref-004-a-successor.md", state: "decided" },
|
|
89
90
|
{ change: "state", id: "ref-001", from: "decided", to: "ratified" },
|
|
90
91
|
{ change: "verdict", id: "ref-001", principal: "alice", verdict: "agree", index: 0, session: "S-0002" },
|
|
91
92
|
{ change: "verdict", id: "ref-001", principal: "bob", verdict: "agree", index: 1, session: "S-0002" },
|
|
92
|
-
{ change: "supersession", id: "ref-
|
|
93
|
+
{ change: "supersession", id: "ref-004", supersedes: "ref-002" },
|
|
93
94
|
{ change: "pin", id: "ref-002", path: "design/screens/home.json", from: expect.stringMatching(/^[0-9a-f]{64}$/), to: "a".repeat(64) },
|
|
94
95
|
]);
|
|
95
96
|
expect(doc.summary).toEqual({ new: 1, removed: 0, state: 1, verdict: 2, supersession: 1, pin: 1 });
|
|
@@ -105,11 +106,11 @@ describe("records --since (#2673)", () => {
|
|
|
105
106
|
});
|
|
106
107
|
|
|
107
108
|
test("without --at it compares with the working tree, and a record deleted there is removed", async () => {
|
|
108
|
-
rmSync(join(h.root, "decisions", "ref-
|
|
109
|
+
rmSync(join(h.root, "decisions", "ref-004-a-successor.md"));
|
|
109
110
|
try {
|
|
110
111
|
const doc = ok(await since({ kind: DECISIONS_KIND, since: h.close, cwd: h.root }));
|
|
111
112
|
expect(doc.at).toBeNull();
|
|
112
|
-
expect(doc.changes).toEqual([{ change: "removed", id: "ref-
|
|
113
|
+
expect(doc.changes).toEqual([{ change: "removed", id: "ref-004", path: "decisions/ref-004-a-successor.md", state: "decided" }]);
|
|
113
114
|
} finally {
|
|
114
115
|
git(h.root, "checkout", "--", "decisions");
|
|
115
116
|
}
|
|
@@ -10,7 +10,8 @@ import { cpSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync
|
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
-
import { parseFrontMatter, recordTextDigest } from "./records";
|
|
13
|
+
import { parseFrontMatter, recordTextDigest as textDigest } from "./records";
|
|
14
|
+
import { queryRecords } from "./records-cli";
|
|
14
15
|
import { allocateId, amendRecord, newRecord, renderRecord, reviewRecord, toYaml } from "./records-write";
|
|
15
16
|
|
|
16
17
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
@@ -19,6 +20,9 @@ const KIND = "decisions/decision.kind.mjs";
|
|
|
19
20
|
|
|
20
21
|
type Data = Record<string, unknown>;
|
|
21
22
|
|
|
23
|
+
/** The digest of a decision: the kind names a ratified state, so the state leaves the digest with the reviews and seal (#2873). */
|
|
24
|
+
const recordTextDigest = (text: string) => textDigest(text, ["reviews", "seal", "state"]);
|
|
25
|
+
|
|
22
26
|
const SAMPLE = (() => {
|
|
23
27
|
const fm = parseFrontMatter(readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8"));
|
|
24
28
|
if (!fm.ok) throw new Error(fm.message);
|
|
@@ -282,6 +286,7 @@ describe("records amend", () => {
|
|
|
282
286
|
test("a decided record may be ratified, re-pin its evidence and take reviews, but not move down", async () => {
|
|
283
287
|
const evidence = [{ title: "A spec", url: "https://example.com/spec" }];
|
|
284
288
|
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence }), cwd: dir })).toMatchObject({ changed: ["evidence"] });
|
|
289
|
+
for (const by of ["alice", "bob"]) await reviewRecord({ kind: KIND, id: "ws-001", verdict: "agree", by, cwd: dir });
|
|
285
290
|
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified" }), cwd: dir })).toMatchObject({ changed: ["state"] });
|
|
286
291
|
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
287
292
|
expect(code(await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "withdrawn" }), cwd: dir }))).toBe("amend-supersede-instead");
|
|
@@ -319,6 +324,101 @@ describe("records amend", () => {
|
|
|
319
324
|
});
|
|
320
325
|
});
|
|
321
326
|
|
|
327
|
+
describe("ratifying against the quorum (#2873)", () => {
|
|
328
|
+
const ratify = (id = "ws-001") => amendRecord({ kind: KIND, id, fields: fields({ state: "ratified" }), cwd: dir });
|
|
329
|
+
const agree = (by: string, id = "ws-001") => reviewRecord({ kind: KIND, id, verdict: "agree", by, cwd: dir });
|
|
330
|
+
async function quorumOf(id: string) {
|
|
331
|
+
const doc = await queryRecords({ kind: KIND, cwd: dir });
|
|
332
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
333
|
+
return doc.records.find((r) => r.id === id)!.quorum!;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
test("below the quorum, ratified is refused with the count and what did not count, and nothing is written", async () => {
|
|
337
|
+
expect(SAMPLE.decided_by).toBe("lex00");
|
|
338
|
+
let before = snapshot();
|
|
339
|
+
let doc = await ratify();
|
|
340
|
+
expect(code(doc)).toBe("ratify-quorum-not-met");
|
|
341
|
+
expect("error" in doc && doc.error.message).toMatch(/^ws-001 can't be ratified yet: its quorum needs 2 agreeing verdicts that count, and it has 0\. Add verdicts with chant workspace records review ws-001/);
|
|
342
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
343
|
+
// One agreement and the decider's own: one counts.
|
|
344
|
+
await agree("alice");
|
|
345
|
+
await agree("lex00");
|
|
346
|
+
before = snapshot();
|
|
347
|
+
doc = await ratify();
|
|
348
|
+
expect("error" in doc && doc.error.message).toMatch(/it has 1 \(not counted: lex00, review-decider\)/);
|
|
349
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("at the quorum, ratified is taken, and the verdicts still count on the ratified record", async () => {
|
|
353
|
+
await agree("alice");
|
|
354
|
+
await agree("bob");
|
|
355
|
+
const judged = recordTextDigest(text("ws-001-one.md"));
|
|
356
|
+
expect(await ratify()).toMatchObject({ changed: ["state"] });
|
|
357
|
+
expect(data("ws-001-one.md").state).toBe("ratified");
|
|
358
|
+
expect(recordTextDigest(text("ws-001-one.md"))).toBe(judged);
|
|
359
|
+
expect(await quorumOf("ws-001")).toMatchObject({ need: 2, agreed: 2, met: true, notCounted: [] });
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("a met quorum with an open concern ratifies, and the concern stays listed", async () => {
|
|
363
|
+
await agree("alice");
|
|
364
|
+
await agree("bob");
|
|
365
|
+
await reviewRecord({ kind: KIND, id: "ws-001", verdict: "dissent", by: "carol", note: "It leaves out X.", cwd: dir });
|
|
366
|
+
expect(code(await ratify())).toBe("ok");
|
|
367
|
+
expect(await quorumOf("ws-001")).toMatchObject({ met: true, metWithObjections: true, openConcerns: [{ reviewer: "carol" }] });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
test("verdicts on text an amendment changed do not count toward ratifying", async () => {
|
|
371
|
+
await agree("alice");
|
|
372
|
+
await agree("bob");
|
|
373
|
+
await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ evidence: [] }), cwd: dir });
|
|
374
|
+
const doc = await ratify();
|
|
375
|
+
expect("error" in doc && doc.error.message).toMatch(/it has 0 \(not counted: alice, review-older-digest; bob, review-older-digest\)/);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("the amendment that ratifies is checked as written, with its own changes to the reviews and pins", async () => {
|
|
379
|
+
await agree("alice");
|
|
380
|
+
const digest = recordTextDigest(text("ws-001-one.md"));
|
|
381
|
+
const reviews = [...(data("ws-001-one.md").reviews as Data[]), { reviewer: "bob", verdict: "agree", on: "2026-09-26", digest }];
|
|
382
|
+
expect(await amendRecord({ kind: KIND, id: "ws-001", fields: fields({ state: "ratified", reviews }), cwd: dir })).toMatchObject({ changed: ["state", "reviews"] });
|
|
383
|
+
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
384
|
+
await agree("alice", "ws-006");
|
|
385
|
+
await agree("bob", "ws-006");
|
|
386
|
+
// Re-pinning in the same write moves the digest, so the verdicts no longer match what would be ratified.
|
|
387
|
+
const doc = await amendRecord({ kind: KIND, id: "ws-006", fields: fields({ state: "ratified", evidence: [] }), cwd: dir });
|
|
388
|
+
expect(code(doc)).toBe("ratify-quorum-not-met");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("records new refuses a record that opens ratified below its quorum", async () => {
|
|
392
|
+
const fresh = decision({ title: "Straight in", state: "ratified" });
|
|
393
|
+
delete fresh.id;
|
|
394
|
+
const before = snapshot();
|
|
395
|
+
expect(code(await newRecord({ kind: KIND, fields: fields(fresh), cwd: dir }))).toBe("ratify-quorum-not-met");
|
|
396
|
+
expect(touched(before, snapshot())).toEqual([]);
|
|
397
|
+
expect(code(await newRecord({ kind: KIND, fields: fields({ ...fresh, state: "decided" }), cwd: dir }))).toBe("ok");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("a kind without reviews.ratified, or without reviews, takes ratified with no quorum, and its digest keeps the state", async () => {
|
|
401
|
+
const kindFile = join(dir, "decisions", "decision.kind.mjs");
|
|
402
|
+
const withRatified = readFileSync(kindFile, "utf-8");
|
|
403
|
+
expect(withRatified).toContain('reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },');
|
|
404
|
+
writeFileSync(kindFile, withRatified.replace(', ratified: "ratified" }', " }"));
|
|
405
|
+
const judged = textDigest(text("ws-001-one.md"), ["reviews", "seal"]);
|
|
406
|
+
expect(await ratify()).toMatchObject({ changed: ["state"] });
|
|
407
|
+
expect(textDigest(text("ws-001-one.md"), ["reviews", "seal"])).not.toBe(judged);
|
|
408
|
+
writeFileSync(kindFile, withRatified.replace(' reviews: { field: "reviews", decider: "decided_by", ratified: "ratified" },\n', ""));
|
|
409
|
+
put("ws-006-six.md", decision({ id: "ws-006", title: "Six" }));
|
|
410
|
+
expect(await ratify("ws-006")).toMatchObject({ changed: ["state"] });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test("a kind whose reviews.ratified is not one of its states is refused", async () => {
|
|
414
|
+
const kindFile = join(dir, "decisions", "decision.kind.mjs");
|
|
415
|
+
writeFileSync(kindFile, readFileSync(kindFile, "utf-8").replace('ratified: "ratified" }', 'ratified: "approved" }'));
|
|
416
|
+
const doc = await ratify();
|
|
417
|
+
expect(code(doc)).toBe("kind-invalid");
|
|
418
|
+
expect("error" in doc && doc.error.message).toMatch(/reviews\.ratified must be one of states/);
|
|
419
|
+
});
|
|
420
|
+
});
|
|
421
|
+
|
|
322
422
|
describe("records review", () => {
|
|
323
423
|
test("appends a dated verdict bound to the digest of the text it judged", async () => {
|
|
324
424
|
const judged = recordTextDigest(text("ws-001-one.md"));
|
|
@@ -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,
|
|
@@ -311,7 +311,7 @@ describe("a decision that originates in the workspace (#2654)", () => {
|
|
|
311
311
|
|
|
312
312
|
test("takes a session and an issue, and nothing else", async () => {
|
|
313
313
|
const base = workspaceDecision("ws-001");
|
|
314
|
-
write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "
|
|
314
|
+
write("ws-001-a.md", base.replace(' member: "app"\n', ' member: "app"\n session: "S-0001"\n issue: "acme/example#77"\n'));
|
|
315
315
|
write("ws-002-b.md", base.replace('id: "ws-001"', 'id: "ws-002"').replace(' member: "app"\n', ' member: "app"\n session: null\n'));
|
|
316
316
|
write("ws-003-c.md", base.replace('id: "ws-001"', 'id: "ws-003"').replace(' member: "app"\n', ' member: "app"\n row: "Sort order"\n'));
|
|
317
317
|
write("ws-004-d.md", base.replace('id: "ws-001"', 'id: "ws-004"').replace(' member: "app"\n', ""));
|
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`:
|
|
@@ -311,10 +311,39 @@ describe("chant workspace status on built workspaces", () => {
|
|
|
311
311
|
{ name: "inference", broker: null, scope: [] },
|
|
312
312
|
],
|
|
313
313
|
isolation: null,
|
|
314
|
+
intent: null,
|
|
315
|
+
services: [],
|
|
314
316
|
},
|
|
315
317
|
]);
|
|
316
318
|
});
|
|
317
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
|
+
|
|
318
347
|
test("every failure validates with its code", async () => {
|
|
319
348
|
const empty = repo({});
|
|
320
349
|
const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-status-nogit-")));
|
|
@@ -599,8 +628,9 @@ describe("stewards in chant workspace status --json (#2731)", () => {
|
|
|
599
628
|
lastTick: null,
|
|
600
629
|
changesCheckout: false,
|
|
601
630
|
workLease: null,
|
|
631
|
+
beside: null,
|
|
602
632
|
},
|
|
603
|
-
{ 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 },
|
|
604
634
|
{
|
|
605
635
|
name: "box-dispatch",
|
|
606
636
|
schedule: { cron: "*/5 * * * *", overlap: "skip" },
|
|
@@ -621,6 +651,7 @@ describe("stewards in chant workspace status --json (#2731)", () => {
|
|
|
621
651
|
},
|
|
622
652
|
],
|
|
623
653
|
},
|
|
654
|
+
beside: null,
|
|
624
655
|
},
|
|
625
656
|
]);
|
|
626
657
|
// #2749: nothing the steward runs waits on a decision point.
|