@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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `chant workspace status` reads of a project's stewards and Ops
|
|
3
|
+
* (#3636), kept per tree so a status doesn't import every `*.op.ts` file
|
|
4
|
+
* each time it runs.
|
|
5
|
+
*
|
|
6
|
+
* Discovery (`../op/discover.ts`) has to import the Op modules, and an Op
|
|
7
|
+
* module may do work when it is imported: run git, read files, even start
|
|
8
|
+
* another chant. Status needs only plain fields of what it finds: each
|
|
9
|
+
* steward's name, file, form, vault, capabilities and Ops, and of each Op
|
|
10
|
+
* its name, schedule, labels, work lease kind and `changesCheckout`. The
|
|
11
|
+
* first status on a tree discovers them and writes that index to
|
|
12
|
+
* `<cache dir>/workspace-stewards/<checkout>/<key>.json`; later ones read
|
|
13
|
+
* it back, until the tree changes. The cache dir is the one the graph cache
|
|
14
|
+
* uses (`./graph-cache.ts`): `$CHANT_CACHE_DIR`, else
|
|
15
|
+
* `$XDG_CACHE_HOME/chant`, else `~/.cache/chant`, outside the workspace,
|
|
16
|
+
* since a read never changes the workspace it reads.
|
|
17
|
+
*
|
|
18
|
+
* The tree is the checkout's `HEAD` tree plus every changed or untracked
|
|
19
|
+
* file `git status` lists (its path and contents), and the chant version,
|
|
20
|
+
* so a commit, an edit or a new file anywhere in the checkout discovers
|
|
21
|
+
* again. Files git ignores (`node_modules`) are not part of it. An index is
|
|
22
|
+
* only written when every Op file imported, so an import that failed is
|
|
23
|
+
* tried again by the next status. `CHANT_STEWARD_INDEX=0` turns the index
|
|
24
|
+
* off, for an Op module whose declaration depends on more than its tree.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execFile } from "node:child_process";
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
30
|
+
import { homedir } from "node:os";
|
|
31
|
+
import { dirname, join, resolve } from "node:path";
|
|
32
|
+
import { promisify } from "node:util";
|
|
33
|
+
import { CHANT_VERSION } from "../cli/version";
|
|
34
|
+
import { discoverOps, discoverStewards } from "../op/discover";
|
|
35
|
+
import { stewardBesideOf, type StewardForm } from "../op/steward";
|
|
36
|
+
import type { OpConfig } from "../op/types";
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
/** Bumped when the index's shape changes, so an older index is never read. */
|
|
41
|
+
const INDEX_FORMAT = 1;
|
|
42
|
+
|
|
43
|
+
/** Where the indexes of the checkout at `top` live, and the checkout's real path. */
|
|
44
|
+
function indexDir(top: string, env: NodeJS.ProcessEnv = process.env): { dir: string; checkout: string } {
|
|
45
|
+
const base = env.CHANT_CACHE_DIR || join(env.XDG_CACHE_HOME || join(homedir(), ".cache"), "chant");
|
|
46
|
+
let checkout = resolve(top);
|
|
47
|
+
try {
|
|
48
|
+
checkout = realpathSync(checkout);
|
|
49
|
+
} catch {
|
|
50
|
+
// Keyed on the path as given.
|
|
51
|
+
}
|
|
52
|
+
return { dir: join(base, "workspace-stewards", createHash("sha256").update(checkout).digest("hex").slice(0, 16)), checkout };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** How many indexes a checkout keeps: the newest, by when they were written. */
|
|
56
|
+
const KEPT = 32;
|
|
57
|
+
|
|
58
|
+
/** What status reads of an Op. */
|
|
59
|
+
export interface IndexedOp {
|
|
60
|
+
name: string;
|
|
61
|
+
schedule: { cron: string } | null;
|
|
62
|
+
/** Absent when the Op has none. */
|
|
63
|
+
labels?: Record<string, string>;
|
|
64
|
+
workLease: { kind: string | null } | null;
|
|
65
|
+
changesCheckout: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What status reads of a steward. */
|
|
69
|
+
export interface IndexedSteward {
|
|
70
|
+
name: string;
|
|
71
|
+
/** The `*.op.ts` file declaring it, absolute. */
|
|
72
|
+
filePath: string;
|
|
73
|
+
/** Every Op it runs: its turns' Ops, then those beside them. */
|
|
74
|
+
ops: IndexedOp[];
|
|
75
|
+
/** The Ops it runs beside its turns (#2861), and whether each declares a ready step. */
|
|
76
|
+
beside: { op: string; ready: boolean }[];
|
|
77
|
+
form: { default: StewardForm; environments: Record<string, StewardForm> };
|
|
78
|
+
capabilities: string[];
|
|
79
|
+
vault: string | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface StewardIndex {
|
|
83
|
+
/** In discovery's order. */
|
|
84
|
+
stewards: IndexedSteward[];
|
|
85
|
+
/** Files that could not be imported. */
|
|
86
|
+
errors: string[];
|
|
87
|
+
/** Stewards dropped as second writers. */
|
|
88
|
+
conflicts: string[];
|
|
89
|
+
/** The project's Ops that no steward lists, or null when they couldn't be discovered. */
|
|
90
|
+
otherOps: IndexedOp[] | null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function indexOp(op: OpConfig): IndexedOp {
|
|
94
|
+
return {
|
|
95
|
+
name: op.name,
|
|
96
|
+
schedule: op.schedule ? { cron: op.schedule.cron } : null,
|
|
97
|
+
...(op.labels ? { labels: { ...op.labels } } : {}),
|
|
98
|
+
workLease: op.workLease ? { kind: op.workLease.kind ?? null } : null,
|
|
99
|
+
changesCheckout: op.changesCheckout === true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Discover the stewards and Ops under `memberDir`, importing every Op file. Throws as `discoverStewards` does. */
|
|
104
|
+
export async function discoverStewardIndex(memberDir: string): Promise<StewardIndex> {
|
|
105
|
+
const { stewards, errors, conflicts } = await discoverStewards({ cwd: memberDir });
|
|
106
|
+
const listed = [...stewards.values()].map(({ declaration, filePath }) => ({
|
|
107
|
+
name: declaration.name,
|
|
108
|
+
filePath,
|
|
109
|
+
ops: declaration.ops.map(indexOp),
|
|
110
|
+
beside: stewardBesideOf(declaration).map((b) => ({ op: b.op, ready: b.ready !== null && b.ready !== undefined })),
|
|
111
|
+
form: { default: declaration.form.default, environments: { ...declaration.form.environments } },
|
|
112
|
+
capabilities: Array.isArray(declaration.capabilities) ? [...declaration.capabilities] : [],
|
|
113
|
+
vault: typeof declaration.vault === "string" ? declaration.vault : null,
|
|
114
|
+
}));
|
|
115
|
+
let otherOps: IndexedOp[] | null = null;
|
|
116
|
+
// Only a project with a steward has waits to look for among its other Ops.
|
|
117
|
+
if (listed.length > 0) {
|
|
118
|
+
const declared = new Set(listed.flatMap((s) => s.ops.map((op) => op.name)));
|
|
119
|
+
try {
|
|
120
|
+
otherOps = [...(await discoverOps({ cwd: memberDir })).ops.values()].map((d) => indexOp(d.config)).filter((op) => !declared.has(op.name));
|
|
121
|
+
} catch {
|
|
122
|
+
otherOps = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { stewards: listed, errors, conflicts, otherOps };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function git(args: string[], cwd: string): Promise<string> {
|
|
129
|
+
// A read: `git status` must not take the index lock from a writer.
|
|
130
|
+
const env = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
|
|
131
|
+
const { stdout } = await execFileAsync("git", args, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
|
|
132
|
+
return stdout;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The key of the tree `memberDir` is in: its `HEAD` tree, each changed or
|
|
137
|
+
* untracked file's path and contents, the chant version and `memberDir`.
|
|
138
|
+
* Null outside a checkout, or when git can't say.
|
|
139
|
+
*/
|
|
140
|
+
export async function treeKey(memberDir: string): Promise<{ key: string; dir: string; checkout: string } | null> {
|
|
141
|
+
try {
|
|
142
|
+
const top = (await git(["rev-parse", "--show-toplevel"], memberDir)).trim();
|
|
143
|
+
const head = (await git(["rev-parse", "--verify", "--quiet", "HEAD^{tree}"], top).catch(() => "")).trim();
|
|
144
|
+
const changed = (await git(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--no-renames"], top))
|
|
145
|
+
.split("\0")
|
|
146
|
+
.filter((e) => e.length > 3)
|
|
147
|
+
.map((e) => e.slice(3))
|
|
148
|
+
.sort();
|
|
149
|
+
const h = createHash("sha256");
|
|
150
|
+
h.update(`${INDEX_FORMAT}\0${CHANT_VERSION}\0${resolve(memberDir)}\0${head}\0`);
|
|
151
|
+
for (const path of changed) {
|
|
152
|
+
h.update(`${path}\0`);
|
|
153
|
+
try {
|
|
154
|
+
const full = join(top, path);
|
|
155
|
+
h.update(statSync(full).isFile() ? readFileSync(full) : "dir");
|
|
156
|
+
} catch {
|
|
157
|
+
h.update("gone");
|
|
158
|
+
}
|
|
159
|
+
h.update("\0");
|
|
160
|
+
}
|
|
161
|
+
return { key: h.digest("hex"), ...indexDir(top) };
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Remove all but the {@link KEPT} newest indexes of this checkout, and the
|
|
169
|
+
* indexes of checkouts that are gone (each directory names its checkout in
|
|
170
|
+
* its `checkout` file).
|
|
171
|
+
*/
|
|
172
|
+
function prune(dir: string): void {
|
|
173
|
+
const files = readdirSync(dir)
|
|
174
|
+
.filter((f) => f.endsWith(".json"))
|
|
175
|
+
.map((f) => ({ f, at: statSync(join(dir, f)).mtimeMs }))
|
|
176
|
+
.sort((a, b) => b.at - a.at);
|
|
177
|
+
for (const { f } of files.slice(KEPT)) rmSync(join(dir, f), { force: true });
|
|
178
|
+
const parent = dirname(dir);
|
|
179
|
+
for (const other of readdirSync(parent)) {
|
|
180
|
+
const at = join(parent, other);
|
|
181
|
+
if (at === dir) continue;
|
|
182
|
+
try {
|
|
183
|
+
if (!existsSync(readFileSync(join(at, "checkout"), "utf-8"))) rmSync(at, { recursive: true, force: true });
|
|
184
|
+
} catch {
|
|
185
|
+
// No checkout file, or being written: left alone.
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isIndex(v: unknown): v is StewardIndex {
|
|
191
|
+
const i = v as StewardIndex;
|
|
192
|
+
return !!i && Array.isArray(i.stewards) && Array.isArray(i.errors) && Array.isArray(i.conflicts) && (i.otherOps === null || Array.isArray(i.otherOps));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The stewards and Ops under `memberDir`: from the index for its tree when
|
|
197
|
+
* there is one, else discovered and indexed. Throws as `discoverStewards`
|
|
198
|
+
* does.
|
|
199
|
+
*/
|
|
200
|
+
export async function readStewardIndex(memberDir: string, deps: { discover?: typeof discoverStewardIndex } = {}): Promise<StewardIndex> {
|
|
201
|
+
const discover = deps.discover ?? discoverStewardIndex;
|
|
202
|
+
if (process.env.CHANT_STEWARD_INDEX === "0") return discover(memberDir);
|
|
203
|
+
const tree = await treeKey(memberDir);
|
|
204
|
+
if (!tree) return discover(memberDir);
|
|
205
|
+
const file = join(tree.dir, `${tree.key}.json`);
|
|
206
|
+
try {
|
|
207
|
+
const kept = JSON.parse(readFileSync(file, "utf-8")) as unknown;
|
|
208
|
+
if (isIndex(kept)) return kept;
|
|
209
|
+
} catch {
|
|
210
|
+
// None yet, or unreadable: discover.
|
|
211
|
+
}
|
|
212
|
+
const index = await discover(memberDir);
|
|
213
|
+
if (index.errors.length === 0) {
|
|
214
|
+
try {
|
|
215
|
+
mkdirSync(tree.dir, { recursive: true });
|
|
216
|
+
writeFileSync(join(tree.dir, "checkout"), tree.checkout);
|
|
217
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
218
|
+
writeFileSync(tmp, JSON.stringify(index));
|
|
219
|
+
renameSync(tmp, file);
|
|
220
|
+
prune(tree.dir);
|
|
221
|
+
} catch {
|
|
222
|
+
// A cache dir that can't be written: status still works, discovering each time.
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return index;
|
|
226
|
+
}
|
|
@@ -403,6 +403,23 @@ export function scopedKind(source: ScopeSource, kindName: string, kindFile: stri
|
|
|
403
403
|
return { name: kindName, declaredName: null, member: dir.startsWith("..") ? null : memberHolding(dir === "" ? "." : dir, decl.members), declared: false };
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The agent sessions as `chant workspace agent` and the write paths read
|
|
408
|
+
* them (#3615): the declaration at base, else the working tree's. Null when
|
|
409
|
+
* neither has a declaration. Lighter than {@link scopeSource}: no trust
|
|
410
|
+
* policy and no principal classes.
|
|
411
|
+
*/
|
|
412
|
+
export function declaredSessions(cwd: string): { agents: AgentDeclaration[]; from: "base" | "working-tree" } | null {
|
|
413
|
+
const top = gitRoot(cwd);
|
|
414
|
+
const base = top ? resolveBase(top) : null;
|
|
415
|
+
if (base?.commit) {
|
|
416
|
+
const atBase = readAt(cwd, base.commit);
|
|
417
|
+
if (atBase) return { agents: atBase.declaration.agents, from: "base" };
|
|
418
|
+
}
|
|
419
|
+
const here = readAt(cwd);
|
|
420
|
+
return here ? { agents: here.declaration.agents, from: "working-tree" } : null;
|
|
421
|
+
}
|
|
422
|
+
|
|
406
423
|
/**
|
|
407
424
|
* Refuse a record write outside the writer's scope (#2548): throws a
|
|
408
425
|
* {@link WriteScopeError}. `agent` is the session the write names
|