@visulima/task-runner 0.0.1 → 1.0.0-alpha.2
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/CHANGELOG.md +21 -0
- package/LICENSE.md +21 -0
- package/README.md +170 -36
- package/binding.js +204 -0
- package/dist/affected.d.ts +48 -0
- package/dist/cache.d.ts +103 -0
- package/dist/default-task-runner.d.ts +44 -0
- package/dist/file-access-tracker.d.ts +53 -0
- package/dist/fingerprint.d.ts +45 -0
- package/dist/framework-inference.d.ts +35 -0
- package/dist/graph-visualizer.d.ts +74 -0
- package/dist/incremental-hasher.d.ts +58 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +20 -0
- package/dist/life-cycle.d.ts +36 -0
- package/dist/lockfile-hasher.d.ts +73 -0
- package/dist/native-binding.d.ts +64 -0
- package/dist/packem_shared/Cache-IYpTYVUC.js +298 -0
- package/dist/packem_shared/CompositeLifeCycle-7AtYw1dv.js +112 -0
- package/dist/packem_shared/FileAccessTracker-CrtBAt5D.js +239 -0
- package/dist/packem_shared/FingerprintManager-D6Y0erg-.js +227 -0
- package/dist/packem_shared/IncrementalFileHasher-Ds3J6dgb.js +151 -0
- package/dist/packem_shared/RemoteCache-BDqrnDEi.js +179 -0
- package/dist/packem_shared/TaskOrchestrator-BvYs3ONw.js +342 -0
- package/dist/packem_shared/TaskScheduler-CJilHDta.js +111 -0
- package/dist/packem_shared/TrackedTaskExecutor-BGUKFE-7.js +164 -0
- package/dist/packem_shared/collectFiles-ClXHnHhg.js +22 -0
- package/dist/packem_shared/computeTaskHash-BoCnnvIJ.js +356 -0
- package/dist/packem_shared/createTaskGraph-CcsFaSrz.js +164 -0
- package/dist/packem_shared/defaultTaskRunner-CrW4v5Ye.js +79 -0
- package/dist/packem_shared/detectFrameworks-CeFzKE6J.js +101 -0
- package/dist/packem_shared/extractPackageName-CbVNW-dr.js +189 -0
- package/dist/packem_shared/filterAffectedTasks-I-18zPg6.js +135 -0
- package/dist/packem_shared/findCycle-DF4_BRdO.js +212 -0
- package/dist/packem_shared/generateRunSummary-qn-_jKwt.js +134 -0
- package/dist/packem_shared/isNativeAvailable-BWhnZ4ES.js +19 -0
- package/dist/packem_shared/projectGraphToDot-VdTjHcVp.js +202 -0
- package/dist/packem_shared/utils-zO0ZRgtf.js +390 -0
- package/dist/remote-cache.d.ts +55 -0
- package/dist/run-summary.d.ts +89 -0
- package/dist/task-graph-utils.d.ts +39 -0
- package/dist/task-graph.d.ts +22 -0
- package/dist/task-hasher.d.ts +67 -0
- package/dist/task-orchestrator.d.ts +38 -0
- package/dist/task-scheduler.d.ts +18 -0
- package/dist/tracked-executor.d.ts +46 -0
- package/dist/types.d.ts +385 -0
- package/dist/utils.d.ts +39 -0
- package/package.json +72 -7
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { createRequire as __cjs_createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const __cjs_require = __cjs_createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
const __cjs_getProcess = typeof globalThis !== "undefined" && typeof globalThis.process !== "undefined" ? globalThis.process : process;
|
|
6
|
+
|
|
7
|
+
const __cjs_getBuiltinModule = (module) => {
|
|
8
|
+
// Check if we're in Node.js and version supports getBuiltinModule
|
|
9
|
+
if (typeof __cjs_getProcess !== "undefined" && __cjs_getProcess.versions && __cjs_getProcess.versions.node) {
|
|
10
|
+
const [major, minor] = __cjs_getProcess.versions.node.split(".").map(Number);
|
|
11
|
+
// Node.js 20.16.0+ and 22.3.0+
|
|
12
|
+
if (major > 22 || (major === 22 && minor >= 3) || (major === 20 && minor >= 16)) {
|
|
13
|
+
return __cjs_getProcess.getBuiltinModule(module);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Fallback to createRequire
|
|
17
|
+
return __cjs_require(module);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
readFile,
|
|
22
|
+
mkdir,
|
|
23
|
+
writeFile,
|
|
24
|
+
rename,
|
|
25
|
+
stat,
|
|
26
|
+
rm,
|
|
27
|
+
cp,
|
|
28
|
+
readdir
|
|
29
|
+
} = __cjs_getBuiltinModule("node:fs/promises");
|
|
30
|
+
import { join, resolve, dirname } from '@visulima/path';
|
|
31
|
+
import { formatBytes, parseBytes } from '@visulima/humanizer';
|
|
32
|
+
import { u as uniqueId } from './utils-zO0ZRgtf.js';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1e3;
|
|
35
|
+
const removeEntry = async (entryPath) => {
|
|
36
|
+
try {
|
|
37
|
+
await rm(entryPath, { force: true, recursive: true });
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const getDirectorySize = async (directoryPath) => {
|
|
42
|
+
let totalSize = 0;
|
|
43
|
+
try {
|
|
44
|
+
const entries = await readdir(directoryPath, { withFileTypes: true });
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const fullPath = join(directoryPath, entry.name);
|
|
47
|
+
if (entry.isDirectory()) {
|
|
48
|
+
totalSize += await getDirectorySize(fullPath);
|
|
49
|
+
} else if (entry.isFile()) {
|
|
50
|
+
const fileStat = await stat(fullPath);
|
|
51
|
+
totalSize += fileStat.size;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
}
|
|
56
|
+
return totalSize;
|
|
57
|
+
};
|
|
58
|
+
const parseCacheSize = (sizeString) => {
|
|
59
|
+
const result = parseBytes(sizeString.trim());
|
|
60
|
+
if (Number.isNaN(result)) {
|
|
61
|
+
throw new Error(`Invalid cache size format: "${sizeString}". Expected format like "500MB" or "1GB".`);
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
};
|
|
65
|
+
const formatCacheSize = (bytes) => formatBytes(bytes, { decimals: 1, space: false });
|
|
66
|
+
class Cache {
|
|
67
|
+
#workspaceRoot;
|
|
68
|
+
#cacheDirectory;
|
|
69
|
+
#maxCacheAge;
|
|
70
|
+
#maxCacheSize;
|
|
71
|
+
/** Serializes concurrent setTaskIndex writes to prevent lost updates */
|
|
72
|
+
#indexWriteQueue = Promise.resolve();
|
|
73
|
+
constructor(options) {
|
|
74
|
+
this.#workspaceRoot = options.workspaceRoot;
|
|
75
|
+
this.#cacheDirectory = options.cacheDirectory ?? join(options.workspaceRoot, ".task-runner-cache");
|
|
76
|
+
this.#maxCacheAge = options.maxCacheAge ?? DEFAULT_MAX_CACHE_AGE;
|
|
77
|
+
this.#maxCacheSize = options.maxCacheSize ? parseCacheSize(options.maxCacheSize) : void 0;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Gets the cache directory path.
|
|
81
|
+
*/
|
|
82
|
+
get cacheDirectory() {
|
|
83
|
+
return this.#cacheDirectory;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Retrieves a cached result for the given task hash.
|
|
87
|
+
* Returns undefined if no valid cache entry exists.
|
|
88
|
+
*/
|
|
89
|
+
async get(hash) {
|
|
90
|
+
const cacheEntryDirectory = join(this.#cacheDirectory, hash);
|
|
91
|
+
try {
|
|
92
|
+
await readFile(join(cacheEntryDirectory, ".commit"));
|
|
93
|
+
const codeString = await readFile(join(cacheEntryDirectory, "code"), "utf8");
|
|
94
|
+
const code = Number.parseInt(codeString.trim(), 10);
|
|
95
|
+
if (Number.isNaN(code)) {
|
|
96
|
+
return void 0;
|
|
97
|
+
}
|
|
98
|
+
let terminalOutput = "";
|
|
99
|
+
try {
|
|
100
|
+
terminalOutput = await readFile(join(cacheEntryDirectory, "terminalOutput"), "utf8");
|
|
101
|
+
} catch {
|
|
102
|
+
}
|
|
103
|
+
let fingerprint;
|
|
104
|
+
try {
|
|
105
|
+
const fingerprintContent = await readFile(join(cacheEntryDirectory, "fingerprint.json"), "utf8");
|
|
106
|
+
fingerprint = JSON.parse(fingerprintContent);
|
|
107
|
+
} catch {
|
|
108
|
+
}
|
|
109
|
+
return { code, fingerprint, hash, terminalOutput };
|
|
110
|
+
} catch {
|
|
111
|
+
return void 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Stores a task result in the cache.
|
|
116
|
+
*
|
|
117
|
+
* Uses atomic write: builds the entry in a temporary directory,
|
|
118
|
+
* then renames into place to avoid partial reads by concurrent processes.
|
|
119
|
+
*/
|
|
120
|
+
async put(hash, terminalOutput, outputs, code, fingerprint) {
|
|
121
|
+
const cacheEntryDirectory = join(this.#cacheDirectory, hash);
|
|
122
|
+
const temporaryDirectory = join(this.#cacheDirectory, `.tmp-${hash}-${uniqueId()}`);
|
|
123
|
+
try {
|
|
124
|
+
await mkdir(temporaryDirectory, { recursive: true });
|
|
125
|
+
const writes = [
|
|
126
|
+
writeFile(join(temporaryDirectory, "code"), String(code)),
|
|
127
|
+
writeFile(join(temporaryDirectory, "terminalOutput"), terminalOutput),
|
|
128
|
+
this.#archiveOutputs(temporaryDirectory, outputs)
|
|
129
|
+
];
|
|
130
|
+
if (fingerprint) {
|
|
131
|
+
writes.push(writeFile(join(temporaryDirectory, "fingerprint.json"), JSON.stringify(fingerprint)));
|
|
132
|
+
}
|
|
133
|
+
await Promise.all(writes);
|
|
134
|
+
await writeFile(join(temporaryDirectory, ".commit"), "");
|
|
135
|
+
await removeEntry(cacheEntryDirectory);
|
|
136
|
+
await rename(temporaryDirectory, cacheEntryDirectory);
|
|
137
|
+
} catch {
|
|
138
|
+
await removeEntry(temporaryDirectory);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Restores cached outputs to their original locations.
|
|
143
|
+
*/
|
|
144
|
+
async restoreOutputs(hash, outputs) {
|
|
145
|
+
const cacheEntryDirectory = join(this.#cacheDirectory, hash);
|
|
146
|
+
const outputsDirectory = join(cacheEntryDirectory, "outputs");
|
|
147
|
+
try {
|
|
148
|
+
await stat(outputsDirectory);
|
|
149
|
+
} catch {
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await Promise.all(
|
|
154
|
+
outputs.map(async (output) => {
|
|
155
|
+
const absoluteOutput = resolve(this.#workspaceRoot, output);
|
|
156
|
+
const cachedOutput = join(outputsDirectory, output);
|
|
157
|
+
try {
|
|
158
|
+
await stat(cachedOutput);
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const parentDirectory = dirname(absoluteOutput);
|
|
163
|
+
await mkdir(parentDirectory, { recursive: true });
|
|
164
|
+
await rm(absoluteOutput, { force: true, recursive: true });
|
|
165
|
+
await cp(cachedOutput, absoluteOutput, { recursive: true });
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
return true;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Retrieves the most recent cached result for a task by its ID.
|
|
175
|
+
* Used in auto-fingerprint mode where the hash is derived from
|
|
176
|
+
* tracked file accesses rather than computed upfront.
|
|
177
|
+
*/
|
|
178
|
+
async getByTaskId(taskId) {
|
|
179
|
+
const indexFile = join(this.#cacheDirectory, ".task-index.json");
|
|
180
|
+
try {
|
|
181
|
+
const indexContent = await readFile(indexFile, "utf8");
|
|
182
|
+
const index = JSON.parse(indexContent);
|
|
183
|
+
const hash = index[taskId];
|
|
184
|
+
if (!hash) {
|
|
185
|
+
return void 0;
|
|
186
|
+
}
|
|
187
|
+
return this.get(hash);
|
|
188
|
+
} catch {
|
|
189
|
+
return void 0;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Stores the mapping from task ID to cache hash.
|
|
194
|
+
* Uses a write queue to serialize concurrent writes and prevent lost updates.
|
|
195
|
+
* Each write is atomic (temp file + rename).
|
|
196
|
+
*/
|
|
197
|
+
async setTaskIndex(taskId, hash) {
|
|
198
|
+
this.#indexWriteQueue = this.#indexWriteQueue.then(() => this.#writeTaskIndex(taskId, hash)).catch(() => {
|
|
199
|
+
});
|
|
200
|
+
return this.#indexWriteQueue;
|
|
201
|
+
}
|
|
202
|
+
async #writeTaskIndex(taskId, hash) {
|
|
203
|
+
const indexFile = join(this.#cacheDirectory, ".task-index.json");
|
|
204
|
+
const temporaryFile = join(this.#cacheDirectory, `.task-index-${uniqueId()}.tmp`);
|
|
205
|
+
let index = {};
|
|
206
|
+
try {
|
|
207
|
+
const indexContent = await readFile(indexFile, "utf8");
|
|
208
|
+
index = JSON.parse(indexContent);
|
|
209
|
+
} catch {
|
|
210
|
+
}
|
|
211
|
+
index[taskId] = hash;
|
|
212
|
+
await mkdir(this.#cacheDirectory, { recursive: true });
|
|
213
|
+
await writeFile(temporaryFile, JSON.stringify(index));
|
|
214
|
+
try {
|
|
215
|
+
await rename(temporaryFile, indexFile);
|
|
216
|
+
} catch {
|
|
217
|
+
await writeFile(indexFile, JSON.stringify(index));
|
|
218
|
+
await rm(temporaryFile, { force: true });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Removes old cache entries that exceed the maximum age,
|
|
223
|
+
* and enforces the maximum cache size by evicting oldest entries.
|
|
224
|
+
*/
|
|
225
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity
|
|
226
|
+
async removeOldEntries() {
|
|
227
|
+
try {
|
|
228
|
+
const entries = await readdir(this.#cacheDirectory);
|
|
229
|
+
const now = Date.now();
|
|
230
|
+
const entryInfos = [];
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (entry.startsWith(".")) {
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const entryPath = join(this.#cacheDirectory, entry);
|
|
236
|
+
try {
|
|
237
|
+
const entryStat = await stat(entryPath);
|
|
238
|
+
if (now - entryStat.mtimeMs > this.#maxCacheAge) {
|
|
239
|
+
await removeEntry(entryPath);
|
|
240
|
+
} else {
|
|
241
|
+
const size = await getDirectorySize(entryPath);
|
|
242
|
+
entryInfos.push({
|
|
243
|
+
mtimeMs: entryStat.mtimeMs,
|
|
244
|
+
name: entry,
|
|
245
|
+
path: entryPath,
|
|
246
|
+
size
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (this.#maxCacheSize !== void 0) {
|
|
253
|
+
const sortedEntries = entryInfos.toSorted((a, b) => a.mtimeMs - b.mtimeMs);
|
|
254
|
+
let totalSize = 0;
|
|
255
|
+
for (const info of sortedEntries) {
|
|
256
|
+
totalSize += info.size;
|
|
257
|
+
}
|
|
258
|
+
for (const info of sortedEntries) {
|
|
259
|
+
if (totalSize <= this.#maxCacheSize) {
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
await removeEntry(info.path);
|
|
263
|
+
totalSize -= info.size;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Clears the entire cache.
|
|
271
|
+
*/
|
|
272
|
+
async clear() {
|
|
273
|
+
try {
|
|
274
|
+
await rm(this.#cacheDirectory, { force: true, recursive: true });
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Archives task output files into the cache.
|
|
280
|
+
*/
|
|
281
|
+
async #archiveOutputs(cacheEntryDirectory, outputs) {
|
|
282
|
+
const outputsDirectory = join(cacheEntryDirectory, "outputs");
|
|
283
|
+
await mkdir(outputsDirectory, { recursive: true });
|
|
284
|
+
for (const output of outputs) {
|
|
285
|
+
const absoluteOutput = resolve(this.#workspaceRoot, output);
|
|
286
|
+
const cachedOutput = join(outputsDirectory, output);
|
|
287
|
+
try {
|
|
288
|
+
await stat(absoluteOutput);
|
|
289
|
+
const cachedOutputParent = join(cachedOutput, "..");
|
|
290
|
+
await mkdir(cachedOutputParent, { recursive: true });
|
|
291
|
+
await cp(absoluteOutput, cachedOutput, { recursive: true });
|
|
292
|
+
} catch {
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export { Cache, formatCacheSize, parseCacheSize };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
const getStatusIcon = (status) => {
|
|
2
|
+
switch (status) {
|
|
3
|
+
case "failure": {
|
|
4
|
+
return "[failure]";
|
|
5
|
+
}
|
|
6
|
+
case "local-cache":
|
|
7
|
+
case "local-cache-kept-existing":
|
|
8
|
+
case "remote-cache": {
|
|
9
|
+
return "[cache]";
|
|
10
|
+
}
|
|
11
|
+
case "skipped": {
|
|
12
|
+
return "[skipped]";
|
|
13
|
+
}
|
|
14
|
+
case "success": {
|
|
15
|
+
return "[success]";
|
|
16
|
+
}
|
|
17
|
+
default: {
|
|
18
|
+
return "[unknown]";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
class CompositeLifeCycle {
|
|
23
|
+
#lifeCycles;
|
|
24
|
+
constructor(lifeCycles) {
|
|
25
|
+
this.#lifeCycles = lifeCycles;
|
|
26
|
+
}
|
|
27
|
+
startCommand() {
|
|
28
|
+
for (const lc of this.#lifeCycles) {
|
|
29
|
+
lc.startCommand?.();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
endCommand() {
|
|
33
|
+
for (const lc of this.#lifeCycles) {
|
|
34
|
+
lc.endCommand?.();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
scheduleTask(task) {
|
|
38
|
+
for (const lc of this.#lifeCycles) {
|
|
39
|
+
lc.scheduleTask?.(task);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
startTasks(tasks) {
|
|
43
|
+
for (const lc of this.#lifeCycles) {
|
|
44
|
+
lc.startTasks?.(tasks);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
endTasks(taskResults) {
|
|
48
|
+
for (const lc of this.#lifeCycles) {
|
|
49
|
+
lc.endTasks?.(taskResults);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
printTaskTerminalOutput(task, status, terminalOutput) {
|
|
53
|
+
for (const lc of this.#lifeCycles) {
|
|
54
|
+
lc.printTaskTerminalOutput?.(task, status, terminalOutput);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
printCacheMiss(task, reasons) {
|
|
58
|
+
for (const lc of this.#lifeCycles) {
|
|
59
|
+
lc.printCacheMiss?.(task, reasons);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
class ConsoleLifeCycle {
|
|
64
|
+
#verbose;
|
|
65
|
+
constructor(verbose = false) {
|
|
66
|
+
this.#verbose = verbose;
|
|
67
|
+
}
|
|
68
|
+
startCommand() {
|
|
69
|
+
if (this.#verbose) {
|
|
70
|
+
console.log("[task-runner] Starting command execution");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
endCommand() {
|
|
74
|
+
if (this.#verbose) {
|
|
75
|
+
console.log("[task-runner] Command execution complete");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
scheduleTask(task) {
|
|
79
|
+
if (this.#verbose) {
|
|
80
|
+
console.log(`[task-runner] Scheduled: ${task.id}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// eslint-disable-next-line class-methods-use-this
|
|
84
|
+
startTasks(tasks) {
|
|
85
|
+
for (const task of tasks) {
|
|
86
|
+
console.log(`> ${task.id}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// eslint-disable-next-line class-methods-use-this
|
|
90
|
+
endTasks(taskResults) {
|
|
91
|
+
for (const result of taskResults) {
|
|
92
|
+
const duration = result.startTime && result.endTime ? ` (${result.endTime - result.startTime}ms)` : "";
|
|
93
|
+
const statusIcon = getStatusIcon(result.status);
|
|
94
|
+
console.log(`${statusIcon} ${result.task.id}${duration}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// eslint-disable-next-line class-methods-use-this
|
|
98
|
+
printTaskTerminalOutput(_task, _status, terminalOutput) {
|
|
99
|
+
if (terminalOutput.trim()) {
|
|
100
|
+
console.log(terminalOutput);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
printCacheMiss(task, reasons) {
|
|
104
|
+
if (this.#verbose) {
|
|
105
|
+
console.log(`[task-runner] ${task.id}: ${reasons}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
class EmptyLifeCycle {
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export { CompositeLifeCycle, ConsoleLifeCycle, EmptyLifeCycle };
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { createRequire as __cjs_createRequire } from "node:module";
|
|
2
|
+
|
|
3
|
+
const __cjs_require = __cjs_createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
const __cjs_getProcess = typeof globalThis !== "undefined" && typeof globalThis.process !== "undefined" ? globalThis.process : process;
|
|
6
|
+
|
|
7
|
+
const __cjs_getBuiltinModule = (module) => {
|
|
8
|
+
// Check if we're in Node.js and version supports getBuiltinModule
|
|
9
|
+
if (typeof __cjs_getProcess !== "undefined" && __cjs_getProcess.versions && __cjs_getProcess.versions.node) {
|
|
10
|
+
const [major, minor] = __cjs_getProcess.versions.node.split(".").map(Number);
|
|
11
|
+
// Node.js 20.16.0+ and 22.3.0+
|
|
12
|
+
if (major > 22 || (major === 22 && minor >= 3) || (major === 20 && minor >= 16)) {
|
|
13
|
+
return __cjs_getProcess.getBuiltinModule(module);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// Fallback to createRequire
|
|
17
|
+
return __cjs_require(module);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
exec,
|
|
22
|
+
execFileSync
|
|
23
|
+
} = __cjs_getBuiltinModule("node:child_process");
|
|
24
|
+
const {
|
|
25
|
+
mkdir,
|
|
26
|
+
readFile,
|
|
27
|
+
rm
|
|
28
|
+
} = __cjs_getBuiltinModule("node:fs/promises");
|
|
29
|
+
const {
|
|
30
|
+
platform
|
|
31
|
+
} = __cjs_getBuiltinModule("node:os");
|
|
32
|
+
import { resolve, join } from '@visulima/path';
|
|
33
|
+
import { u as uniqueId } from './utils-zO0ZRgtf.js';
|
|
34
|
+
|
|
35
|
+
import __cjs_mod__ from "node:module"; // -- packem CommonJS require shim --
|
|
36
|
+
const require = __cjs_mod__.createRequire(import.meta.url);
|
|
37
|
+
let straceAvailable;
|
|
38
|
+
const isStraceAvailable = () => {
|
|
39
|
+
if (straceAvailable === void 0) {
|
|
40
|
+
try {
|
|
41
|
+
execFileSync("strace", ["-V"], { stdio: "ignore" });
|
|
42
|
+
straceAvailable = true;
|
|
43
|
+
} catch {
|
|
44
|
+
straceAvailable = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return straceAvailable;
|
|
48
|
+
};
|
|
49
|
+
const STRACE_PATTERNS = [
|
|
50
|
+
{ pattern: /openat\(AT_FDCWD,\s*"([^"]+)"/, type: "read" },
|
|
51
|
+
{ pattern: /^(?:\d+\s+)?open\("([^"]+)"/, type: "read" },
|
|
52
|
+
{ pattern: /(?:stat|lstat|newfstatat)\((?:AT_FDCWD,\s*)?"([^"]+)"/, type: "stat" },
|
|
53
|
+
{ pattern: /access\("([^"]+)"/, type: "stat" }
|
|
54
|
+
];
|
|
55
|
+
class FileAccessTracker {
|
|
56
|
+
#workspaceRoot;
|
|
57
|
+
#excludePatterns;
|
|
58
|
+
/** Tracks active child processes for cleanup on abort */
|
|
59
|
+
#activeProcesses = /* @__PURE__ */ new Set();
|
|
60
|
+
constructor(workspaceRoot, excludePatterns) {
|
|
61
|
+
this.#workspaceRoot = resolve(workspaceRoot);
|
|
62
|
+
this.#excludePatterns = excludePatterns ?? [
|
|
63
|
+
/\/proc\//,
|
|
64
|
+
/\/sys\//,
|
|
65
|
+
/\/dev\//,
|
|
66
|
+
/\/tmp\//,
|
|
67
|
+
/\/etc\//,
|
|
68
|
+
/\.so(\.\d+)*$/,
|
|
69
|
+
/node_modules\/.package-lock\.json$/
|
|
70
|
+
];
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Returns true if file access tracking is supported on the current platform.
|
|
74
|
+
*/
|
|
75
|
+
// eslint-disable-next-line class-methods-use-this
|
|
76
|
+
isSupported() {
|
|
77
|
+
return platform() === "linux" && isStraceAvailable();
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Runs a command and tracks all file system accesses.
|
|
81
|
+
* On unsupported platforms, runs the command without tracking.
|
|
82
|
+
*/
|
|
83
|
+
async track(command, options = {}) {
|
|
84
|
+
if (!this.isSupported()) {
|
|
85
|
+
return this.#runWithoutTracking(command, options);
|
|
86
|
+
}
|
|
87
|
+
return this.#runWithStrace(command, options);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Runs a command wrapped with strace to capture file accesses.
|
|
91
|
+
*/
|
|
92
|
+
async #runWithStrace(command, options) {
|
|
93
|
+
const traceDirectory = join(this.#workspaceRoot, "node_modules", ".cache", "task-runner");
|
|
94
|
+
await mkdir(traceDirectory, { recursive: true });
|
|
95
|
+
const traceFile = join(traceDirectory, `strace-${uniqueId()}.log`);
|
|
96
|
+
const straceCommand = `strace -f -qq -e trace=open,openat,stat,lstat,newfstatat,access,getdents,getdents64 -o ${traceFile} -- ${command}`;
|
|
97
|
+
return new Promise((_resolve) => {
|
|
98
|
+
const child = exec(
|
|
99
|
+
straceCommand,
|
|
100
|
+
{
|
|
101
|
+
cwd: options.cwd ?? this.#workspaceRoot,
|
|
102
|
+
env: { ...process.env, ...options.env },
|
|
103
|
+
maxBuffer: 50 * 1024 * 1024
|
|
104
|
+
// 50MB
|
|
105
|
+
},
|
|
106
|
+
async (_error, stdout, stderr) => {
|
|
107
|
+
this.#activeProcesses.delete(child);
|
|
108
|
+
let accesses = [];
|
|
109
|
+
try {
|
|
110
|
+
const traceContent = await readFile(traceFile, "utf8");
|
|
111
|
+
accesses = this.#parseStraceOutput(traceContent, options.cwd ?? this.#workspaceRoot);
|
|
112
|
+
} catch {
|
|
113
|
+
}
|
|
114
|
+
await rm(traceFile, { force: true }).catch(() => {
|
|
115
|
+
});
|
|
116
|
+
_resolve({
|
|
117
|
+
accesses,
|
|
118
|
+
code: child.exitCode ?? 1,
|
|
119
|
+
output: stdout + stderr
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
this.#activeProcesses.add(child);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Parses strace output to extract file accesses.
|
|
128
|
+
*/
|
|
129
|
+
#parseStraceOutput(traceContent, cwd) {
|
|
130
|
+
const accesses = [];
|
|
131
|
+
const seenPaths = /* @__PURE__ */ new Set();
|
|
132
|
+
for (const line of traceContent.split("\n")) {
|
|
133
|
+
const parsed = this.#parseStraceLine(line, cwd);
|
|
134
|
+
if (parsed && !seenPaths.has(parsed.path)) {
|
|
135
|
+
seenPaths.add(parsed.path);
|
|
136
|
+
accesses.push(parsed);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return accesses;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parses a single strace output line.
|
|
143
|
+
* Each entry maps a regex to the file access type it represents.
|
|
144
|
+
*/
|
|
145
|
+
#parseStraceLine(line, cwd) {
|
|
146
|
+
const isMissing = line.includes("ENOENT");
|
|
147
|
+
for (const { pattern, type } of STRACE_PATTERNS) {
|
|
148
|
+
const match = pattern.exec(line);
|
|
149
|
+
if (!match?.[1]) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
let path = match[1];
|
|
153
|
+
if (!path.startsWith("/")) {
|
|
154
|
+
path = resolve(cwd, path);
|
|
155
|
+
}
|
|
156
|
+
if (this.#shouldExclude(path) || !path.startsWith(this.#workspaceRoot)) {
|
|
157
|
+
return void 0;
|
|
158
|
+
}
|
|
159
|
+
return { path, type: isMissing ? "missing" : type };
|
|
160
|
+
}
|
|
161
|
+
return void 0;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Checks if a path should be excluded from tracking.
|
|
165
|
+
*/
|
|
166
|
+
#shouldExclude(filePath) {
|
|
167
|
+
return this.#excludePatterns.some((pattern) => pattern.test(filePath));
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Kills all active child processes. Called on abort/signal to prevent orphans.
|
|
171
|
+
*/
|
|
172
|
+
killAll() {
|
|
173
|
+
for (const child of this.#activeProcesses) {
|
|
174
|
+
try {
|
|
175
|
+
child.kill("SIGTERM");
|
|
176
|
+
} catch {
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
this.#activeProcesses.clear();
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Runs a command without file access tracking.
|
|
183
|
+
*/
|
|
184
|
+
async #runWithoutTracking(command, options) {
|
|
185
|
+
return new Promise((_resolve) => {
|
|
186
|
+
const child = exec(
|
|
187
|
+
command,
|
|
188
|
+
{
|
|
189
|
+
cwd: options.cwd ?? this.#workspaceRoot,
|
|
190
|
+
env: { ...process.env, ...options.env },
|
|
191
|
+
maxBuffer: 50 * 1024 * 1024
|
|
192
|
+
},
|
|
193
|
+
(_error, stdout, stderr) => {
|
|
194
|
+
this.#activeProcesses.delete(child);
|
|
195
|
+
_resolve({
|
|
196
|
+
accesses: [],
|
|
197
|
+
code: child.exitCode ?? 1,
|
|
198
|
+
output: stdout + stderr
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
this.#activeProcesses.add(child);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const generatePreloadScript = (outputPath) => String.raw`
|
|
207
|
+
import { createWriteStream } from "node:fs";
|
|
208
|
+
|
|
209
|
+
const fs = require("node:fs");
|
|
210
|
+
const fsp = require("node:fs/promises");
|
|
211
|
+
const logStream = createWriteStream(${JSON.stringify(outputPath)}, { flags: "a" });
|
|
212
|
+
const log = (type, path) => { logStream.write(JSON.stringify({ type, path }) + "\n"); };
|
|
213
|
+
|
|
214
|
+
// Patch each fs method: save original, replace with logged wrapper
|
|
215
|
+
const patch = (obj, method, type) => {
|
|
216
|
+
const orig = obj[method];
|
|
217
|
+
obj[method] = function(...args) {
|
|
218
|
+
log(type, args[0]?.toString());
|
|
219
|
+
return orig.apply(this, args);
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Sync
|
|
224
|
+
patch(fs, "readFileSync", "read");
|
|
225
|
+
patch(fs, "statSync", "stat");
|
|
226
|
+
patch(fs, "readdirSync", "readdir");
|
|
227
|
+
// Async (callback)
|
|
228
|
+
patch(fs, "readFile", "read");
|
|
229
|
+
patch(fs, "stat", "stat");
|
|
230
|
+
patch(fs, "readdir", "readdir");
|
|
231
|
+
// Async (promises)
|
|
232
|
+
patch(fsp, "readFile", "read");
|
|
233
|
+
patch(fsp, "stat", "stat");
|
|
234
|
+
patch(fsp, "readdir", "readdir");
|
|
235
|
+
|
|
236
|
+
process.on("beforeExit", () => { logStream.end(); });
|
|
237
|
+
`;
|
|
238
|
+
|
|
239
|
+
export { FileAccessTracker, generatePreloadScript };
|