@intentius/chant 0.86.0 → 0.88.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/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +5 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/version.d.ts +8 -0
- package/dist/cli/version.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/sessions.d.ts +9 -0
- package/dist/workspace/__fixtures__/sessions.d.ts.map +1 -1
- package/dist/workspace/composites.d.ts +13 -0
- package/dist/workspace/composites.d.ts.map +1 -1
- package/dist/workspace/environments.d.ts +80 -0
- package/dist/workspace/environments.d.ts.map +1 -0
- package/dist/workspace/intent.d.ts +6 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +20 -4
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +12 -2
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +41 -0
- package/dist/workspace/records-close.d.ts.map +1 -0
- package/dist/workspace/records-since.d.ts +40 -1
- package/dist/workspace/records-since.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +117 -12
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +84 -14
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/runtimes.d.ts +6 -0
- package/dist/workspace/runtimes.d.ts.map +1 -1
- package/dist/workspace/session-kinds.d.ts +28 -0
- package/dist/workspace/session-kinds.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +2 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/trust/seal.d.ts +127 -0
- package/dist/workspace/trust/seal.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +7 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -1
- package/dist/workspace/work.d.ts +3 -3
- package/package.json +1 -1
- package/src/cli/handlers/misc.ts +1 -9
- package/src/cli/main.ts +29 -10
- package/src/cli/mcp/server.test.ts +14 -1
- package/src/cli/mcp/server.ts +3 -1
- package/src/cli/registry.ts +5 -0
- package/src/cli/version.ts +15 -0
- package/src/workspace/__fixtures__/sessions.ts +41 -0
- package/src/workspace/composites.schema.json +68 -3
- package/src/workspace/composites.test.ts +119 -5
- package/src/workspace/composites.ts +26 -7
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +23 -0
- package/src/workspace/environments.ts +165 -0
- package/src/workspace/intent-gaps.test.ts +217 -0
- package/src/workspace/intent.schema.json +23 -1
- package/src/workspace/intent.test.ts +2 -0
- package/src/workspace/intent.ts +35 -3
- package/src/workspace/read-contract.test.ts +3 -0
- package/src/workspace/reason-codes.test.ts +9 -3
- package/src/workspace/reason-codes.ts +24 -4
- package/src/workspace/record-assets.test.ts +4 -3
- package/src/workspace/records-amend.schema.json +30 -1
- package/src/workspace/records-cli.ts +113 -14
- package/src/workspace/records-close.schema.json +192 -0
- package/src/workspace/records-close.ts +129 -0
- package/src/workspace/records-contract.test.ts +4 -3
- package/src/workspace/records-new.schema.json +25 -0
- package/src/workspace/records-review.schema.json +81 -3
- package/src/workspace/records-sessions-write.test.ts +274 -0
- package/src/workspace/records-since.schema.json +27 -2
- package/src/workspace/records-since.ts +120 -6
- package/src/workspace/records-write-contract.test.ts +5 -1
- package/src/workspace/records-write.test.ts +4 -2
- package/src/workspace/records-write.ts +336 -43
- package/src/workspace/records.schema.json +37 -3
- package/src/workspace/records.ts +145 -25
- package/src/workspace/runtimes.ts +12 -3
- package/src/workspace/session-kinds.ts +79 -0
- package/src/workspace/status.ts +1 -1
- package/src/workspace/trust/record-seal.test.ts +315 -0
- package/src/workspace/trust/seal.test.ts +222 -0
- package/src/workspace/trust/seal.ts +289 -0
- package/src/workspace/trust/ssh-commit.ts +2 -2
- package/src/workspace/work.test.ts +3 -1
- package/src/workspace/work.ts +4 -4
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
loadRecordKind,
|
|
35
35
|
normalisePrincipal,
|
|
36
36
|
readRecords,
|
|
37
|
+
RECORD_SEAL_FIELD,
|
|
37
38
|
RecordReadError,
|
|
38
39
|
type LoadedRecordKind,
|
|
39
40
|
type Quorum,
|
|
@@ -42,11 +43,15 @@ import {
|
|
|
42
43
|
type RecordEntry,
|
|
43
44
|
type RecordFormat,
|
|
44
45
|
type RecordHistory,
|
|
46
|
+
type SealInput,
|
|
47
|
+
type VerdictAttestation,
|
|
45
48
|
} from "./records";
|
|
46
49
|
import { gitTree, workingTree, type WorkspaceTree } from "./tree";
|
|
47
50
|
import type { DecisionWork } from "./work";
|
|
48
51
|
import { activeAttestors, type ProvenanceLevel } from "./trust/attestor";
|
|
49
52
|
import { policyAtBase, recordProvenance, resolveBase, type BaseSource, type RecordProvenance } from "./trust/provenance";
|
|
53
|
+
import type { TrustPolicy } from "./trust/policy";
|
|
54
|
+
import type { SealedRecord } from "./trust/seal";
|
|
50
55
|
|
|
51
56
|
/** The version of the `records` output this chant writes. */
|
|
52
57
|
export const RECORDS_CONTRACT_VERSION = 1;
|
|
@@ -55,7 +60,7 @@ export const RECORDS_CONTRACT_VERSION = 1;
|
|
|
55
60
|
export const RECORDS_OUTPUT_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records/v1/records.schema.json";
|
|
56
61
|
|
|
57
62
|
const USAGE =
|
|
58
|
-
"chant workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review (#2670)";
|
|
63
|
+
"chant workspace records [--kind <kind file>] [--current] [--at <rev>] [--base <rev>] [--require attested] [--json] | chant workspace records [--kind <kind file>] --since <rev|session id> [--at <rev>] [--json] | chant workspace records pin <path> | chant workspace records new|amend|review|close (#2670, #2693)";
|
|
59
64
|
|
|
60
65
|
/** Exit code when the read worked and a record falls below `--require`. */
|
|
61
66
|
export const EXIT_BELOW_REQUIRED = 2;
|
|
@@ -68,13 +73,26 @@ export interface RecordsQuery {
|
|
|
68
73
|
base?: string;
|
|
69
74
|
/** Where `kind` is resolved from and the repository is found. */
|
|
70
75
|
cwd: string;
|
|
76
|
+
/**
|
|
77
|
+
* For a work kind: walk the region of each done item's `source` and raise
|
|
78
|
+
* `work-done-gap-open` when its finding still fires (#2686). On unless
|
|
79
|
+
* false; the intent graph passes false, since it raises the warning itself.
|
|
80
|
+
*/
|
|
81
|
+
workGaps?: boolean;
|
|
71
82
|
}
|
|
72
83
|
|
|
73
84
|
/**
|
|
74
85
|
* A record as the output carries it: the entry plus its provenance (#2547),
|
|
75
|
-
* and
|
|
86
|
+
* and, when the kind has a reviews list, its quorum (#2671) and what its
|
|
87
|
+
* author seal establishes (#2688).
|
|
76
88
|
*/
|
|
77
|
-
export type RecordView = RecordEntry & {
|
|
89
|
+
export type RecordView = RecordEntry & {
|
|
90
|
+
provenance: RecordProvenance;
|
|
91
|
+
quorum?: Quorum | null;
|
|
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
|
+
attested?: boolean | null;
|
|
94
|
+
attestation?: VerdictAttestation;
|
|
95
|
+
};
|
|
78
96
|
|
|
79
97
|
/** The role in the trust policy whose holders' verdicts the quorum does not count (#2671). */
|
|
80
98
|
export const AGENT_ROLE = "agent";
|
|
@@ -266,15 +284,26 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
266
284
|
paths: result.records.map((r) => r.path),
|
|
267
285
|
attestors: policy.active ? await activeAttestors() : [],
|
|
268
286
|
});
|
|
269
|
-
// The quorum: the need from the declaration in the tree read, agents
|
|
270
|
-
// whether verdicts need a seal
|
|
271
|
-
|
|
287
|
+
// The quorum: the need from the declaration in the tree read, agents,
|
|
288
|
+
// whether verdicts need a seal, and the keys a seal verifies against,
|
|
289
|
+
// all from the policy at base (#2671, #2687).
|
|
290
|
+
const seals = loaded.kind.reviews ? await import("./trust/seal") : undefined;
|
|
291
|
+
const checkVerdictSeal = seals?.checkVerdictSeal;
|
|
292
|
+
const quorumOptions = checkVerdictSeal
|
|
272
293
|
? {
|
|
273
294
|
...declaredQuorum(tree),
|
|
274
295
|
agents: new Set((policy.roles[AGENT_ROLE] ?? []).map(normalisePrincipal)),
|
|
275
296
|
attestation: policy.active,
|
|
297
|
+
verifySeal: (v: SealInput) => checkVerdictSeal(policy, v),
|
|
276
298
|
}
|
|
277
299
|
: undefined;
|
|
300
|
+
const records: RecordView[] = result.records.map((r) => ({
|
|
301
|
+
...r,
|
|
302
|
+
provenance: provenance.get(r.path)!,
|
|
303
|
+
...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
|
|
304
|
+
...(seals ? authorSeal(loaded.kind, r, policy, seals.checkRecordSeal) : {}),
|
|
305
|
+
}));
|
|
306
|
+
if (loaded.kind.work && query.workGaps !== false && top) await raiseWorkGaps(loaded, records, { root, workspaceRoot, at: query.at });
|
|
278
307
|
return {
|
|
279
308
|
$schema: RECORDS_OUTPUT_SCHEMA_ID,
|
|
280
309
|
contract: RECORDS_CONTRACT_VERSION,
|
|
@@ -288,11 +317,7 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
288
317
|
workspaceRoot,
|
|
289
318
|
current: !!query.current,
|
|
290
319
|
trust: { base: base.commit, baseFrom: base.from, active: policy.active, signersPath: policy.signersPath, problems: policy.problems },
|
|
291
|
-
records
|
|
292
|
-
...r,
|
|
293
|
-
provenance: provenance.get(r.path)!,
|
|
294
|
-
...(quorumOptions ? { quorum: computeQuorum(loaded.kind, r, quorumOptions) } : {}),
|
|
295
|
-
})),
|
|
320
|
+
records,
|
|
296
321
|
summary: result.summary,
|
|
297
322
|
...(result.decisions ? { decisions: result.decisions } : {}),
|
|
298
323
|
};
|
|
@@ -302,6 +327,79 @@ export async function queryRecords(query: RecordsQuery): Promise<RecordsDocument
|
|
|
302
327
|
}
|
|
303
328
|
}
|
|
304
329
|
|
|
330
|
+
/**
|
|
331
|
+
* A record's author seal checked against the policy at base (#2688), for a
|
|
332
|
+
* kind with a reviews list: its author is the kind's `reviews.decider` field.
|
|
333
|
+
* Under an active signers file, a record that names an author and is not
|
|
334
|
+
* attested gains the warning `record-unattested`, and is still read: sealing
|
|
335
|
+
* records is opt-in for now. A record that can't be parsed gets neither field.
|
|
336
|
+
*/
|
|
337
|
+
function authorSeal(
|
|
338
|
+
kind: LoadedRecordKind["kind"],
|
|
339
|
+
r: RecordEntry,
|
|
340
|
+
policy: TrustPolicy,
|
|
341
|
+
check: (policy: TrustPolicy, r: SealedRecord) => { attested: boolean | null } & VerdictAttestation,
|
|
342
|
+
): { attested?: boolean | null; attestation?: VerdictAttestation } {
|
|
343
|
+
if (r.data === null) return {};
|
|
344
|
+
const field = kind.reviews!.decider;
|
|
345
|
+
const author = typeof r.data[field] === "string" && (r.data[field] as string).trim() !== "" ? (r.data[field] as string) : null;
|
|
346
|
+
const { attested, ...attestation } = check(policy, { record: r.id, digest: r.digest, author, authorField: field, state: r.state, seal: r.data[RECORD_SEAL_FIELD] });
|
|
347
|
+
if (policy.active && author !== null && attested !== true) {
|
|
348
|
+
r.warnings.push({ code: "record-unattested", message: `an attestation policy is active at base, and ${attestation.message}` });
|
|
349
|
+
}
|
|
350
|
+
return { attested, attestation };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* `work-done-gap-open` on a records read (#2686): for each done work record
|
|
355
|
+
* whose `source` names a finding and a region, walk that region with the
|
|
356
|
+
* intent graph, at the same revision, and copy the warning the walk raises
|
|
357
|
+
* on the record. One walk per region, and only a region some done item names.
|
|
358
|
+
* The walk reads the record kinds the declaration names, and the work kind
|
|
359
|
+
* and its decision kind if it names neither. It needs git and a workspace
|
|
360
|
+
* declaration; when the walk can't be made, for one without them or a region
|
|
361
|
+
* that no longer exists, the record is left as it was.
|
|
362
|
+
*/
|
|
363
|
+
async function raiseWorkGaps(loaded: LoadedRecordKind, records: RecordView[], opts: { root: string; workspaceRoot: string; at: string | undefined }): Promise<void> {
|
|
364
|
+
const work = loaded.kind.work!;
|
|
365
|
+
const byRegion = new Map<string, RecordView[]>();
|
|
366
|
+
for (const r of records) {
|
|
367
|
+
if (r.id === null || r.state !== work.done) continue;
|
|
368
|
+
const src = r.data?.source;
|
|
369
|
+
if (src === null || typeof src !== "object" || Array.isArray(src)) continue;
|
|
370
|
+
const { finding, region } = src as Record<string, unknown>;
|
|
371
|
+
if (typeof finding !== "string" || typeof region !== "string") continue;
|
|
372
|
+
byRegion.set(region, [...(byRegion.get(region) ?? []), r]);
|
|
373
|
+
}
|
|
374
|
+
if (byRegion.size === 0) return;
|
|
375
|
+
const cwd = opts.workspaceRoot === "." ? opts.root : join(opts.root, ...opts.workspaceRoot.split("/"));
|
|
376
|
+
let declared: string[];
|
|
377
|
+
try {
|
|
378
|
+
declared = declaredKindFiles(cwd, opts.at).map((k) => k.file);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
if (err instanceof WorkspaceReadError) return;
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
const kinds: string[] = [];
|
|
384
|
+
const seen = new Set<string>();
|
|
385
|
+
for (const file of [...declared, resolve(dirname(loaded.file), work.decisions), loaded.file]) {
|
|
386
|
+
const real = realpathOr(file);
|
|
387
|
+
if (seen.has(real)) continue;
|
|
388
|
+
seen.add(real);
|
|
389
|
+
kinds.push(file);
|
|
390
|
+
}
|
|
391
|
+
const { intentGraph } = await import("./intent");
|
|
392
|
+
for (const [region, items] of byRegion) {
|
|
393
|
+
const { doc } = await intentGraph({ cwd, region, at: opts.at, kinds });
|
|
394
|
+
if ("error" in doc) continue;
|
|
395
|
+
for (const r of items) {
|
|
396
|
+
const node = doc.nodes.find((n) => n.kind === "work" && n.id === `record:${loaded.kind.name}/${r.id}`);
|
|
397
|
+
const warning = node?.kind === "work" ? node.warnings.find((w) => w.code === "work-done-gap-open") : undefined;
|
|
398
|
+
if (warning && !r.warnings.some((w) => w.code === warning.code)) r.warnings.push(warning);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
305
403
|
/**
|
|
306
404
|
* `chant workspace records pin <path>`: the `{path, sha256}` a decision's
|
|
307
405
|
* evidence entry holds for a file, with the path from the workspace root
|
|
@@ -321,7 +419,7 @@ export function pinFile(file: string, cwd: string): { path: string; sha256: stri
|
|
|
321
419
|
|
|
322
420
|
export async function runWorkspaceRecords(ctx: CommandContext): Promise<number> {
|
|
323
421
|
const { args } = ctx;
|
|
324
|
-
if (args.extraPositional === "new" || args.extraPositional === "amend" || args.extraPositional === "review") {
|
|
422
|
+
if (args.extraPositional === "new" || args.extraPositional === "amend" || args.extraPositional === "review" || args.extraPositional === "close") {
|
|
325
423
|
return (await import("./records-write")).runRecordsWrite(ctx);
|
|
326
424
|
}
|
|
327
425
|
if (args.extraPositional === "pin") {
|
|
@@ -338,7 +436,7 @@ export async function runWorkspaceRecords(ctx: CommandContext): Promise<number>
|
|
|
338
436
|
return 0;
|
|
339
437
|
}
|
|
340
438
|
if (args.extraPositional) {
|
|
341
|
-
console.error(formatError({ message: `chant workspace records takes no argument but pin, new, amend or
|
|
439
|
+
console.error(formatError({ message: `chant workspace records takes no argument but pin, new, amend, review or close (got ${args.extraPositional})`, hint: USAGE }));
|
|
342
440
|
return 1;
|
|
343
441
|
}
|
|
344
442
|
if (!args.kind) return runDeclaredRecords(args);
|
|
@@ -493,7 +591,8 @@ function formatRecords(records: RecordView[], summary: { total: number; valid: n
|
|
|
493
591
|
const flag = r.valid ? "" : " INVALID";
|
|
494
592
|
const superseded = r.supersededBy ? ` superseded by ${r.supersededBy}` : "";
|
|
495
593
|
const attested = r.provenance.level === "attested" ? ` attested by ${r.provenance.principal}` : "";
|
|
496
|
-
|
|
594
|
+
const sealed = r.attested === true ? ` sealed by ${(r.data?.[RECORD_SEAL_FIELD] as { signer: string }).signer}` : "";
|
|
595
|
+
lines.push(`${(r.id ?? "-").padEnd(idWidth)} ${(r.state ?? "-").padEnd(stateWidth)} ${title}${superseded}${attested}${sealed}${flag}`);
|
|
497
596
|
if (r.ready !== undefined) {
|
|
498
597
|
const blocked = (r.blockedBy ?? []).map((b) => `${b.id} (${b.state ?? "unknown"})`).join(", ");
|
|
499
598
|
const implemented = (r.implements ?? []).map((d) => `${d.id} (${d.state ?? "unknown"})`).join(", ");
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json",
|
|
4
|
+
"title": "chant workspace records close output",
|
|
5
|
+
"description": "What `chant workspace records close <session id> [--kind <session kind file>]` prints (#2693): the session's path and id, the fields the close set (the state, and the close time and closing revision when the session kind names fields for them), the seal written and the commit the session closed at, or the reason it wrote nothing. Version 1 of the write contract for records. The command writes one file or none and never commits. Readers ignore fields they do not know; a field is only ever added within a version. The error codes are a closed list, each in the one closed list of `reason-codes.ts`. Contract version 1 of this document is written by chant 0.88.0 and newer; an older chant refuses the close verb.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{
|
|
8
|
+
"$ref": "#/$defs/result"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"$ref": "#/$defs/failure"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"$defs": {
|
|
15
|
+
"result": {
|
|
16
|
+
"description": "The record was written, or would be with --dry-run. Exit code 0.",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": [
|
|
19
|
+
"$schema",
|
|
20
|
+
"contract",
|
|
21
|
+
"kind",
|
|
22
|
+
"path",
|
|
23
|
+
"id",
|
|
24
|
+
"changed",
|
|
25
|
+
"seal",
|
|
26
|
+
"closedRev",
|
|
27
|
+
"dryRun",
|
|
28
|
+
"warnings"
|
|
29
|
+
],
|
|
30
|
+
"properties": {
|
|
31
|
+
"$schema": {
|
|
32
|
+
"const": "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json"
|
|
33
|
+
},
|
|
34
|
+
"contract": {
|
|
35
|
+
"const": 1
|
|
36
|
+
},
|
|
37
|
+
"kind": {
|
|
38
|
+
"type": "object",
|
|
39
|
+
"required": [
|
|
40
|
+
"name",
|
|
41
|
+
"schema",
|
|
42
|
+
"file"
|
|
43
|
+
],
|
|
44
|
+
"properties": {
|
|
45
|
+
"name": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "The kind's name, such as \"session\"."
|
|
48
|
+
},
|
|
49
|
+
"schema": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "The `$id` of the schema the record was validated against."
|
|
52
|
+
},
|
|
53
|
+
"file": {
|
|
54
|
+
"type": "string",
|
|
55
|
+
"description": "The kind file, relative to the repository root, with / separators."
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"path": {
|
|
60
|
+
"type": "string",
|
|
61
|
+
"description": "The record file, relative to the repository root (the working directory outside git), with / separators."
|
|
62
|
+
},
|
|
63
|
+
"id": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"description": "The record's id."
|
|
66
|
+
},
|
|
67
|
+
"changed": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
},
|
|
72
|
+
"description": "The top-level fields whose value differs from the session as it was: its state, the close time, the closing revision and the seal, and the opening revision when it was null and the repository now has a commit."
|
|
73
|
+
},
|
|
74
|
+
"seal": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"required": [
|
|
77
|
+
"field",
|
|
78
|
+
"digest"
|
|
79
|
+
],
|
|
80
|
+
"properties": {
|
|
81
|
+
"field": {
|
|
82
|
+
"type": "string",
|
|
83
|
+
"description": "The seal field, as the session kind's session.seal names it, such as closed_digest."
|
|
84
|
+
},
|
|
85
|
+
"digest": {
|
|
86
|
+
"type": "string",
|
|
87
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
88
|
+
"description": "The seal written: the lowercase hex SHA-256 of the file with LF line endings and without its seal line, which records checks on every read (session-seal-mismatch)."
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
"closedRev": {
|
|
93
|
+
"type": [
|
|
94
|
+
"string",
|
|
95
|
+
"null"
|
|
96
|
+
],
|
|
97
|
+
"pattern": "^[0-9a-f]{40,64}$",
|
|
98
|
+
"description": "The full commit id HEAD named at the close, or null outside git or before the first commit. It is written to the field session.closedRev names, when the kind names one. The caller commits the close, so the commit that carries it comes after this one."
|
|
99
|
+
},
|
|
100
|
+
"dryRun": {
|
|
101
|
+
"type": "boolean",
|
|
102
|
+
"description": "True when --dry-run was given and nothing was written."
|
|
103
|
+
},
|
|
104
|
+
"warnings": {
|
|
105
|
+
"type": "array",
|
|
106
|
+
"description": "The written record's warnings, as chant workspace records reports them. A warning never refuses a write.",
|
|
107
|
+
"items": {
|
|
108
|
+
"$ref": "#/$defs/warning"
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"text": {
|
|
112
|
+
"type": "string",
|
|
113
|
+
"description": "With --dry-run only: the whole text of the file the command would write."
|
|
114
|
+
},
|
|
115
|
+
"error": false
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"warning": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"required": [
|
|
121
|
+
"code",
|
|
122
|
+
"message"
|
|
123
|
+
],
|
|
124
|
+
"properties": {
|
|
125
|
+
"code": {
|
|
126
|
+
"enum": [
|
|
127
|
+
"asset-drift",
|
|
128
|
+
"asset-missing",
|
|
129
|
+
"asset-stale",
|
|
130
|
+
"record-supersedes-pending",
|
|
131
|
+
"record-no-evidence",
|
|
132
|
+
"review-undigested"
|
|
133
|
+
]
|
|
134
|
+
},
|
|
135
|
+
"message": {
|
|
136
|
+
"type": "string"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"failure": {
|
|
141
|
+
"description": "Nothing was written. Exit code 1.",
|
|
142
|
+
"type": "object",
|
|
143
|
+
"required": [
|
|
144
|
+
"$schema",
|
|
145
|
+
"contract",
|
|
146
|
+
"error"
|
|
147
|
+
],
|
|
148
|
+
"properties": {
|
|
149
|
+
"$schema": {
|
|
150
|
+
"const": "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json"
|
|
151
|
+
},
|
|
152
|
+
"contract": {
|
|
153
|
+
"const": 1
|
|
154
|
+
},
|
|
155
|
+
"error": {
|
|
156
|
+
"type": "object",
|
|
157
|
+
"required": [
|
|
158
|
+
"code",
|
|
159
|
+
"message"
|
|
160
|
+
],
|
|
161
|
+
"properties": {
|
|
162
|
+
"code": {
|
|
163
|
+
"enum": [
|
|
164
|
+
"kind-unreadable",
|
|
165
|
+
"kind-invalid",
|
|
166
|
+
"schema-unreadable",
|
|
167
|
+
"schema-id-mismatch",
|
|
168
|
+
"schema-invalid",
|
|
169
|
+
"location-missing",
|
|
170
|
+
"write-usage-invalid",
|
|
171
|
+
"record-not-found",
|
|
172
|
+
"record-closed",
|
|
173
|
+
"record-unparseable",
|
|
174
|
+
"record-schema-invalid",
|
|
175
|
+
"record-id-duplicate",
|
|
176
|
+
"record-supersedes-unknown",
|
|
177
|
+
"record-supersedes-conflict",
|
|
178
|
+
"session-seal-mismatch",
|
|
179
|
+
"session-verdict-unknown-record"
|
|
180
|
+
]
|
|
181
|
+
},
|
|
182
|
+
"message": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"description": "What was wrong, and what to do instead when there is something. A session already closed is record-closed; a verdict naming a record the session kind's subjects lack is session-verdict-unknown-record."
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
"path": false
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace records close <session id>` (#2693): close a review
|
|
3
|
+
* session in one write. It sets the session's state to its kind's closed
|
|
4
|
+
* state, the time it closed and the commit it closed at (the fields the
|
|
5
|
+
* kind's `session` block names in `closedOn` and `closedRev`), and then the
|
|
6
|
+
* seal by chant's rule ({@link sessionSeal}), so a UI never computes a seal.
|
|
7
|
+
*
|
|
8
|
+
* Like `new`, `amend` and `review`, it reads the records again with the file
|
|
9
|
+
* in place and writes only when the session comes back valid: a verdict
|
|
10
|
+
* naming a record the subjects lack is refused with
|
|
11
|
+
* `session-verdict-unknown-record`. A session already closed is
|
|
12
|
+
* `record-closed`. It writes one file or none and never commits.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { writeFileSync } from "node:fs";
|
|
16
|
+
import type { ReasonCode } from "./reason-codes";
|
|
17
|
+
import { sessionSeal } from "./record-sessions";
|
|
18
|
+
import { RECORD_REASON_CODES } from "./records";
|
|
19
|
+
import {
|
|
20
|
+
abs,
|
|
21
|
+
failure,
|
|
22
|
+
findRecord,
|
|
23
|
+
LOAD_ERROR_CODES,
|
|
24
|
+
open,
|
|
25
|
+
openedRevFill,
|
|
26
|
+
readAll,
|
|
27
|
+
RECORDS_WRITE_CONTRACT_VERSION,
|
|
28
|
+
RecordWriteError,
|
|
29
|
+
replaceFields,
|
|
30
|
+
stableJson,
|
|
31
|
+
validateWrite,
|
|
32
|
+
type WriteFailure,
|
|
33
|
+
type WriteResult,
|
|
34
|
+
} from "./records-write";
|
|
35
|
+
import { headCommit } from "./session-kinds";
|
|
36
|
+
|
|
37
|
+
export const RECORDS_CLOSE_SCHEMA_ID = "https://intentius.io/chant/schemas/workspace/records-close/v1/records-close.schema.json";
|
|
38
|
+
|
|
39
|
+
/** Why `records close` wrote nothing. Closed. */
|
|
40
|
+
export const CLOSE_ERROR_CODES = [
|
|
41
|
+
...LOAD_ERROR_CODES,
|
|
42
|
+
"write-usage-invalid",
|
|
43
|
+
"record-not-found",
|
|
44
|
+
"record-closed",
|
|
45
|
+
...RECORD_REASON_CODES,
|
|
46
|
+
] as const satisfies readonly ReasonCode[];
|
|
47
|
+
export type CloseErrorCode = (typeof CLOSE_ERROR_CODES)[number];
|
|
48
|
+
|
|
49
|
+
export type CloseDocument =
|
|
50
|
+
| (WriteResult & {
|
|
51
|
+
/** The top-level fields the close set, in the order written. */
|
|
52
|
+
changed: string[];
|
|
53
|
+
/** The seal field and the digest written in it. */
|
|
54
|
+
seal: { field: string; digest: string };
|
|
55
|
+
/** The commit HEAD named at the close, or null outside git or before the first commit. */
|
|
56
|
+
closedRev: string | null;
|
|
57
|
+
})
|
|
58
|
+
| WriteFailure<CloseErrorCode>;
|
|
59
|
+
|
|
60
|
+
export interface CloseRecordOptions {
|
|
61
|
+
/** The session kind file, resolved against `cwd`. */
|
|
62
|
+
kind: string;
|
|
63
|
+
id: string;
|
|
64
|
+
dryRun?: boolean;
|
|
65
|
+
cwd: string;
|
|
66
|
+
/** The close time. Defaults to now. */
|
|
67
|
+
now?: Date;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** An ISO 8601 time in UTC to the second, as the reference session schema's dateTime takes it. */
|
|
71
|
+
function isoSeconds(d: Date): string {
|
|
72
|
+
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `records close`: close one open session and seal it. */
|
|
76
|
+
export async function closeRecord(opts: CloseRecordOptions): Promise<CloseDocument> {
|
|
77
|
+
try {
|
|
78
|
+
const o = await open(opts.kind, opts.cwd);
|
|
79
|
+
const { kind } = o.loaded;
|
|
80
|
+
const decl = kind.session;
|
|
81
|
+
if (!decl || kind.stateField === undefined) {
|
|
82
|
+
throw new RecordWriteError("write-usage-invalid", `the ${kind.name} kind has no session block, and records close closes a review session; amend sets the state of other records`);
|
|
83
|
+
}
|
|
84
|
+
const closedState = (kind.closedStates ?? [])[0];
|
|
85
|
+
if (closedState === undefined) throw new RecordWriteError("write-usage-invalid", `the ${kind.name} kind lists no closed state, so a session of it can't close`);
|
|
86
|
+
const before = await readAll(o, o.source);
|
|
87
|
+
const target = findRecord(before, opts.id, kind.name);
|
|
88
|
+
if (target.state !== null && (kind.closedStates ?? []).includes(target.state)) {
|
|
89
|
+
throw new RecordWriteError("record-closed", `${opts.id} is already ${target.state}, and a closed session is sealed and stays as it is`);
|
|
90
|
+
}
|
|
91
|
+
if (target.data === null) throw new RecordWriteError("record-unparseable", `${target.path} can't be read, so it can't be closed`);
|
|
92
|
+
const old = target.data;
|
|
93
|
+
const closedRev = headCommit(o.root);
|
|
94
|
+
const set: Record<string, unknown> = {
|
|
95
|
+
...openedRevFill(kind, old, o.root),
|
|
96
|
+
[kind.stateField]: closedState,
|
|
97
|
+
...(decl.closedOn ? { [decl.closedOn]: isoSeconds(opts.now ?? new Date()) } : {}),
|
|
98
|
+
...(decl.closedRev ? { [decl.closedRev]: closedRev } : {}),
|
|
99
|
+
};
|
|
100
|
+
// The seal line is written last, into text that already holds everything else, so the seal is the digest of the file without it.
|
|
101
|
+
const merged = { ...old, ...set };
|
|
102
|
+
delete merged[decl.seal];
|
|
103
|
+
const unsealed = replaceFields(o.source.read(target.path), set, merged);
|
|
104
|
+
if (unsealed === undefined) throw new RecordWriteError("record-unparseable", `${target.path}: its fields can't be rewritten in place without changing the rest of the file`);
|
|
105
|
+
const digest = sessionSeal(unsealed, decl.seal, kind.format);
|
|
106
|
+
const text = replaceFields(unsealed, { [decl.seal]: digest }, { ...merged, [decl.seal]: digest });
|
|
107
|
+
if (text === undefined || sessionSeal(text, decl.seal, kind.format) !== digest) {
|
|
108
|
+
throw new RecordWriteError("record-unparseable", `${target.path}: the ${decl.seal} line can't be added without changing the text it seals`);
|
|
109
|
+
}
|
|
110
|
+
const warnings = await validateWrite(o, before, target.path, text);
|
|
111
|
+
if (!opts.dryRun) writeFileSync(abs(o, target.path), text);
|
|
112
|
+
const written = { ...merged, [decl.seal]: digest };
|
|
113
|
+
return {
|
|
114
|
+
$schema: RECORDS_CLOSE_SCHEMA_ID,
|
|
115
|
+
contract: RECORDS_WRITE_CONTRACT_VERSION,
|
|
116
|
+
kind: o.view,
|
|
117
|
+
path: target.path,
|
|
118
|
+
id: opts.id,
|
|
119
|
+
changed: Object.keys(written).filter((k) => stableJson(old[k]) !== stableJson(written[k])),
|
|
120
|
+
seal: { field: decl.seal, digest },
|
|
121
|
+
closedRev,
|
|
122
|
+
dryRun: !!opts.dryRun,
|
|
123
|
+
warnings,
|
|
124
|
+
...(opts.dryRun ? { text } : {}),
|
|
125
|
+
};
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return failure<CloseErrorCode>(RECORDS_CLOSE_SCHEMA_ID, err);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -11,7 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import Ajv2020 from "ajv/dist/2020";
|
|
12
12
|
import { afterAll, describe, expect, test } from "vitest";
|
|
13
13
|
import { queryRecords, RECORDS_CONTRACT_VERSION, RECORDS_OUTPUT_SCHEMA_ID, type RecordsDocument } from "./records-cli";
|
|
14
|
-
import { READ_ERROR_CODES, RECORD_REASON_CODES, RECORD_WARNING_CODES, recordTextDigest, REVIEW_REASON_CODES } from "./records";
|
|
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
16
|
import schema from "./records.schema.json";
|
|
17
17
|
import { PROVENANCE_LEVELS } from "./trust/attestor";
|
|
@@ -148,8 +148,9 @@ describe("records output schema", () => {
|
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
test("lists exactly the warning codes the code can return", () => {
|
|
151
|
-
// A work kind's records carry the work warnings too,
|
|
152
|
-
|
|
151
|
+
// 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
154
|
});
|
|
154
155
|
|
|
155
156
|
test("every failure validates with its code", async () => {
|
|
@@ -61,6 +61,30 @@
|
|
|
61
61
|
"type": "string",
|
|
62
62
|
"description": "The record's id."
|
|
63
63
|
},
|
|
64
|
+
"seal": {
|
|
65
|
+
"type": "object",
|
|
66
|
+
"description": "Added in contract 1 by #2688. With --sign only: the author seal written into the record's top-level seal field, an ssh signature by the key given (or git's user.signingkey) over <id>\\n<digest>\\n<author>\\n<state>, in the ssh-keygen namespace chant-record. The author is the kind's reviews.decider field (decided_by for decisions), and the digest is the record's digest by the digest rule, which leaves the seal field out. The write does not check the key against the signers file; chant workspace records reports whether the seal verifies.",
|
|
67
|
+
"required": [
|
|
68
|
+
"signer",
|
|
69
|
+
"key",
|
|
70
|
+
"signature"
|
|
71
|
+
],
|
|
72
|
+
"properties": {
|
|
73
|
+
"signer": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "The record's author, as its reviews.decider field names them."
|
|
76
|
+
},
|
|
77
|
+
"key": {
|
|
78
|
+
"type": "string",
|
|
79
|
+
"pattern": "^SHA256:[A-Za-z0-9+/]+=*$",
|
|
80
|
+
"description": "The fingerprint of the key that signed, read back from the signature."
|
|
81
|
+
},
|
|
82
|
+
"signature": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"description": "The armored ssh signature."
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
64
88
|
"dryRun": {
|
|
65
89
|
"type": "boolean",
|
|
66
90
|
"description": "True when --dry-run was given and nothing was written."
|
|
@@ -136,6 +160,7 @@
|
|
|
136
160
|
"record-id-taken",
|
|
137
161
|
"record-id-unallocatable",
|
|
138
162
|
"record-path-unmatched",
|
|
163
|
+
"record-sign-failed",
|
|
139
164
|
"record-unparseable",
|
|
140
165
|
"record-schema-invalid",
|
|
141
166
|
"record-id-duplicate",
|