@intentius/chant 0.110.0 → 0.112.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/main.d.ts.map +1 -1
- package/dist/op/activities/factory.d.ts.map +1 -1
- package/dist/op/steward.d.ts +1 -1
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/workspace/box-cli.d.ts +3 -2
- package/dist/workspace/box-cli.d.ts.map +1 -1
- package/dist/workspace/box-factory-write.d.ts +71 -0
- package/dist/workspace/box-factory-write.d.ts.map +1 -0
- package/dist/workspace/box-intent.d.ts +6 -0
- package/dist/workspace/box-intent.d.ts.map +1 -1
- package/dist/workspace/declaration-write-cli.d.ts +11 -0
- package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
- package/dist/workspace/declaration-write.d.ts +78 -0
- package/dist/workspace/declaration-write.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +8 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/json-edit.d.ts +11 -0
- package/dist/workspace/json-edit.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +8 -0
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +4 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +4 -1
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/steward-index.d.ts +89 -0
- package/dist/workspace/steward-index.d.ts.map +1 -0
- package/dist/workspace/write-scope.d.ts +10 -0
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/main.test.ts +2 -0
- package/src/cli/main.ts +18 -0
- package/src/op/activities/factory.ts +3 -1
- package/src/op/factory.e2e.test.ts +3 -1
- package/src/op/steward.ts +1 -1
- package/src/workspace/box-cli.ts +35 -4
- package/src/workspace/box-factory-write.schema.json +342 -0
- package/src/workspace/box-factory-write.test.ts +108 -0
- package/src/workspace/box-factory-write.ts +226 -0
- package/src/workspace/box-intent.test.ts +14 -4
- package/src/workspace/box-intent.ts +9 -1
- package/src/workspace/declaration-extensions.test.ts +79 -0
- package/src/workspace/declaration-write-cli.ts +82 -0
- package/src/workspace/declaration-write.test.ts +155 -0
- package/src/workspace/declaration-write.ts +275 -0
- package/src/workspace/declaration.ts +19 -1
- package/src/workspace/json-edit.ts +71 -0
- package/src/workspace/ls-contract.test.ts +53 -0
- package/src/workspace/ls.schema.json +11 -0
- package/src/workspace/ls.ts +18 -0
- package/src/workspace/member-write.schema.json +193 -0
- package/src/workspace/reason-codes.test.ts +4 -0
- package/src/workspace/reason-codes.ts +6 -0
- package/src/workspace/status-stewards.ts +23 -35
- package/src/workspace/status.schema.json +10 -1
- package/src/workspace/status.ts +25 -6
- package/src/workspace/steward-index.test.ts +171 -0
- package/src/workspace/steward-index.ts +226 -0
- package/src/workspace/write-scope.ts +17 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json",
|
|
4
|
+
"title": "chant workspace member add|remove and host set output",
|
|
5
|
+
"description": "What `chant workspace member add <name> --from <file|->`, `member remove <name>` and `host set <name> --from <file|->` print (#3596): the entry before and after the write and the file it changed, or the reason it wrote nothing. Each write changes one entry of members or hosts in the declaration, in place, keeping the file's formatting and comments, 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.110.0 and newer.",
|
|
6
|
+
"oneOf": [
|
|
7
|
+
{
|
|
8
|
+
"$ref": "#/$defs/result"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"$ref": "#/$defs/failure"
|
|
12
|
+
}
|
|
13
|
+
],
|
|
14
|
+
"$defs": {
|
|
15
|
+
"result": {
|
|
16
|
+
"description": "The entry was written, or would be with --dry-run, or the declaration already held it (changed false). Exit code 0.",
|
|
17
|
+
"type": "object",
|
|
18
|
+
"required": [
|
|
19
|
+
"$schema",
|
|
20
|
+
"contract",
|
|
21
|
+
"chant",
|
|
22
|
+
"action",
|
|
23
|
+
"name",
|
|
24
|
+
"declaration",
|
|
25
|
+
"paths",
|
|
26
|
+
"changed",
|
|
27
|
+
"dryRun",
|
|
28
|
+
"previous",
|
|
29
|
+
"entry"
|
|
30
|
+
],
|
|
31
|
+
"properties": {
|
|
32
|
+
"$schema": {
|
|
33
|
+
"const": "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json"
|
|
34
|
+
},
|
|
35
|
+
"contract": {
|
|
36
|
+
"const": 1
|
|
37
|
+
},
|
|
38
|
+
"chant": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"description": "The chant that wrote it."
|
|
41
|
+
},
|
|
42
|
+
"action": {
|
|
43
|
+
"enum": [
|
|
44
|
+
"member add",
|
|
45
|
+
"member remove",
|
|
46
|
+
"host set"
|
|
47
|
+
]
|
|
48
|
+
},
|
|
49
|
+
"name": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "The member's or host's name."
|
|
52
|
+
},
|
|
53
|
+
"declaration": {
|
|
54
|
+
"type": "object",
|
|
55
|
+
"required": [
|
|
56
|
+
"path",
|
|
57
|
+
"sha256"
|
|
58
|
+
],
|
|
59
|
+
"properties": {
|
|
60
|
+
"path": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "The declaration file, relative to the repository root (the working directory outside git), with / separators."
|
|
63
|
+
},
|
|
64
|
+
"sha256": {
|
|
65
|
+
"type": "string",
|
|
66
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
67
|
+
"description": "The sha256 of the declaration's bytes after the write, or as they would be with --dry-run."
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"paths": {
|
|
72
|
+
"type": "array",
|
|
73
|
+
"items": {
|
|
74
|
+
"type": "string"
|
|
75
|
+
},
|
|
76
|
+
"description": "The declaration, relative to the repository root, when the write changed it or would with --dry-run; empty when it already held what was given."
|
|
77
|
+
},
|
|
78
|
+
"changed": {
|
|
79
|
+
"type": "boolean",
|
|
80
|
+
"description": "Whether the declaration changed: paths is not empty."
|
|
81
|
+
},
|
|
82
|
+
"dryRun": {
|
|
83
|
+
"type": "boolean",
|
|
84
|
+
"description": "True when --dry-run was given: nothing was written, and the document says what would have been."
|
|
85
|
+
},
|
|
86
|
+
"previous": {
|
|
87
|
+
"oneOf": [
|
|
88
|
+
{
|
|
89
|
+
"type": "null"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"type": "object"
|
|
93
|
+
}
|
|
94
|
+
],
|
|
95
|
+
"description": "The entry before the write, as the declaration held it, x- keys included; null when member add or host set adds a new one."
|
|
96
|
+
},
|
|
97
|
+
"entry": {
|
|
98
|
+
"oneOf": [
|
|
99
|
+
{
|
|
100
|
+
"type": "null"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"type": "object",
|
|
104
|
+
"required": [
|
|
105
|
+
"name"
|
|
106
|
+
],
|
|
107
|
+
"properties": {
|
|
108
|
+
"name": {
|
|
109
|
+
"type": "string"
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
],
|
|
114
|
+
"description": "The entry after the write, as the declaration holds it, with name first; null after member remove."
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
"failure": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"description": "Nothing was written. Exit code 1.",
|
|
121
|
+
"required": [
|
|
122
|
+
"$schema",
|
|
123
|
+
"contract",
|
|
124
|
+
"chant",
|
|
125
|
+
"action",
|
|
126
|
+
"name",
|
|
127
|
+
"error"
|
|
128
|
+
],
|
|
129
|
+
"properties": {
|
|
130
|
+
"$schema": {
|
|
131
|
+
"const": "https://intentius.io/chant/schemas/workspace/member-write/v1/member-write.schema.json"
|
|
132
|
+
},
|
|
133
|
+
"contract": {
|
|
134
|
+
"const": 1
|
|
135
|
+
},
|
|
136
|
+
"chant": {
|
|
137
|
+
"type": "string"
|
|
138
|
+
},
|
|
139
|
+
"action": {
|
|
140
|
+
"enum": [
|
|
141
|
+
"member add",
|
|
142
|
+
"member remove",
|
|
143
|
+
"host set"
|
|
144
|
+
]
|
|
145
|
+
},
|
|
146
|
+
"name": {
|
|
147
|
+
"type": [
|
|
148
|
+
"string",
|
|
149
|
+
"null"
|
|
150
|
+
]
|
|
151
|
+
},
|
|
152
|
+
"error": {
|
|
153
|
+
"type": "object",
|
|
154
|
+
"required": [
|
|
155
|
+
"code",
|
|
156
|
+
"message"
|
|
157
|
+
],
|
|
158
|
+
"properties": {
|
|
159
|
+
"code": {
|
|
160
|
+
"enum": [
|
|
161
|
+
"declaration-missing",
|
|
162
|
+
"declaration-ambiguous",
|
|
163
|
+
"declaration-unparseable",
|
|
164
|
+
"declaration-invalid",
|
|
165
|
+
"placement-invalid",
|
|
166
|
+
"reader-too-old",
|
|
167
|
+
"root-chant-required",
|
|
168
|
+
"not-a-git-repository",
|
|
169
|
+
"revision-unknown",
|
|
170
|
+
"write-usage-invalid",
|
|
171
|
+
"write-input-invalid",
|
|
172
|
+
"member-exists",
|
|
173
|
+
"member-unknown",
|
|
174
|
+
"box-isolation-collision",
|
|
175
|
+
"box-isolation-literal",
|
|
176
|
+
"write-scope-member",
|
|
177
|
+
"write-scope-protected",
|
|
178
|
+
"write-scope-class-unknown",
|
|
179
|
+
"agent-unknown",
|
|
180
|
+
"principal-unidentified",
|
|
181
|
+
"write-lock-timeout",
|
|
182
|
+
"write-lock-not-held"
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
"message": {
|
|
186
|
+
"type": "string"
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -40,7 +40,9 @@ import { PATCH_ERROR_CODES } from "./patch";
|
|
|
40
40
|
import { AGENT_ERROR_CODES } from "./agent-cli";
|
|
41
41
|
import { SCOPE_FINDING_CODES, WRITE_SCOPE_CODES } from "./write-scope";
|
|
42
42
|
import { PLANTABLE_REASON_CODES } from "./box-factory";
|
|
43
|
+
import { BOX_FACTORY_ERROR_CODES } from "./box-factory-write";
|
|
43
44
|
import { BOX_LISTING_ERROR_CODES } from "./box-listing";
|
|
45
|
+
import { DECLARATION_WRITE_ERROR_CODES } from "./declaration-write";
|
|
44
46
|
import { BOX_PUBLISH_ERROR_CODES } from "./box-publish";
|
|
45
47
|
import { WRITE_LOCK_ERROR_CODES } from "./write-lock-cli";
|
|
46
48
|
import { WIP_ERROR_CODES, WIP_WRITE_ERROR_CODES } from "./wip";
|
|
@@ -108,6 +110,8 @@ const PER_COMMAND: Record<string, readonly string[]> = {
|
|
|
108
110
|
SCOPE_FINDING_CODES,
|
|
109
111
|
PLANTABLE_REASON_CODES,
|
|
110
112
|
BOX_LISTING_ERROR_CODES,
|
|
113
|
+
DECLARATION_WRITE_ERROR_CODES,
|
|
114
|
+
BOX_FACTORY_ERROR_CODES,
|
|
111
115
|
BOX_PUBLISH_ERROR_CODES,
|
|
112
116
|
WRITE_LOCK_ERROR_CODES,
|
|
113
117
|
WIP_ERROR_CODES,
|
|
@@ -260,6 +260,12 @@ export const REASONS = {
|
|
|
260
260
|
"listing-member-unknown": "box listing set names a member the declaration does not declare.",
|
|
261
261
|
"listing-box-missing": "box listing set names a member whose entry declares no box block, so it has no listing.",
|
|
262
262
|
"listing-cover-invalid": "The cover can't be read, is not a PNG, JPEG or WebP picture, is larger than 5 MiB, has a path outside the workspace, or has an extension other than its picture format's.",
|
|
263
|
+
// A declaration's members and hosts written through chant (member add|remove, host set, #3596): why the write was refused. Nothing is written.
|
|
264
|
+
"member-exists": "member add names a member the declaration already has, with an entry other than the one given.",
|
|
265
|
+
"member-unknown": "member remove names a member the declaration does not declare.",
|
|
266
|
+
// A box's factory written through chant (box factory set, #3600): why the write was refused. Nothing is written.
|
|
267
|
+
"factory-member-unknown": "box factory set names a member the declaration does not declare.",
|
|
268
|
+
"factory-box-missing": "box factory set names a member whose entry declares no box block, so it has no factory.",
|
|
263
269
|
// A box's work published through chant (box publish, #3165, ws-088): why the call printed no result.
|
|
264
270
|
"publish-member-unknown": "box publish names a member the declaration does not declare.",
|
|
265
271
|
"publish-none": "box publish names a member whose box block names no publisher, or which declares no box block.",
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* its Ops with their schedules, and each Op's last run.
|
|
5
5
|
*
|
|
6
6
|
* The steward itself is read from its declaration, the same `*.op.ts` files
|
|
7
|
-
* `chant operator --steward` reads (`../op/discover.ts`),
|
|
7
|
+
* `chant operator --steward` reads (`../op/discover.ts`), through the index
|
|
8
|
+
* kept per tree (`./steward-index.ts`, #3636), and only for a member
|
|
8
9
|
* of kind `chant` that is a chant project of its own (a `chant.config.ts` or
|
|
9
10
|
* `.json` in its directory), so a member never lists a parent project's
|
|
10
11
|
* steward. Everything
|
|
@@ -47,8 +48,7 @@
|
|
|
47
48
|
|
|
48
49
|
import { existsSync, realpathSync } from "node:fs";
|
|
49
50
|
import { dirname, join, relative, resolve } from "node:path";
|
|
50
|
-
import {
|
|
51
|
-
import { stewardBesideFor, stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
51
|
+
import { stewardFormFor, stewardLeaseName, type StewardForm } from "../op/steward";
|
|
52
52
|
import { readRunLedger, runEnvOf } from "../lifecycle/run-ledger";
|
|
53
53
|
import { readConvergeLedger, type ConvergeTickRecord } from "../lifecycle/converge-ledger";
|
|
54
54
|
import { leaseRef } from "../lifecycle/lease";
|
|
@@ -56,7 +56,7 @@ import { readBlobBySha, readRefSha } from "../lifecycle/git";
|
|
|
56
56
|
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
57
57
|
import { listWorkLeases } from "../lifecycle/work-lease";
|
|
58
58
|
import { stewardWorkHolder } from "../op/work-lease-run";
|
|
59
|
-
import type
|
|
59
|
+
import { readStewardIndex, type IndexedOp, type IndexedSteward, type StewardIndex } from "./steward-index";
|
|
60
60
|
import type { OpRunRecord } from "../op/runtime";
|
|
61
61
|
import { readInFlightRun } from "../op/run-live";
|
|
62
62
|
import { approveCommand } from "./status-gates";
|
|
@@ -342,19 +342,14 @@ async function readLeaseRef(leaseName: string, memberDir: string, now: string):
|
|
|
342
342
|
}
|
|
343
343
|
|
|
344
344
|
/** An Op's `beside` entry (#2861): null for an Op run as one of the steward's turns. */
|
|
345
|
-
async function besideOf(
|
|
346
|
-
|
|
347
|
-
op: string,
|
|
348
|
-
memberDir: string,
|
|
349
|
-
now: string,
|
|
350
|
-
): Promise<StatusStewardOp["beside"]> {
|
|
351
|
-
const beside = stewardBesideFor(declaration, op);
|
|
345
|
+
async function besideOf(steward: IndexedSteward, op: string, memberDir: string, now: string): Promise<StatusStewardOp["beside"]> {
|
|
346
|
+
const beside = steward.beside.find((b) => b.op === op);
|
|
352
347
|
if (!beside) return null;
|
|
353
|
-
return { ready: beside.ready
|
|
348
|
+
return { ready: beside.ready, lease: await readLeaseRef(op, memberDir, now) };
|
|
354
349
|
}
|
|
355
350
|
|
|
356
351
|
/** The work leases `steward`'s turns of `op` hold, from the ledger of the Op's kind or the member's. */
|
|
357
|
-
async function readHeldWorkLeases(steward: string, op:
|
|
352
|
+
async function readHeldWorkLeases(steward: string, op: IndexedOp, memberDir: string, now: string): Promise<StatusStewardWorkLease[]> {
|
|
358
353
|
try {
|
|
359
354
|
const kind = op.workLease?.kind;
|
|
360
355
|
const cwd = kind ? dirname(resolve(memberDir, kind)) : memberDir;
|
|
@@ -378,7 +373,7 @@ export async function readMemberStewards(
|
|
|
378
373
|
now: string,
|
|
379
374
|
kind = "chant",
|
|
380
375
|
box: { capabilities: { name: string; broker: string | null }[] } | null = null,
|
|
381
|
-
deps: { readQuestions?: ReadQuestionStates } = {},
|
|
376
|
+
deps: { readQuestions?: ReadQuestionStates; readIndex?: (memberDir: string) => Promise<StewardIndex> } = {},
|
|
382
377
|
): Promise<MemberStewards> {
|
|
383
378
|
const reasons: MemberStewards["reasons"] = [];
|
|
384
379
|
// Read once per member, and only when a run stopped on a question.
|
|
@@ -386,19 +381,18 @@ export async function readMemberStewards(
|
|
|
386
381
|
const questionStates = () => (states ??= (deps.readQuestions ?? readQuestionStates)(memberDir));
|
|
387
382
|
if (kind !== "chant" || !isChantProject(memberDir)) return { stewards: [], reasons };
|
|
388
383
|
|
|
389
|
-
let
|
|
384
|
+
let index: StewardIndex;
|
|
390
385
|
try {
|
|
391
|
-
|
|
386
|
+
index = await (deps.readIndex ?? readStewardIndex)(memberDir);
|
|
392
387
|
} catch (err) {
|
|
393
388
|
reasons.push({ code: "stewards-unreadable", message: err instanceof Error ? err.message.split("\n")[0] : String(err) });
|
|
394
389
|
return { stewards: [], reasons };
|
|
395
390
|
}
|
|
396
|
-
const {
|
|
397
|
-
for (const message of
|
|
398
|
-
for (const message of conflicts) reasons.push({ code: "stewards-conflict", message });
|
|
391
|
+
for (const message of index.errors) reasons.push({ code: "stewards-unreadable", message });
|
|
392
|
+
for (const message of index.conflicts) reasons.push({ code: "stewards-conflict", message });
|
|
399
393
|
|
|
400
394
|
const stewards: StatusSteward[] = [];
|
|
401
|
-
for (const
|
|
395
|
+
for (const declaration of [...index.stewards].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
402
396
|
const ops: StatusStewardOp[] = [];
|
|
403
397
|
const waiting: StatusSteward["waiting"] = [];
|
|
404
398
|
for (const op of declaration.ops) {
|
|
@@ -467,11 +461,11 @@ export async function readMemberStewards(
|
|
|
467
461
|
}
|
|
468
462
|
stewards.push({
|
|
469
463
|
name: declaration.name,
|
|
470
|
-
file: relative(realpathSync(memberDir), realpathSync(filePath)).split("\\").join("/"),
|
|
464
|
+
file: relative(realpathSync(memberDir), realpathSync(declaration.filePath)).split("\\").join("/"),
|
|
471
465
|
form: stewardFormFor(declaration, env),
|
|
472
466
|
forms: { default: declaration.form.default, environments: { ...declaration.form.environments } },
|
|
473
|
-
vault:
|
|
474
|
-
capabilities:
|
|
467
|
+
vault: declaration.vault,
|
|
468
|
+
capabilities: declaration.capabilities.map((name) => {
|
|
475
469
|
const declared = box?.capabilities.find((c) => c.name === name);
|
|
476
470
|
return { name, broker: declared?.broker ?? null, declared: declared !== undefined };
|
|
477
471
|
}),
|
|
@@ -480,7 +474,7 @@ export async function readMemberStewards(
|
|
|
480
474
|
waiting,
|
|
481
475
|
});
|
|
482
476
|
}
|
|
483
|
-
if (stewards.length > 0) await addUndeclaredWaits(memberDir, stewards,
|
|
477
|
+
if (stewards.length > 0 && index.otherOps) await addUndeclaredWaits(memberDir, stewards, index.otherOps, reasons, now, questionStates);
|
|
484
478
|
return { stewards, reasons };
|
|
485
479
|
}
|
|
486
480
|
|
|
@@ -489,26 +483,20 @@ export async function readMemberStewards(
|
|
|
489
483
|
* steward its record names. A steward's process can start an Op itself
|
|
490
484
|
* with `CHANT_STEWARD` set (studio#137), and the run ledger then records
|
|
491
485
|
* the run as the steward's though the declaration does not list the Op.
|
|
492
|
-
*
|
|
493
|
-
*
|
|
486
|
+
* `ops` are the member's Ops no steward lists, from the index; import
|
|
487
|
+
* failures are left out there, as discovering the stewards reported them as
|
|
488
|
+
* `stewards-unreadable`.
|
|
494
489
|
*/
|
|
495
490
|
async function addUndeclaredWaits(
|
|
496
491
|
memberDir: string,
|
|
497
492
|
stewards: StatusSteward[],
|
|
498
|
-
|
|
493
|
+
ops: IndexedOp[],
|
|
499
494
|
reasons: MemberStewards["reasons"],
|
|
500
495
|
now: string,
|
|
501
496
|
questionStates: () => Promise<Map<string, string> | null>,
|
|
502
497
|
): Promise<void> {
|
|
503
|
-
const declared = new Set([...found.values()].flatMap(({ declaration }) => declaration.ops.map((op) => op.name)));
|
|
504
|
-
let ops: OpConfig[];
|
|
505
|
-
try {
|
|
506
|
-
ops = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => d.config).filter((op) => !declared.has(op.name));
|
|
507
|
-
} catch {
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
498
|
const byName = new Map(stewards.map((s) => [s.name, s]));
|
|
511
|
-
for (const op of ops.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
499
|
+
for (const op of [...ops].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
512
500
|
const opEnv = runEnvOf(op);
|
|
513
501
|
try {
|
|
514
502
|
const newest = (await readRunLedger(opEnv, op.name, { cwd: memberDir })).records.at(-1);
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"$defs": {
|
|
8
8
|
"boxShip": {
|
|
9
9
|
"type": "object",
|
|
10
|
+
"patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
|
|
10
11
|
"additionalProperties": false,
|
|
11
12
|
"required": ["op", "gate", "env", "bookkeeping", "serving", "pending"],
|
|
12
13
|
"properties": {
|
|
@@ -170,6 +171,7 @@
|
|
|
170
171
|
},
|
|
171
172
|
"member": {
|
|
172
173
|
"type": "object",
|
|
174
|
+
"patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
|
|
173
175
|
"required": ["name", "dir", "kind", "fields", "environments", "compare", "readable", "gateLedger", "gates", "box", "stewards", "stewardReasons"],
|
|
174
176
|
"properties": {
|
|
175
177
|
"name": { "type": "string" },
|
|
@@ -543,7 +545,11 @@
|
|
|
543
545
|
"choice": { "description": "The record's choice as written, such as { option, reason }, or null while the record is proposed." },
|
|
544
546
|
"answer": { "type": ["string", "null"], "description": "The chosen option's label, from options[]'s entry whose id is choice.option, or null while no option is chosen (#2855). Added by chant 0.94.0." },
|
|
545
547
|
"decided_by": { "type": ["string", "null"], "description": "Who decided it, or null while it is proposed." },
|
|
546
|
-
"decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." }
|
|
548
|
+
"decided_on": { "type": ["string", "null"], "description": "The day it was decided, or null while it is proposed." },
|
|
549
|
+
"approved": {
|
|
550
|
+
"type": "boolean",
|
|
551
|
+
"description": "Added in contract 1 by #3609. Whether the record's state counts as approved: the decision kind's approval rank for that state is above 0, the rule the factory's pick holds dispatch with. With chant's decision kind, decided, ratified and superseded are approved, and proposed and withdrawn are not. False when no decision record has the id, and for a kind that declares no approval ranks."
|
|
552
|
+
}
|
|
547
553
|
}
|
|
548
554
|
},
|
|
549
555
|
"replication": {
|
|
@@ -583,6 +589,7 @@
|
|
|
583
589
|
},
|
|
584
590
|
"box": {
|
|
585
591
|
"type": "object",
|
|
592
|
+
"patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
|
|
586
593
|
"required": ["capabilities", "isolation", "intent", "services"],
|
|
587
594
|
"properties": {
|
|
588
595
|
"factory": {
|
|
@@ -681,6 +688,7 @@
|
|
|
681
688
|
},
|
|
682
689
|
"factory": {
|
|
683
690
|
"type": "object",
|
|
691
|
+
"patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
|
|
684
692
|
"required": ["builds", "check", "checks", "builders", "tiers", "builderFor", "publish"],
|
|
685
693
|
"properties": {
|
|
686
694
|
"builds": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "The members the factory builds, in declaration order. A work item a person asks for constrains member:<the first>." },
|
|
@@ -723,6 +731,7 @@
|
|
|
723
731
|
},
|
|
724
732
|
"listing": {
|
|
725
733
|
"type": "object",
|
|
734
|
+
"patternProperties": { "^x-": { "description": "Added in contract 1 by #3595. An x- key the declaration holds on this object, with its value as written." } },
|
|
726
735
|
"required": ["published", "title", "line", "cover"],
|
|
727
736
|
"properties": {
|
|
728
737
|
"published": { "type": "boolean", "description": "Whether the box is listed; true unless declared otherwise." },
|
package/src/workspace/status.ts
CHANGED
|
@@ -48,7 +48,7 @@ import { resolveBoxes, type ResolvedIsolation } from "./box-isolation";
|
|
|
48
48
|
import { resolveBoxIntents, unresolvedIntent, type BoxIntent } from "./box-intent";
|
|
49
49
|
import { loadKindRegistry, resolveMemberFields, type MemberFields } from "./kinds";
|
|
50
50
|
import { factoryView, listingView, plantability, treeBytes, type FactoryView, type ListingView, type Plantable } from "./box-factory";
|
|
51
|
-
import { declaredRecordKinds, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
|
|
51
|
+
import { declaredRecordKinds, extensionsAt, readDeclaration, readerVersion, WorkspaceReadError, type Declaration, type ErrorLocation, type Member } from "./declaration";
|
|
52
52
|
import type { ReasonCode } from "./reason-codes";
|
|
53
53
|
import { gateAdmissionFrom } from "./identity";
|
|
54
54
|
import { scopeSource } from "./write-scope";
|
|
@@ -320,6 +320,18 @@ export interface StatusQuery {
|
|
|
320
320
|
readStewards?: typeof readMemberStewards;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
/** A factory view with the declaration's x- keys on it and on its check, publish and tiers (#3595). */
|
|
324
|
+
function factoryWithX(view: FactoryView | null, pointer: string, x: (pointer: string) => Record<string, unknown>): FactoryView | null {
|
|
325
|
+
if (view === null) return null;
|
|
326
|
+
return {
|
|
327
|
+
...view,
|
|
328
|
+
check: view.check === null ? null : { ...view.check, ...x(`${pointer}/check`) },
|
|
329
|
+
tiers: view.tiers.map((t, i) => ({ ...t, ...x(`${pointer}/tiers/${i}`) })),
|
|
330
|
+
publish: view.publish === null ? null : { ...view.publish, ...x(`${pointer}/publish`) },
|
|
331
|
+
...x(pointer),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
323
335
|
class StatusError extends Error {
|
|
324
336
|
constructor(
|
|
325
337
|
readonly code: StatusErrorCode,
|
|
@@ -500,6 +512,10 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
500
512
|
const own = await hasMemberLedger(m, found.dir);
|
|
501
513
|
const gates = await readMemberGates(own ? `${MEMBERS_DIR}/${m.name}/${GATES_DIR}` : GATES_DIR, own ? "members" : "flat", commit, envs, found.dir, now, query.readGates, rules);
|
|
502
514
|
const stewards = await (query.readStewards ?? readMemberStewards)(resolve(found.dir, m.dir), query.env, now, m.kind, m.box);
|
|
515
|
+
// The declaration's x- keys go back on the objects that hold them (#3595).
|
|
516
|
+
const x = (pointer: string) => extensionsAt(declaration, pointer);
|
|
517
|
+
const bp = m.box?.pointer ?? "";
|
|
518
|
+
const withX = <T extends object>(view: T | null, pointer: string): T | null => (view === null ? null : { ...view, ...x(pointer) });
|
|
503
519
|
members.push({
|
|
504
520
|
name: m.name,
|
|
505
521
|
dir: m.dir,
|
|
@@ -514,7 +530,7 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
514
530
|
m.box === null
|
|
515
531
|
? null
|
|
516
532
|
: {
|
|
517
|
-
capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope] })),
|
|
533
|
+
capabilities: m.box.capabilities.map((c) => ({ name: c.name, broker: c.broker, scope: [...c.scope], ...x(c.pointer) })),
|
|
518
534
|
isolation: isolation.get(m.name) ?? null,
|
|
519
535
|
intent: intents.get(m.name) ?? null,
|
|
520
536
|
services: m.box.services.map((s) => ({
|
|
@@ -525,15 +541,18 @@ export async function workspaceStatus(query: StatusQuery): Promise<StatusDocumen
|
|
|
525
541
|
duration: s.duration,
|
|
526
542
|
health: s.health,
|
|
527
543
|
optional: s.optional,
|
|
544
|
+
...x(s.pointer),
|
|
528
545
|
})),
|
|
529
|
-
factory: factoryView(m.box.factory, declaration.members),
|
|
530
|
-
listing: listingView(m.box.listing, coverBytes),
|
|
546
|
+
factory: factoryWithX(factoryView(m.box.factory, declaration.members), `${bp}/factory`, x),
|
|
547
|
+
listing: withX(listingView(m.box.listing, coverBytes), `${bp}/listing`),
|
|
531
548
|
publisher: m.box.publisher,
|
|
532
|
-
ship: await shipView(m, found.dir, read),
|
|
533
|
-
replicate: m.box.replicate === null ? null : (({ box: _box, ...rest }) => rest)(policyView(m.name, m.box.replicate)),
|
|
549
|
+
ship: withX(await shipView(m, found.dir, read), `${bp}/ship`),
|
|
550
|
+
replicate: withX(m.box.replicate === null ? null : (({ box: _box, ...rest }) => rest)(policyView(m.name, m.box.replicate)), `${bp}/replicate`),
|
|
551
|
+
...x(bp),
|
|
534
552
|
},
|
|
535
553
|
stewards: stewards.stewards,
|
|
536
554
|
stewardReasons: stewards.reasons,
|
|
555
|
+
...x(m.pointer),
|
|
537
556
|
});
|
|
538
557
|
}
|
|
539
558
|
// Several members read from one flat ledger see the same records; say so.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The steward index behind `chant workspace status` (#3636): discovered
|
|
3
|
+
* once per tree, read back while the tree is the same, discovered again
|
|
4
|
+
* after an edit, a new file or a commit, and never written into the
|
|
5
|
+
* checkout.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { afterAll, afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
11
|
+
import { cleanScratch, commitAll, git, repo, scratchDir } from "./__fixtures__/contract-repo";
|
|
12
|
+
import { declareSteward } from "../op/steward";
|
|
13
|
+
import type { OpConfig } from "../op/types";
|
|
14
|
+
import { discoverStewardIndex, readStewardIndex, type StewardIndex } from "./steward-index";
|
|
15
|
+
import { readMemberStewards } from "./status-stewards";
|
|
16
|
+
|
|
17
|
+
afterAll(cleanScratch);
|
|
18
|
+
|
|
19
|
+
let cache: string;
|
|
20
|
+
let before: string | undefined;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
before = process.env.CHANT_CACHE_DIR;
|
|
23
|
+
cache = scratchDir("chant-steward-index-cache-");
|
|
24
|
+
process.env.CHANT_CACHE_DIR = cache;
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (before === undefined) delete process.env.CHANT_CACHE_DIR;
|
|
28
|
+
else process.env.CHANT_CACHE_DIR = before;
|
|
29
|
+
delete process.env.CHANT_STEWARD_INDEX;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function op(name: string, extra: Partial<OpConfig> = {}): OpConfig {
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
overview: `${name} fixture`,
|
|
36
|
+
phases: [{ name: "only", steps: [{ kind: "activity", fn: "noop", args: {} }] }],
|
|
37
|
+
...extra,
|
|
38
|
+
} as OpConfig;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A chant project whose one Op file declares a steward and exports another Op it doesn't list. */
|
|
42
|
+
function project(): string {
|
|
43
|
+
const tick = op("tick", { schedule: { cron: "0 * * * *" }, labels: { Env: "box" } });
|
|
44
|
+
const build = op("build", { workLease: { item: "W-001", kind: "work/work.kind.mjs" }, changesCheckout: true });
|
|
45
|
+
const steward = declareSteward({
|
|
46
|
+
name: "box-steward",
|
|
47
|
+
ops: [tick],
|
|
48
|
+
beside: [{ op: build, ready: { kind: "activity", fn: "ready", args: {} } }],
|
|
49
|
+
form: { default: "local", environments: { k3d: "fountain" } },
|
|
50
|
+
capabilities: ["inference"],
|
|
51
|
+
});
|
|
52
|
+
return repo(
|
|
53
|
+
{
|
|
54
|
+
"chant.config.json": "{}\n",
|
|
55
|
+
"ops/steward.op.ts": `export const steward = ${JSON.stringify(steward)};\nexport const lonely = { props: ${JSON.stringify(op("lonely"))} };\n`,
|
|
56
|
+
},
|
|
57
|
+
true,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `readStewardIndex`, counting the discoveries it makes. */
|
|
62
|
+
function counted(dir: string) {
|
|
63
|
+
let n = 0;
|
|
64
|
+
const discover = async (d: string): Promise<StewardIndex> => {
|
|
65
|
+
n++;
|
|
66
|
+
return discoverStewardIndex(d);
|
|
67
|
+
};
|
|
68
|
+
return { read: () => readStewardIndex(dir, { discover }), discoveries: () => n };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("the steward index (#3636)", () => {
|
|
72
|
+
test("holds what status reads of the stewards and Ops", async () => {
|
|
73
|
+
const root = project();
|
|
74
|
+
const index = await discoverStewardIndex(root);
|
|
75
|
+
expect(index.errors).toEqual([]);
|
|
76
|
+
expect(index.conflicts).toEqual([]);
|
|
77
|
+
expect(index.stewards).toHaveLength(1);
|
|
78
|
+
const [s] = index.stewards;
|
|
79
|
+
expect(s).toMatchObject({
|
|
80
|
+
name: "box-steward",
|
|
81
|
+
beside: [{ op: "build", ready: true }],
|
|
82
|
+
form: { default: "local", environments: { k3d: "fountain" } },
|
|
83
|
+
capabilities: ["inference"],
|
|
84
|
+
vault: null,
|
|
85
|
+
});
|
|
86
|
+
expect(s.filePath.endsWith(join("ops", "steward.op.ts"))).toBe(true);
|
|
87
|
+
expect(s.ops).toEqual([
|
|
88
|
+
{ name: "tick", schedule: { cron: "0 * * * *" }, labels: { Env: "box" }, workLease: null, changesCheckout: false },
|
|
89
|
+
{ name: "build", schedule: null, workLease: { kind: "work/work.kind.mjs" }, changesCheckout: true },
|
|
90
|
+
]);
|
|
91
|
+
expect(index.otherOps).toEqual([{ name: "lonely", schedule: null, workLease: null, changesCheckout: false }]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("a second read of the same tree discovers nothing", async () => {
|
|
95
|
+
const root = project();
|
|
96
|
+
const c = counted(root);
|
|
97
|
+
const first = await c.read();
|
|
98
|
+
const second = await c.read();
|
|
99
|
+
expect(c.discoveries()).toBe(1);
|
|
100
|
+
expect(second).toEqual(JSON.parse(JSON.stringify(first)));
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("an edit, a new file or a commit discovers again", async () => {
|
|
104
|
+
const root = project();
|
|
105
|
+
const c = counted(root);
|
|
106
|
+
await c.read();
|
|
107
|
+
// A module already imported in this process isn't read again, so only the count is checked.
|
|
108
|
+
writeFileSync(join(root, "ops", "steward.op.ts"), "export const nothing = 1;\n");
|
|
109
|
+
await c.read();
|
|
110
|
+
expect(c.discoveries()).toBe(2);
|
|
111
|
+
await c.read();
|
|
112
|
+
expect(c.discoveries()).toBe(2);
|
|
113
|
+
mkdirSync(join(root, "lib"));
|
|
114
|
+
writeFileSync(join(root, "lib", "helper.ts"), "export const x = 1;\n");
|
|
115
|
+
await c.read();
|
|
116
|
+
expect(c.discoveries()).toBe(3);
|
|
117
|
+
commitAll(root);
|
|
118
|
+
await c.read();
|
|
119
|
+
expect(c.discoveries()).toBe(4);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an Op file that fails to import is not indexed, so the next read tries again", async () => {
|
|
123
|
+
const root = project();
|
|
124
|
+
writeFileSync(join(root, "ops", "broken.op.ts"), "throw new Error('not today');\n");
|
|
125
|
+
const c = counted(root);
|
|
126
|
+
expect((await c.read()).errors).toHaveLength(1);
|
|
127
|
+
expect((await c.read()).errors).toHaveLength(1);
|
|
128
|
+
expect(c.discoveries()).toBe(2);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("CHANT_STEWARD_INDEX=0 discovers every time", async () => {
|
|
132
|
+
const root = project();
|
|
133
|
+
process.env.CHANT_STEWARD_INDEX = "0";
|
|
134
|
+
const c = counted(root);
|
|
135
|
+
await c.read();
|
|
136
|
+
await c.read();
|
|
137
|
+
expect(c.discoveries()).toBe(2);
|
|
138
|
+
expect(existsSync(join(cache, "workspace-stewards"))).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the index lives in the cache dir, never in the checkout", async () => {
|
|
142
|
+
const root = project();
|
|
143
|
+
const status = git(root, "status", "--porcelain", "--ignored");
|
|
144
|
+
await readStewardIndex(root);
|
|
145
|
+
expect(git(root, "status", "--porcelain", "--ignored")).toBe(status);
|
|
146
|
+
const [dir] = readdirSync(join(cache, "workspace-stewards"));
|
|
147
|
+
expect(readdirSync(join(cache, "workspace-stewards", dir)).filter((f) => f.endsWith(".json"))).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("the indexes of a checkout that is gone go on the next write", async () => {
|
|
151
|
+
const gone = project();
|
|
152
|
+
await readStewardIndex(gone);
|
|
153
|
+
rmSync(gone, { recursive: true, force: true });
|
|
154
|
+
await readStewardIndex(project());
|
|
155
|
+
expect(readdirSync(join(cache, "workspace-stewards"))).toHaveLength(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("status lists the same stewards from the index as from discovery", async () => {
|
|
159
|
+
const root = project();
|
|
160
|
+
const now = "2027-01-01T00:00:00Z";
|
|
161
|
+
process.env.CHANT_STEWARD_INDEX = "0";
|
|
162
|
+
const discovered = await readMemberStewards(root, "k3d", now);
|
|
163
|
+
delete process.env.CHANT_STEWARD_INDEX;
|
|
164
|
+
await readMemberStewards(root, "k3d", now);
|
|
165
|
+
const indexed = await readMemberStewards(root, "k3d", now, "chant", null, {
|
|
166
|
+
readIndex: (d) => readStewardIndex(d, { discover: () => Promise.reject(new Error("should have been read from the index")) }),
|
|
167
|
+
});
|
|
168
|
+
expect(indexed).toEqual(discovered);
|
|
169
|
+
expect(indexed.stewards[0]).toMatchObject({ name: "box-steward", form: "fountain", file: "ops/steward.op.ts" });
|
|
170
|
+
});
|
|
171
|
+
});
|