@procedures/lib 0.3.0 → 0.4.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.
@@ -1,281 +0,0 @@
1
- /**
2
- * dag.traverse procedure
3
- *
4
- * General-purpose DAG traversal that executes a visit procedure
5
- * for each node in dependency order.
6
- *
7
- * This enables declarative composition of traversals:
8
- * ```typescript
9
- * // Execute git add on all packages (using $when: "$parent")
10
- * await client.exec({
11
- * $proc: ["dag", "traverse"],
12
- * input: {
13
- * visit: {
14
- * $proc: ["git", "add"],
15
- * input: { all: true },
16
- * $when: "$parent" // Defer to dag.traverse
17
- * }
18
- * }
19
- * });
20
- *
21
- * // Simple form with just procedure path
22
- * await client.exec({
23
- * $proc: ["dag", "traverse"],
24
- * input: {
25
- * visit: ["git", "add"]
26
- * }
27
- * });
28
- * ```
29
- *
30
- * The `$when` field controls when nested $proc refs are executed:
31
- * - "$immediate" (default): Execute during hydration
32
- * - "$parent": Defer to parent procedure (dag.traverse executes per-node)
33
- * - "$never": Never auto-execute, pass as pure data
34
- */
35
-
36
- import { existsSync } from "node:fs";
37
- import { dirname, join, resolve } from "node:path";
38
- import type { ProcedureContext, ProcedurePath } from "@procedures/client";
39
- import { isAnyProcedureRef, hydrateInput, scopeOf } from "@procedures/client";
40
- import type {
41
- DAGNode,
42
- DagTraverseInput,
43
- DagTraverseOutput,
44
- TraverseNodeResult,
45
- } from "../../types.js";
46
- import { libScan } from "../lib/scan.js";
47
- import {
48
- buildDAGNodes,
49
- buildLeveledDAG,
50
- executeDAG,
51
- createProcessor,
52
- filterDAGFromRoot,
53
- } from "../../dag/index.js";
54
-
55
- /** The folder of the git repository that holds `path` (it has `.git`), or `path` itself. */
56
- export function repositoryRoot(path: string): string {
57
- let current = resolve(path);
58
- for (;;) {
59
- if (existsSync(join(current, ".git"))) return current;
60
- const parent = dirname(current);
61
- if (parent === current) return resolve(path);
62
- current = parent;
63
- }
64
- }
65
-
66
- /** True when the value names a `git` procedure: a path, or a `$proc` ref at any depth. */
67
- export function callsGit(value: unknown): boolean {
68
- if (Array.isArray(value)) {
69
- if (value[0] === "git" && value.every((part) => typeof part === "string")) return true;
70
- return value.some(callsGit);
71
- }
72
- if (typeof value === "object" && value !== null) {
73
- return Object.values(value).some(callsGit);
74
- }
75
- return false;
76
- }
77
-
78
- /**
79
- * Run the tasks of one key one at a time, in the order of the calls. Tasks of other keys run
80
- * in parallel.
81
- */
82
- export function keyedLock(): <T>(key: string, task: () => Promise<T>) => Promise<T> {
83
- const tails = new Map<string, Promise<void>>();
84
- return async <T>(key: string, task: () => Promise<T>): Promise<T> => {
85
- const previous = tails.get(key) ?? Promise.resolve();
86
- let release!: () => void;
87
- const done = new Promise<void>((resolveDone) => {
88
- release = resolveDone;
89
- });
90
- const tail = previous.then(() => done);
91
- tails.set(key, tail);
92
- await previous;
93
- try {
94
- return await task();
95
- } finally {
96
- release();
97
- if (tails.get(key) === tail) tails.delete(key);
98
- }
99
- };
100
- }
101
-
102
- /**
103
- * Execute a DAG traversal with a custom visit procedure
104
- *
105
- * The visits of nodes in one git repository run one at a time when the visit calls git (or
106
- * with `serialize: "repository"`). All the packages of a workspace share one repository, and
107
- * concurrent `git add` calls failed on `index.lock` (deep dive DATA-17).
108
- */
109
- export async function dagTraverse(
110
- input: DagTraverseInput,
111
- ctx: ProcedureContext
112
- ): Promise<DagTraverseOutput> {
113
- const startTime = Date.now();
114
- const results: TraverseNodeResult[] = [];
115
-
116
- // Scan for all packages
117
- const scanResult = await libScan(input.rootPath !== undefined ? { rootPath: input.rootPath } : {}, ctx);
118
- let allNodes = buildDAGNodes(scanResult.packages);
119
-
120
- // Filter to specific packages if requested
121
- if (input.filter && input.filter.length > 0) {
122
- const filtered = new Map<string, DAGNode>();
123
- for (const name of input.filter) {
124
- const node = allNodes.get(name);
125
- if (node) {
126
- filtered.set(name, node);
127
- }
128
- }
129
- allNodes = filtered;
130
- }
131
-
132
- // Filter to root and its dependencies if requested
133
- if (input.root) {
134
- const rootNode = allNodes.get(input.root);
135
- if (!rootNode) {
136
- return {
137
- success: false,
138
- results: [],
139
- totalDuration: Date.now() - startTime,
140
- visited: 0,
141
- failed: 0,
142
- };
143
- }
144
- allNodes = filterDAGFromRoot(allNodes, input.root);
145
- }
146
-
147
- // Build DAG for dependency-ordered execution
148
- const dag = buildLeveledDAG(allNodes);
149
-
150
- // Determine how to execute the visit procedure
151
- // visit can be:
152
- // - Array: procedure path like ["git", "add"]
153
- // - $proc ref: { $proc: [...], input: {...}, $when: "$parent" }
154
- const visitIsRef = isAnyProcedureRef(input.visit);
155
- let visitPath: ProcedurePath;
156
- let baseInput: unknown = {};
157
-
158
- if (Array.isArray(input.visit)) {
159
- visitPath = input.visit;
160
- } else if (visitIsRef && typeof input.visit === "object" && "$proc" in input.visit) {
161
- visitPath = input.visit.$proc;
162
- baseInput = input.visit.input ?? {};
163
- } else {
164
- throw new Error("visit must be a procedure path or $proc reference with $when");
165
- }
166
-
167
- const serialize = input.serialize ?? "auto";
168
- const oneAtATime = serialize === "repository" || (serialize === "auto" && callsGit(input.visit));
169
- const lock = keyedLock();
170
-
171
- // Create processor that executes visit procedure per node
172
- const processor = createProcessor(async (node: DAGNode) => {
173
- if (oneAtATime) {
174
- await lock(repositoryRoot(node.repoPath), () => visit(node));
175
- } else {
176
- await visit(node);
177
- }
178
- });
179
-
180
- async function visit(node: DAGNode): Promise<void> {
181
- if (input.dryRun) {
182
- results.push({
183
- name: node.name,
184
- path: node.repoPath,
185
- success: true,
186
- duration: 0,
187
- output: { dryRun: true, wouldExecute: visitPath },
188
- });
189
- return;
190
- }
191
-
192
- const nodeStartTime = Date.now();
193
-
194
- try {
195
- // Merge base input with node context
196
- const visitInput = {
197
- ...(typeof baseInput === "object" ? baseInput : {}),
198
- cwd: node.repoPath,
199
- node: {
200
- name: node.name,
201
- path: node.repoPath,
202
- dependencies: node.dependencies,
203
- },
204
- };
205
-
206
- // Create executor for hydration that injects cwd into every call
207
- const executor = async <TIn, TOut>(path: ProcedurePath, inp: TIn): Promise<TOut> => {
208
- // Inject cwd into the input so nested procedures run in correct directory
209
- const inputWithCwd = typeof inp === "object" && inp !== null
210
- ? { ...inp, cwd: node.repoPath }
211
- : inp;
212
- return ctx.client.call(path, inputWithCwd) as Promise<TOut>;
213
- };
214
-
215
- // Hydrate the input (execute any nested procedure refs like chain steps)
216
- // Push "dag.traverse" onto context stack so refs with $when: "dag.traverse" execute
217
- const hydratedInput = await hydrateInput(visitInput, executor, {
218
- contextStack: ["dag.traverse"],
219
- onRef: ctx.onRef,
220
- // The names of the chain around this procedure: a `$ref` in the visit can read them
221
- scope: scopeOf(input),
222
- });
223
-
224
- // Execute visit procedure with hydrated input
225
- const output = await ctx.client.call(visitPath, hydratedInput);
226
-
227
- results.push({
228
- name: node.name,
229
- path: node.repoPath,
230
- success: true,
231
- duration: Date.now() - nodeStartTime,
232
- output,
233
- });
234
- } catch (error) {
235
- const result: TraverseNodeResult = {
236
- name: node.name,
237
- path: node.repoPath,
238
- success: false,
239
- duration: Date.now() - nodeStartTime,
240
- error: error instanceof Error ? error.message : String(error),
241
- };
242
- results.push(result);
243
- throw error; // Re-throw for DAG executor to handle
244
- }
245
- }
246
-
247
- // Execute DAG traversal
248
- const dagResult = await executeDAG(dag, processor, {
249
- concurrency: input.concurrency ?? 4,
250
- failFast: !input.continueOnError,
251
- });
252
-
253
- // If dry run, we already populated results in the processor
254
- if (!input.dryRun) {
255
- // Ensure all nodes have results (some may have been skipped due to errors)
256
- for (const [name, nodeResult] of dagResult.results) {
257
- const existing = results.find((r) => r.name === name);
258
- if (!existing) {
259
- const node = allNodes.get(name);
260
- const result: TraverseNodeResult = {
261
- name,
262
- path: node?.repoPath ?? "unknown",
263
- success: nodeResult.success,
264
- duration: nodeResult.duration,
265
- };
266
- if (nodeResult.error?.message) {
267
- result.error = nodeResult.error.message;
268
- }
269
- results.push(result);
270
- }
271
- }
272
- }
273
-
274
- return {
275
- success: dagResult.success,
276
- results,
277
- totalDuration: Date.now() - startTime,
278
- visited: results.filter((r) => r.success).length,
279
- failed: results.filter((r) => !r.success).length,
280
- };
281
- }