@intentius/chant 0.90.0 → 0.91.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/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +14 -0
- 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-tools.d.ts +6 -4
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +2 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/index.d.ts +9 -3
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +19 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +10 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/operator.d.ts +29 -0
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +9 -0
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward.d.ts +140 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +51 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +71 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +1 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +10 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +184 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +51 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +172 -0
- package/dist/workspace/declared-kinds.d.ts +12 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +26 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -0
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +8 -2
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +121 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +52 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +78 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/operator.ts +107 -2
- package/src/cli/handlers/run.test.ts +19 -0
- package/src/cli/handlers/run.ts +53 -1
- package/src/cli/main.test.ts +40 -0
- package/src/cli/main.ts +78 -2
- package/src/cli/mcp/workspace-tools.test.ts +14 -1
- package/src/cli/mcp/workspace-tools.ts +49 -5
- package/src/cli/registry.ts +31 -0
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/op/builders.ts +5 -0
- package/src/op/discover.ts +71 -0
- package/src/op/index.ts +16 -3
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +216 -18
- package/src/op/local-output.ts +13 -0
- package/src/op/op-ir.ts +14 -0
- package/src/op/operator.ts +75 -4
- package/src/op/runtime.ts +6 -0
- package/src/op/runtimes/local.ts +3 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward.test.ts +212 -0
- package/src/op/steward.ts +253 -0
- package/src/op/types.ts +53 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +326 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/check-contract.test.ts +3 -1
- package/src/workspace/check.schema.json +15 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +197 -0
- package/src/workspace/checks/boxes.ts +307 -0
- package/src/workspace/checks/records.ts +25 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +19 -2
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +576 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +172 -0
- package/src/workspace/declaration.ts +137 -0
- package/src/workspace/declared-kinds.ts +25 -2
- package/src/workspace/intent.schema.json +4 -1
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +273 -0
- package/src/workspace/points-write.schema.json +489 -0
- package/src/workspace/points.schema.json +710 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +14 -1
- package/src/workspace/reason-codes.ts +33 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-cli.ts +15 -2
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records.schema.json +17 -0
- package/src/workspace/records.ts +33 -3
- package/src/workspace/status-contract.test.ts +178 -0
- package/src/workspace/status-stewards.ts +225 -0
- package/src/workspace/status.schema.json +230 -4
- package/src/workspace/status.ts +106 -5
- package/src/workspace/work-cli.test.ts +180 -0
- package/src/workspace/work-cli.ts +246 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
|
@@ -57,6 +57,10 @@ export const REASONS = {
|
|
|
57
57
|
"gates-no-ledger": "The checkout has no chant/lifecycle branch, so there is no gate ledger to read.",
|
|
58
58
|
"gates-no-gate-ledger": "The branch has no gate ledger for the member: no run of it has reached a gate.",
|
|
59
59
|
"gates-ledger-unreadable": "Reading the member's gate ledger failed, so no gate is listed.",
|
|
60
|
+
// A member's stewards status can't fully list (status, #2731).
|
|
61
|
+
"stewards-unreadable": "An *.op.ts file could not be imported, so a steward it declares may be missing.",
|
|
62
|
+
"stewards-conflict": "A steward was dropped: its name, or an Op it lists, belongs to another steward.",
|
|
63
|
+
"steward-runs-unreadable": "Reading an Op's run ledger failed, so its last run is null.",
|
|
60
64
|
// A record that isn't valid (records).
|
|
61
65
|
"record-unparseable": "No front matter, a YAML error or a value outside the JSON subset of YAML, or for a JSON kind a file that is not one object or repeats a member name.",
|
|
62
66
|
"record-schema-invalid": "The record's front matter, or its JSON object, does not match the kind's schema.",
|
|
@@ -82,6 +86,10 @@ export const REASONS = {
|
|
|
82
86
|
"work-done-unpinned": "A work record is done and its evidence list is empty: nothing shows the work was done.",
|
|
83
87
|
"work-closed-without-date": "A work record is done or dropped and has no closing date.",
|
|
84
88
|
"work-done-gap-open": "A work record is done, and the finding it came from still fires on its region. graph --intent raises it, and records does by walking that region.",
|
|
89
|
+
// An answer to a decision point that is valid but warned about (records, points, ws-058, #2739).
|
|
90
|
+
"answer-points-unreadable": "The points file the answer kind names can't be read, or is not valid.",
|
|
91
|
+
"answer-point-unknown": "The answer's point is not declared in the points file the answer kind names.",
|
|
92
|
+
"answer-point-changed": "The point's declaration changed since the question was asked, so the answer is to an older version of the question.",
|
|
85
93
|
// A verdict the quorum does not count (records, #2671, #2672).
|
|
86
94
|
"review-decider": "The verdict is the decider's own, and the quorum counts verdicts besides the decider's.",
|
|
87
95
|
"review-agent": "The reviewer holds the agent role in the trust policy at base.",
|
|
@@ -121,6 +129,14 @@ export const REASONS = {
|
|
|
121
129
|
// A review given in a session (records review --session, #2693).
|
|
122
130
|
"session-unknown": "--session names no session of a session kind whose subjects are the record's kind.",
|
|
123
131
|
"session-not-open": "--session names a session in a closed state, which takes no more verdicts.",
|
|
132
|
+
// Decision points (points, points ask, points answer, ws-058, #2739).
|
|
133
|
+
"points-undeclared": "No record kind with an answers block is declared, or given with --kind, so there is no points file to ask.",
|
|
134
|
+
"points-invalid": "The points file an answer kind names can't be read, or does not match decision-points.schema.json and the rules checked in code.",
|
|
135
|
+
"point-unknown": "No points file declares the point asked, or the one an answer names.",
|
|
136
|
+
"point-inputs-invalid": "The inputs given to an ask are not a JSON object of the point's declared inputs.",
|
|
137
|
+
"point-decider-failed": "A model decider that fails closed (unreachable: fail) could not answer, so the ask wrote nothing.",
|
|
138
|
+
"answer-not-candidate": "The people's answer is not one of the question's candidates.",
|
|
139
|
+
"quorum-not-met": "Too few of the people who answered count toward the point's quorum: distinct, not holding the agent role, and holding one of its roles when it names any.",
|
|
124
140
|
// records --since that fails (#2673).
|
|
125
141
|
"since-rev-unknown": "--since names no commit, or a session with no opening revision and no commit that added it.",
|
|
126
142
|
"since-session-unknown": "--since has the shape of a session id and names no commit, and no session the kind or the declaration reads has that id.",
|
|
@@ -159,6 +175,23 @@ export const REASONS = {
|
|
|
159
175
|
"environments-none-declared": "The member's chant.config.ts declares no environments, so only local and the environments in its ledger are listed.",
|
|
160
176
|
"environments-ledger-undeclared": "The member's ledger has releases in an environment its config's environments don't cover, so chant run --env would refuse it and it is not listed.",
|
|
161
177
|
"environments-ledger-unreadable": "The chant/lifecycle branch exists and the member's ledger environments could not be listed.",
|
|
178
|
+
// A box that holds a credential (check, #2726). Each is also a WSP finding.
|
|
179
|
+
"box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",
|
|
180
|
+
"box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
|
|
181
|
+
// The work lease (chant workspace work claim|renew|release, #2732): why the command could not run.
|
|
182
|
+
"work-kind-missing": "No work kind to find the item in: --kind names a kind with no work block, or the declaration names no work kind.",
|
|
183
|
+
"work-kind-ambiguous": "More than one declared work kind has a record with the id, so --kind must name one.",
|
|
184
|
+
"work-item-unknown": "No work record has the id, so there is nothing to lease.",
|
|
185
|
+
"work-item-closed": "A claim on a work item in a closed state, such as done or dropped: there is no work left to claim.",
|
|
186
|
+
// The work lease: why a claim, renew or release was refused (exit 2).
|
|
187
|
+
"lease-held": "Someone holds a live lease on the work item: another worker, or, for a claim, the same one.",
|
|
188
|
+
"lease-not-held": "Nobody holds a live lease on the work item: it expired, was released or was never claimed.",
|
|
189
|
+
"lease-token-mismatch": "The live lease on the work item carries another fencing token than the one given.",
|
|
190
|
+
"lease-race": "Another writer changed the lease between this command's read and its write.",
|
|
191
|
+
"lease-push-rejected": "The remote refused the lease push: another clone claimed the item first, or the remote could not be reached.",
|
|
192
|
+
// A box whose isolation fails (check, #2727). Each is also a WSP finding.
|
|
193
|
+
"box-isolation-collision": "Two boxes on one host resolve to the same port, state path or cookie name, or two ports in one box share an offset.",
|
|
194
|
+
"box-isolation-literal": "A host's stateRoot or a box's state entry is a literal machine path instead of one derived from an environment reference and the box's name.",
|
|
162
195
|
// The lineage lock (check).
|
|
163
196
|
"lock-invalid": "The lineage lock can't be read.",
|
|
164
197
|
"manual-step-open": "A scope in the lineage lock has an open manual step.",
|
|
@@ -18,6 +18,7 @@ import { constraintCovers, isWorkspacePath, memberHolding, WORKSPACE_PATH_PATTER
|
|
|
18
18
|
import { pinFile, queryRecords, type RecordsDocument } from "./records-cli";
|
|
19
19
|
import { parseFrontMatter, RECORD_WARNING_CODES, SEAL_WARNING_CODES } from "./records";
|
|
20
20
|
import { WORK_WARNING_CODES } from "./work";
|
|
21
|
+
import { ANSWER_WARNING_CODES } from "./points";
|
|
21
22
|
import { workspaceGraph } from "./graph-cli";
|
|
22
23
|
import graphSchema from "./graph.schema.json";
|
|
23
24
|
import recordsSchema from "./records.schema.json";
|
|
@@ -170,8 +171,8 @@ describe("chant workspace records checks each pin", () => {
|
|
|
170
171
|
|
|
171
172
|
test("the schema lists exactly the warning codes", () => {
|
|
172
173
|
// A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686),
|
|
173
|
-
// and records adds record-unattested for an author seal under a signers file at base (#2688).
|
|
174
|
-
expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
|
|
174
|
+
// an answer kind's the answer warnings (ws-058), and records adds record-unattested for an author seal under a signers file at base (#2688).
|
|
175
|
+
expect(recordsSchema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...ANSWER_WARNING_CODES, ...SEAL_WARNING_CODES]);
|
|
175
176
|
});
|
|
176
177
|
|
|
177
178
|
test("records pin <path> prints the entry's path from the workspace root and the file's hash", () => {
|
|
@@ -81,7 +81,10 @@ export function checkPins(pins: { path: string; sha256: string }[], tree: Worksp
|
|
|
81
81
|
const state: PinState = actual === null ? "missing" : actual === pin.sha256 ? "pinned" : "drifted";
|
|
82
82
|
assets.push({ ...pin, actual, state });
|
|
83
83
|
if (state === "missing") {
|
|
84
|
-
|
|
84
|
+
// Worded the same for the working tree and a revision (#2745): a missing
|
|
85
|
+
// file is missing either way, and `tree.label` would otherwise make the
|
|
86
|
+
// same finding read differently depending on which one was read.
|
|
87
|
+
warnings.push({ code: "asset-missing", message: `evidence pins ${pin.path}, which does not exist` });
|
|
85
88
|
} else if (state === "drifted") {
|
|
86
89
|
warnings.push({
|
|
87
90
|
code: "asset-drift",
|
|
@@ -92,6 +92,13 @@ export type RecordView = RecordEntry & {
|
|
|
92
92
|
/** true when the record's seal verifies for its author against the signers at base; false when it fails, or is missing under an active policy; null when nothing here can say (#2688). */
|
|
93
93
|
attested?: boolean | null;
|
|
94
94
|
attestation?: VerdictAttestation;
|
|
95
|
+
/**
|
|
96
|
+
* For a work kind read in the working tree (#2732): the item's active work
|
|
97
|
+
* lease, or null when nobody holds one. Read from the local lease refs of
|
|
98
|
+
* the ledger owning the kind, without fetching. Absent under `--at`, since a
|
|
99
|
+
* lease is live state and not part of a revision.
|
|
100
|
+
*/
|
|
101
|
+
lease?: { holder: string; token: string; acquiredAt: string; expiresAt: string } | null;
|
|
95
102
|
};
|
|
96
103
|
|
|
97
104
|
/** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
|
|
@@ -304,6 +311,11 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
304
311
|
...(seals ? authorSeal(loaded.kind, r, policy, seals.checkRecordSeal) : {}),
|
|
305
312
|
}));
|
|
306
313
|
if (loaded.kind.work && query.workGaps !== false && top) await raiseWorkGaps(loaded, records, { root, workspaceRoot, at: query.at });
|
|
314
|
+
if (loaded.kind.work && at === null && top) {
|
|
315
|
+
const { activeWorkLeases } = await import("../lifecycle/work-lease");
|
|
316
|
+
const leases = await activeWorkLeases(loaded.file);
|
|
317
|
+
for (const r of records) if (r.id !== null) r.lease = leases.get(r.id) ?? null;
|
|
318
|
+
}
|
|
307
319
|
return {
|
|
308
320
|
$schema: RECORDS_OUTPUT_SCHEMA_ID,
|
|
309
321
|
contract: RECORDS_CONTRACT_VERSION,
|
|
@@ -596,7 +608,8 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
|
|
|
596
608
|
if (r.ready !== undefined) {
|
|
597
609
|
const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
|
|
598
610
|
const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
|
|
599
|
-
const
|
|
611
|
+
const held = r.lease ? `held by ${r.lease.holder} until ${r.lease.expiresAt}` : "";
|
|
612
|
+
const status = [r.ready ? "ready" : blocked ? `blocked by ${blocked}` : "", implemented ? `implements ${implemented}` : "", held].filter(Boolean).join("; ");
|
|
600
613
|
if (status) lines.push(`${" ".repeat(idWidth + 2)}${status}`);
|
|
601
614
|
}
|
|
602
615
|
for (const reason of r.reasons) lines.push(`${" ".repeat(idWidth + 2)}${reason.code}: ${reason.message} (${r.path})`);
|
|
@@ -620,7 +633,7 @@ export async function runWorkspaceUnknown(ctx: CommandContext): Promise<number>
|
|
|
620
633
|
console.error(
|
|
621
634
|
formatError({
|
|
622
635
|
message: sub ? `Unknown workspace subcommand: ${sub}` : "chant workspace needs a subcommand",
|
|
623
|
-
hint: `Workspace subcommands: audit, build, check, graph, init, lineage, lint, ls, records, status, upgrade, verify. Run "chant --help" for their options.`,
|
|
636
|
+
hint: `Workspace subcommands: audit, build, check, graph, init, lineage, lint, ls, records, status, upgrade, verify, work. Run "chant --help" for their options.`,
|
|
624
637
|
}),
|
|
625
638
|
);
|
|
626
639
|
return 1;
|
|
@@ -13,6 +13,7 @@ import { afterAll, describe, expect, test } from "vitest";
|
|
|
13
13
|
import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
|
|
14
14
|
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES, SEAL_WARNING_CODES } from "./records";
|
|
15
15
|
import { WORK_WARNING_CODES } from "./work";
|
|
16
|
+
import { ANSWER_WARNING_CODES } from "./points";
|
|
16
17
|
import schema from "./records.schema.json";
|
|
17
18
|
import { PROVENANCE_LEVELS } from "./trust/attestor";
|
|
18
19
|
|
|
@@ -149,8 +150,8 @@ describe("records output schema", () => {
|
|
|
149
150
|
|
|
150
151
|
test("lists exactly the warning codes the code can return", () => {
|
|
151
152
|
// A work kind's records carry the work warnings too (#2683), work-done-gap-open included since records walks a done item's region (#2686),
|
|
152
|
-
// and records adds record-unattested for an author seal under a signers file at base (#2688).
|
|
153
|
-
expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...SEAL_WARNING_CODES]);
|
|
153
|
+
// an answer kind's the answer warnings (ws-058), and records adds record-unattested for an author seal under a signers file at base (#2688).
|
|
154
|
+
expect(schema.$defs.warning.properties.code.enum).toEqual([...RECORD_WARNING_CODES, ...WORK_WARNING_CODES, ...ANSWER_WARNING_CODES, ...SEAL_WARNING_CODES]);
|
|
154
155
|
});
|
|
155
156
|
|
|
156
157
|
test("every failure validates with its code", async () => {
|
|
@@ -177,6 +177,10 @@
|
|
|
177
177
|
"description": "Added in contract 1 by #2683, on every parsed record of a work kind. Each decision the record implements, with its state now; null for an id no decision has.",
|
|
178
178
|
"type": "array",
|
|
179
179
|
"items": { "$ref": "#/$defs/workLink" }
|
|
180
|
+
},
|
|
181
|
+
"lease": {
|
|
182
|
+
"description": "Added in contract 1 by #2732, on every record of a work kind with an id, read in the working tree (absent under --at). The item's active work lease (chant workspace work claim), or null when nobody holds one live. Read from the local lease refs, refs/chant/lease/[_members/<member>/]work/<id> and the remote's as last fetched, of the ledger owning the work kind file; records never fetches.",
|
|
183
|
+
"oneOf": [{ "type": "null" }, { "$ref": "#/$defs/workLease" }]
|
|
180
184
|
}
|
|
181
185
|
},
|
|
182
186
|
"allOf": [
|
|
@@ -187,6 +191,16 @@
|
|
|
187
191
|
}
|
|
188
192
|
]
|
|
189
193
|
},
|
|
194
|
+
"workLease": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"required": ["holder", "token", "acquiredAt", "expiresAt"],
|
|
197
|
+
"properties": {
|
|
198
|
+
"holder": { "type": "string", "description": "Who holds the lease, as --holder gave it." },
|
|
199
|
+
"token": { "type": "string", "description": "The fencing token: new on every claim, kept by every renew." },
|
|
200
|
+
"acquiredAt": { "type": "string", "description": "When the lease was claimed, ISO 8601." },
|
|
201
|
+
"expiresAt": { "type": "string", "description": "When the lease expires unless renewed, ISO 8601." }
|
|
202
|
+
}
|
|
203
|
+
},
|
|
190
204
|
"asset": {
|
|
191
205
|
"type": "object",
|
|
192
206
|
"required": ["path", "sha256", "actual", "state"],
|
|
@@ -217,6 +231,9 @@
|
|
|
217
231
|
"work-done-unpinned",
|
|
218
232
|
"work-closed-without-date",
|
|
219
233
|
"work-done-gap-open",
|
|
234
|
+
"answer-points-unreadable",
|
|
235
|
+
"answer-point-unknown",
|
|
236
|
+
"answer-point-changed",
|
|
220
237
|
"record-unattested"
|
|
221
238
|
]
|
|
222
239
|
},
|
package/src/workspace/records.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { RecordSource } from "./record-source";
|
|
|
32
32
|
import { sourceBlock, sourceBlockProblems, transcriptDrift } from "./source-block";
|
|
33
33
|
import type { WorkspaceTree } from "./tree";
|
|
34
34
|
import type { DecisionWork, WorkLink, WorkWarningCode } from "./work";
|
|
35
|
+
import type { AnswerWarningCode } from "./points";
|
|
35
36
|
|
|
36
37
|
// ── Reason codes ─────────────────────────────────────────────────────────────
|
|
37
38
|
|
|
@@ -175,8 +176,8 @@ export interface VerdictAttestation {
|
|
|
175
176
|
}
|
|
176
177
|
|
|
177
178
|
export interface RecordWarning {
|
|
178
|
-
/** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), and `records` adds `SEAL_WARNING_CODES` (#2688). */
|
|
179
|
-
code: RecordWarningCode | WorkWarningCode | SealWarningCode;
|
|
179
|
+
/** A work kind's records also carry the codes of `WORK_WARNING_CODES` (#2683), an answer kind's those of `ANSWER_WARNING_CODES` (#2739), and `records` adds `SEAL_WARNING_CODES` (#2688). */
|
|
180
|
+
code: RecordWarningCode | WorkWarningCode | SealWarningCode | AnswerWarningCode;
|
|
180
181
|
message: string;
|
|
181
182
|
}
|
|
182
183
|
|
|
@@ -360,6 +361,17 @@ export const recordKindSchema = z
|
|
|
360
361
|
})
|
|
361
362
|
.strict()
|
|
362
363
|
.optional(),
|
|
364
|
+
/**
|
|
365
|
+
* An answer kind (ws-058, #2739): the decision points file its records
|
|
366
|
+
* answer, relative to this kind file. The records are written by `chant
|
|
367
|
+
* workspace points ask` and `points answer` in the shape of
|
|
368
|
+
* `point-answer.schema.json`, with an `id`, the states `escalated`,
|
|
369
|
+
* `proposed` and `answered`, and `answered` closed. With it, `records`
|
|
370
|
+
* warns about an answer whose point is gone or changed, `check` validates
|
|
371
|
+
* the points file (WSP116), and `points` lists the open questions.
|
|
372
|
+
* Optional.
|
|
373
|
+
*/
|
|
374
|
+
answers: z.object({ points: z.string().min(1) }).strict().optional(),
|
|
363
375
|
})
|
|
364
376
|
.strict()
|
|
365
377
|
.refine((k) => (k.idField === undefined) !== (k.idFrom === undefined), {
|
|
@@ -393,7 +405,23 @@ export const recordKindSchema = z
|
|
|
393
405
|
.refine((k) => !k.work || (k.states !== undefined && k.states.includes(k.work.open) && k.states.includes(k.work.done)), {
|
|
394
406
|
message: "a work kind must have states, and its work open and done states must be listed in them",
|
|
395
407
|
path: ["work"],
|
|
396
|
-
})
|
|
408
|
+
})
|
|
409
|
+
.refine(
|
|
410
|
+
(k) =>
|
|
411
|
+
!k.answers ||
|
|
412
|
+
(k.format === "markdown-front-matter" &&
|
|
413
|
+
k.idField === "id" &&
|
|
414
|
+
k.stateField === "state" &&
|
|
415
|
+
ANSWER_STATES.every((s) => k.states?.includes(s)) &&
|
|
416
|
+
(k.closedStates ?? []).includes("answered")),
|
|
417
|
+
{
|
|
418
|
+
message: 'an answer kind is markdown-front-matter with idField "id", stateField "state", the states escalated, proposed and answered, and answered closed',
|
|
419
|
+
path: ["answers"],
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
/** The states of an answer kind's records (ws-058): open for people, proposed by a model, answered by a table, people, or a confirmed proposal. */
|
|
424
|
+
export const ANSWER_STATES = ["escalated", "proposed", "answered"] as const;
|
|
397
425
|
|
|
398
426
|
export type RecordKind = z.infer<typeof recordKindSchema>;
|
|
399
427
|
|
|
@@ -1250,6 +1278,8 @@ export async function readRecords(loaded: LoadedRecordKind, options: ReadRecords
|
|
|
1250
1278
|
joinSessions(kind, entries, texts, options.subjects ?? null);
|
|
1251
1279
|
// A work kind's links, ready and blocked (#2683), from every record before --current.
|
|
1252
1280
|
const work = kind.work ? await (await import("./work")).applyWork(loaded, entries, options) : undefined;
|
|
1281
|
+
// An answer kind's points (ws-058): an answer whose point is gone or changed is warned about.
|
|
1282
|
+
if (kind.answers) (await import("./points")).applyAnswers(loaded, entries, options);
|
|
1253
1283
|
|
|
1254
1284
|
for (const e of entries) e.valid = e.reasons.length === 0;
|
|
1255
1285
|
const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
|
|
@@ -22,10 +22,15 @@ import {
|
|
|
22
22
|
STATUS_GATE_REASON_CODES,
|
|
23
23
|
STATUS_OUTPUT_SCHEMA_ID,
|
|
24
24
|
STATUS_REASON_CODES,
|
|
25
|
+
STATUS_STEWARD_REASON_CODES,
|
|
25
26
|
workspaceStatus,
|
|
26
27
|
type StatusDocument,
|
|
27
28
|
} from "./status";
|
|
28
29
|
import schema from "./status.schema.json";
|
|
30
|
+
import { appendRunRecord } from "../lifecycle/run-ledger";
|
|
31
|
+
import { acquireStewardLease } from "../op/operator";
|
|
32
|
+
import { stewardWorkHolder } from "../op/work-lease-run";
|
|
33
|
+
import { claimWorkLease } from "../lifecycle/work-lease";
|
|
29
34
|
|
|
30
35
|
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
31
36
|
|
|
@@ -148,6 +153,7 @@ describe("status output schema", () => {
|
|
|
148
153
|
expect(schema.$defs.environment.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_REASON_CODES]);
|
|
149
154
|
expect(schema.$defs.failure.properties.error.properties.code.enum).toEqual([...STATUS_ERROR_CODES]);
|
|
150
155
|
expect(schema.$defs.gateLedger.properties.reason.oneOf[1].properties!.code.enum).toEqual([...STATUS_GATE_REASON_CODES]);
|
|
156
|
+
expect(schema.$defs.member.properties.stewardReasons.items.properties.code.enum).toEqual([...STATUS_STEWARD_REASON_CODES]);
|
|
151
157
|
});
|
|
152
158
|
});
|
|
153
159
|
|
|
@@ -163,6 +169,8 @@ describe("chant workspace status on built workspaces", () => {
|
|
|
163
169
|
expect(web.environments[0].ledger).toEqual({ layout: "members", path: "_members/web/staging/releases.jsonl", shared: false });
|
|
164
170
|
// The later of the two records wins.
|
|
165
171
|
expect(web.environments[0].releases.map((r) => [r.component, r.digest, r.gitSha])).toEqual([["web", D("a"), "a".repeat(40)]]);
|
|
172
|
+
// No plan was ever written for these digests (ws-055, #2733): the field is present, and null.
|
|
173
|
+
expect(web.environments[0].releases.map((r) => r.plan)).toEqual([null]);
|
|
166
174
|
// api has no _members/api/ yet, so it falls back to the flat ledger, which the root member reads too.
|
|
167
175
|
expect(api.environments[0].ledger).toEqual({ layout: "flat", path: "staging/releases.jsonl", shared: true });
|
|
168
176
|
expect(site.environments[0].ledger).toEqual({ layout: "flat", path: "staging/releases.jsonl", shared: true });
|
|
@@ -199,6 +207,33 @@ describe("chant workspace status on built workspaces", () => {
|
|
|
199
207
|
expect(text).toContain("3 members, 3 with a release in staging, 2 differ from prod, 0 unreadable");
|
|
200
208
|
});
|
|
201
209
|
|
|
210
|
+
test("resolves a release's plan from _plans/<digest>.json, member-scoped per #2524 D7 (ws-055, #2733)", async () => {
|
|
211
|
+
const root = repo({
|
|
212
|
+
"chant.workspace.json": declaration([
|
|
213
|
+
{ name: "site", dir: ".", kind: "other", because: "the root project" },
|
|
214
|
+
{ name: "web", dir: "apps/web", kind: "chant" },
|
|
215
|
+
]),
|
|
216
|
+
"apps/web/chant.config.ts": "",
|
|
217
|
+
});
|
|
218
|
+
const webPlan = { digest: D("a"), release: "r-web", units: [{ id: "w-1" }] };
|
|
219
|
+
const apiPlan = { digest: D("b"), release: "r-api", units: [] };
|
|
220
|
+
lifecycle(root, {
|
|
221
|
+
"_members/web/staging/releases.jsonl": jsonl(release("web", "staging", D("a"), "a".repeat(40))),
|
|
222
|
+
[`_members/web/_plans/sha256_${"a".repeat(64)}.json`]: JSON.stringify(webPlan),
|
|
223
|
+
"staging/releases.jsonl": jsonl(release("api", "staging", D("b"), "b".repeat(40)), release("worker", "staging", D("c"), "c".repeat(40))),
|
|
224
|
+
[`_plans/sha256_${"b".repeat(64)}.json`]: JSON.stringify(apiPlan),
|
|
225
|
+
});
|
|
226
|
+
const doc = result(await workspaceStatus({ cwd: root, env: "staging" }));
|
|
227
|
+
expectValid(doc);
|
|
228
|
+
const web = doc.members.find((m) => m.name === "web")!;
|
|
229
|
+
const site = doc.members.find((m) => m.name === "site")!;
|
|
230
|
+
expect(web.environments[0].releases[0].plan).toEqual(webPlan);
|
|
231
|
+
const byComponent = new Map(site.environments[0].releases.map((r) => [r.component, r]));
|
|
232
|
+
expect(byComponent.get("api")?.plan).toEqual(apiPlan);
|
|
233
|
+
// worker has no plan stored under its digest: reads null, not an error.
|
|
234
|
+
expect(byComponent.get("worker")?.plan).toBeNull();
|
|
235
|
+
});
|
|
236
|
+
|
|
202
237
|
test("compares on the input digest when a release has one", async () => {
|
|
203
238
|
const root = repo({ "chant.workspace.json": declaration([{ name: "chart", dir: "chart", kind: "other", because: "helm" }]) });
|
|
204
239
|
lifecycle(root, {
|
|
@@ -253,6 +288,33 @@ describe("chant workspace status on built workspaces", () => {
|
|
|
253
288
|
expect(formatStatus(doc)).toContain("web - no release");
|
|
254
289
|
});
|
|
255
290
|
|
|
291
|
+
test("each member lists its box block: the capability, its broker and its scope (#2726)", async () => {
|
|
292
|
+
const root = repo({
|
|
293
|
+
"chant.workspace.json": declaration([
|
|
294
|
+
{ name: "web", dir: "web", kind: "chant" },
|
|
295
|
+
{
|
|
296
|
+
name: "box",
|
|
297
|
+
dir: "box",
|
|
298
|
+
kind: "other",
|
|
299
|
+
because: "the box's declarations",
|
|
300
|
+
box: { capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] }, { name: "inference" }] },
|
|
301
|
+
},
|
|
302
|
+
]),
|
|
303
|
+
});
|
|
304
|
+
const doc = result(await workspaceStatus({ cwd: root, env: "prod" }));
|
|
305
|
+
expectValid(doc);
|
|
306
|
+
expect(doc.members.map((m) => m.box)).toEqual([
|
|
307
|
+
null,
|
|
308
|
+
{
|
|
309
|
+
capabilities: [
|
|
310
|
+
{ name: "fountain", broker: "lobby", scope: ["agent", "vault", "conversations", "sandboxes"] },
|
|
311
|
+
{ name: "inference", broker: null, scope: [] },
|
|
312
|
+
],
|
|
313
|
+
isolation: null,
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
256
318
|
test("every failure validates with its code", async () => {
|
|
257
319
|
const empty = repo({});
|
|
258
320
|
const outside = realpathSync(mkdtempSync(join(tmpdir(), "chant-status-nogit-")));
|
|
@@ -459,6 +521,122 @@ describe("gate state in chant workspace status --json (#2674)", () => {
|
|
|
459
521
|
});
|
|
460
522
|
});
|
|
461
523
|
|
|
524
|
+
describe("stewards in chant workspace status --json (#2731)", () => {
|
|
525
|
+
const op = (name: string, cron?: string) => ({
|
|
526
|
+
name,
|
|
527
|
+
overview: name,
|
|
528
|
+
phases: [{ name: "Run", steps: [{ kind: "activity", fn: "noop", args: {} }] }],
|
|
529
|
+
...(cron ? { schedule: { cron, overlap: "skip" } } : {}),
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
/** A box member declaring one steward, local by default and fountain on fountain-k3d. */
|
|
533
|
+
function stewarded(): string {
|
|
534
|
+
const steward = {
|
|
535
|
+
kind: "Chant::Steward",
|
|
536
|
+
name: "box-steward",
|
|
537
|
+
ops: [
|
|
538
|
+
op("box-converge", "* * * * *"),
|
|
539
|
+
op("box-release"),
|
|
540
|
+
{ ...op("box-dispatch", "*/5 * * * *"), workLease: { item: ["W-1", "W-2"] }, changesCheckout: true },
|
|
541
|
+
],
|
|
542
|
+
form: { default: "local", environments: { "fountain-k3d": "fountain" } },
|
|
543
|
+
capabilities: ["fountain", "inference"],
|
|
544
|
+
vault: null,
|
|
545
|
+
};
|
|
546
|
+
const root = repo({
|
|
547
|
+
"chant.workspace.json": declaration([
|
|
548
|
+
{ name: "box", dir: "box", kind: "chant", box: { capabilities: [{ name: "fountain", broker: "lobby", scope: ["agent", "vault"] }] } },
|
|
549
|
+
{ name: "notes", dir: "notes", kind: "other", because: "prose" },
|
|
550
|
+
]),
|
|
551
|
+
"box/chant.config.ts": "export default {};\n",
|
|
552
|
+
"box/ops/steward.op.ts": `export const steward = ${JSON.stringify(steward)};\n`,
|
|
553
|
+
"notes/README.md": "notes\n",
|
|
554
|
+
});
|
|
555
|
+
lifecycle(root, { "README": "ledger\n" });
|
|
556
|
+
return root;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
test("names the steward, its form in the environment asked for, its Ops, schedules, last runs and lease", async () => {
|
|
560
|
+
const root = stewarded();
|
|
561
|
+
const box = join(root, "box");
|
|
562
|
+
await appendRunRecord(
|
|
563
|
+
{ op: "box-converge", env: "local", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", status: "ok", labels: {}, outcomes: {}, phases: [] },
|
|
564
|
+
{ cwd: box },
|
|
565
|
+
);
|
|
566
|
+
expect((await acquireStewardLease("box-steward", "box-host:1:abc", { cwd: box, now: () => new Date("2026-09-25T10:00:00.000Z") })).acquired).toBe(true);
|
|
567
|
+
// #2748: the dispatch turn holds W-1; someone else holds W-2.
|
|
568
|
+
const turn = await claimWorkLease("W-1", stewardWorkHolder("box-steward", "box-dispatch", "box-host:1:abc"), { cwd: box, ttlMs: 600_000, now: () => new Date("2026-09-25T10:00:30.000Z") });
|
|
569
|
+
expect(turn.ok).toBe(true);
|
|
570
|
+
expect((await claimWorkLease("W-2", "someone-else", { cwd: box, ttlMs: 600_000, now: () => new Date("2026-09-25T10:00:30.000Z") })).ok).toBe(true);
|
|
571
|
+
|
|
572
|
+
const doc = result(await workspaceStatus({ cwd: root, env: "minimal", now: "2026-09-25T10:01:00.000Z" }));
|
|
573
|
+
expectValid(doc);
|
|
574
|
+
const [boxMember, notes] = doc.members;
|
|
575
|
+
expect(notes.stewards).toEqual([]);
|
|
576
|
+
expect(boxMember.stewardReasons).toEqual([]);
|
|
577
|
+
expect(boxMember.stewards).toHaveLength(1);
|
|
578
|
+
const s = boxMember.stewards[0];
|
|
579
|
+
expect(s).toMatchObject({
|
|
580
|
+
name: "box-steward",
|
|
581
|
+
file: "ops/steward.op.ts",
|
|
582
|
+
form: "local",
|
|
583
|
+
forms: { default: "local", environments: { "fountain-k3d": "fountain" } },
|
|
584
|
+
// #2726: the steward holds no credential; its capabilities join the box block.
|
|
585
|
+
vault: null,
|
|
586
|
+
capabilities: [
|
|
587
|
+
{ name: "fountain", broker: "lobby", declared: true },
|
|
588
|
+
{ name: "inference", broker: null, declared: false },
|
|
589
|
+
],
|
|
590
|
+
lease: { holder: "box-host:1:abc", acquiredAt: "2026-09-25T10:00:00.000Z", live: true },
|
|
591
|
+
});
|
|
592
|
+
expect(s.ops).toEqual([
|
|
593
|
+
{
|
|
594
|
+
name: "box-converge",
|
|
595
|
+
schedule: { cron: "* * * * *", overlap: "skip" },
|
|
596
|
+
env: "local",
|
|
597
|
+
lastRun: { id: expect.any(String), status: "ok", started: "2026-09-25T10:00:00.000Z", ended: "2026-09-25T10:00:05.000Z", gate: null },
|
|
598
|
+
changesCheckout: false,
|
|
599
|
+
workLease: null,
|
|
600
|
+
},
|
|
601
|
+
{ name: "box-release", schedule: null, env: "local", lastRun: null, changesCheckout: false, workLease: null },
|
|
602
|
+
{
|
|
603
|
+
name: "box-dispatch",
|
|
604
|
+
schedule: { cron: "*/5 * * * *", overlap: "skip" },
|
|
605
|
+
env: "local",
|
|
606
|
+
lastRun: null,
|
|
607
|
+
changesCheckout: true,
|
|
608
|
+
workLease: {
|
|
609
|
+
kind: null,
|
|
610
|
+
held: [
|
|
611
|
+
{
|
|
612
|
+
item: "W-1",
|
|
613
|
+
holder: "box-steward/box-dispatch@box-host:1:abc",
|
|
614
|
+
token: turn.ok ? turn.lease.token : "",
|
|
615
|
+
acquiredAt: "2026-09-25T10:00:30.000Z",
|
|
616
|
+
expiresAt: "2026-09-25T10:10:30.000Z",
|
|
617
|
+
state: "active",
|
|
618
|
+
},
|
|
619
|
+
],
|
|
620
|
+
},
|
|
621
|
+
},
|
|
622
|
+
]);
|
|
623
|
+
|
|
624
|
+
const k3d = result(await workspaceStatus({ cwd: root, env: "fountain-k3d", now: "2026-09-25T11:00:00.000Z" }));
|
|
625
|
+
expectValid(k3d);
|
|
626
|
+
expect(k3d.members[0].stewards[0].form).toBe("fountain");
|
|
627
|
+
expect(k3d.members[0].stewards[0].lease!.live).toBe(false);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
test("an op file that can't be imported is a reason, and the read still succeeds", async () => {
|
|
631
|
+
const root = stewarded();
|
|
632
|
+
writeFileSync(join(root, "box", "ops", "broken.op.ts"), "export const x = ;\n");
|
|
633
|
+
const doc = result(await workspaceStatus({ cwd: root, env: "minimal" }));
|
|
634
|
+
expectValid(doc);
|
|
635
|
+
expect(doc.members[0].stewards.map((s) => s.name)).toEqual(["box-steward"]);
|
|
636
|
+
expect(doc.members[0].stewardReasons.map((r) => r.code)).toEqual(["stewards-unreadable"]);
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
462
640
|
describe("chant workspace status on the chant repo (#2557)", () => {
|
|
463
641
|
test("validates and lists every declared member", async () => {
|
|
464
642
|
const doc = result(await workspaceStatus({ cwd: join(REPO, "packages", "core"), env: "prod", compareTo: "staging" }));
|