@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.
Files changed (59) hide show
  1. package/dist/cli/main.d.ts.map +1 -1
  2. package/dist/op/activities/factory.d.ts.map +1 -1
  3. package/dist/op/steward.d.ts +1 -1
  4. package/dist/op/steward.d.ts.map +1 -1
  5. package/dist/workspace/box-cli.d.ts +3 -2
  6. package/dist/workspace/box-cli.d.ts.map +1 -1
  7. package/dist/workspace/box-factory-write.d.ts +71 -0
  8. package/dist/workspace/box-factory-write.d.ts.map +1 -0
  9. package/dist/workspace/box-intent.d.ts +6 -0
  10. package/dist/workspace/box-intent.d.ts.map +1 -1
  11. package/dist/workspace/declaration-write-cli.d.ts +11 -0
  12. package/dist/workspace/declaration-write-cli.d.ts.map +1 -0
  13. package/dist/workspace/declaration-write.d.ts +78 -0
  14. package/dist/workspace/declaration-write.d.ts.map +1 -0
  15. package/dist/workspace/declaration.d.ts +8 -0
  16. package/dist/workspace/declaration.d.ts.map +1 -1
  17. package/dist/workspace/json-edit.d.ts +11 -0
  18. package/dist/workspace/json-edit.d.ts.map +1 -1
  19. package/dist/workspace/ls.d.ts +8 -0
  20. package/dist/workspace/ls.d.ts.map +1 -1
  21. package/dist/workspace/reason-codes.d.ts +4 -0
  22. package/dist/workspace/reason-codes.d.ts.map +1 -1
  23. package/dist/workspace/status-stewards.d.ts +4 -1
  24. package/dist/workspace/status-stewards.d.ts.map +1 -1
  25. package/dist/workspace/status.d.ts.map +1 -1
  26. package/dist/workspace/steward-index.d.ts +89 -0
  27. package/dist/workspace/steward-index.d.ts.map +1 -0
  28. package/dist/workspace/write-scope.d.ts +10 -0
  29. package/dist/workspace/write-scope.d.ts.map +1 -1
  30. package/package.json +1 -1
  31. package/src/cli/main.test.ts +2 -0
  32. package/src/cli/main.ts +18 -0
  33. package/src/op/activities/factory.ts +3 -1
  34. package/src/op/factory.e2e.test.ts +3 -1
  35. package/src/op/steward.ts +1 -1
  36. package/src/workspace/box-cli.ts +35 -4
  37. package/src/workspace/box-factory-write.schema.json +342 -0
  38. package/src/workspace/box-factory-write.test.ts +108 -0
  39. package/src/workspace/box-factory-write.ts +226 -0
  40. package/src/workspace/box-intent.test.ts +14 -4
  41. package/src/workspace/box-intent.ts +9 -1
  42. package/src/workspace/declaration-extensions.test.ts +79 -0
  43. package/src/workspace/declaration-write-cli.ts +82 -0
  44. package/src/workspace/declaration-write.test.ts +155 -0
  45. package/src/workspace/declaration-write.ts +275 -0
  46. package/src/workspace/declaration.ts +19 -1
  47. package/src/workspace/json-edit.ts +71 -0
  48. package/src/workspace/ls-contract.test.ts +53 -0
  49. package/src/workspace/ls.schema.json +11 -0
  50. package/src/workspace/ls.ts +18 -0
  51. package/src/workspace/member-write.schema.json +193 -0
  52. package/src/workspace/reason-codes.test.ts +4 -0
  53. package/src/workspace/reason-codes.ts +6 -0
  54. package/src/workspace/status-stewards.ts +23 -35
  55. package/src/workspace/status.schema.json +10 -1
  56. package/src/workspace/status.ts +25 -6
  57. package/src/workspace/steward-index.test.ts +171 -0
  58. package/src/workspace/steward-index.ts +226 -0
  59. 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