@visulima/task-runner 1.0.0-alpha.5 → 1.0.0-alpha.7

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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/archive.d.ts +38 -0
  3. package/dist/cache.d.ts +31 -3
  4. package/dist/chrome-trace.d.ts +53 -0
  5. package/dist/file-access-tracker.d.ts +7 -1
  6. package/dist/fingerprint.d.ts +9 -0
  7. package/dist/incremental-hasher.d.ts +18 -0
  8. package/dist/index.d.ts +8 -3
  9. package/dist/index.js +22 -19
  10. package/dist/life-cycle.d.ts +2 -0
  11. package/dist/log-reporter.d.ts +34 -0
  12. package/dist/output-resolver.d.ts +20 -0
  13. package/dist/packem_shared/{Cache-iAjRMV2d.js → Cache-CWaX_c8U.js} +135 -45
  14. package/dist/packem_shared/{CompositeLifeCycle-7AtYw1dv.js → CompositeLifeCycle-CSVbRC_5.js} +10 -0
  15. package/dist/packem_shared/{FileAccessTracker-CrtBAt5D.js → FileAccessTracker-CQ5Ot7Hd.js} +68 -16
  16. package/dist/packem_shared/{FingerprintManager-Cu-ta9ee.js → FingerprintManager-CV7U4f4f.js} +22 -1
  17. package/dist/packem_shared/{IncrementalFileHasher-Cm_kJY5V.js → IncrementalFileHasher-BRS76-mb.js} +26 -0
  18. package/dist/packem_shared/LogReporter-BDt52HLu.js +44 -0
  19. package/dist/packem_shared/{RemoteCache-BFceSe4a.js → RemoteCache-DSU3lc87.js} +77 -37
  20. package/dist/packem_shared/{TaskOrchestrator-lLn-PH1m.js → TaskOrchestrator-rf45vW5c.js} +94 -15
  21. package/dist/packem_shared/{TerminalBuffer-CnPyFgPB.js → TerminalBuffer-qVJvbRQZ.js} +1 -1
  22. package/dist/packem_shared/{TrackedTaskExecutor-BGUKFE-7.js → TrackedTaskExecutor-CFPpQfXF.js} +1 -1
  23. package/dist/packem_shared/archive-UQHAnZUa.js +102 -0
  24. package/dist/packem_shared/{buildForwardDependencyMap-0BJFMMPv.js → buildForwardDependencyMap-DLPgKEto.js} +2 -1
  25. package/dist/packem_shared/{computeTaskHash-B2SVZqgp.js → computeTaskHash-DYqfrDGq.js} +122 -6
  26. package/dist/packem_shared/{createTaskGraph-CcsFaSrz.js → createTaskGraph-B7nH0kY_.js} +2 -2
  27. package/dist/packem_shared/{defaultTaskRunner-BdFTifsh.js → defaultTaskRunner-Cp7jCmIl.js} +28 -6
  28. package/dist/packem_shared/{extractPackageName-CbVNW-dr.js → extractPackageName-BllKetnz.js} +2 -1
  29. package/dist/packem_shared/{generateRunSummary-qn-_jKwt.js → generateRunSummary-BE1jnQ3H.js} +19 -1
  30. package/dist/packem_shared/{parsePartition-C4-P5RjK.js → parsePartition-BfLbHGAx.js} +18 -0
  31. package/dist/packem_shared/{projectGraphToDot-C8uYeaPo.js → projectGraphToDot-DU1lSe-c.js} +1 -1
  32. package/dist/packem_shared/resolveOutputs-n6MCKoTe.js +111 -0
  33. package/dist/packem_shared/{runConcurrentFallback-CGHz_f-Q.js → runConcurrentFallback-BTmgGV1H.js} +1 -1
  34. package/dist/packem_shared/{runConcurrently-qrkWyzXW.js → runConcurrently-CmfC4r-f.js} +1 -1
  35. package/dist/packem_shared/toChromeTrace-B2tZoJ-7.js +121 -0
  36. package/dist/remote-cache.d.ts +45 -0
  37. package/dist/run-summary.d.ts +26 -4
  38. package/dist/task-hasher.d.ts +37 -0
  39. package/dist/task-orchestrator.d.ts +2 -2
  40. package/dist/types.d.ts +137 -3
  41. package/index.js +52 -52
  42. package/package.json +13 -13
@@ -22,17 +22,30 @@ const {
22
22
  mkdir,
23
23
  writeFile,
24
24
  rename,
25
- stat,
26
25
  rm,
27
- cp,
28
- readdir
26
+ readdir,
27
+ stat,
28
+ cp
29
29
  } = __cjs_getBuiltinModule("node:fs/promises");
30
30
  import { formatBytes, parseBytes } from '@visulima/humanizer';
31
31
  import { join, resolve, dirname } from '@visulima/path';
32
+ import { e as extractTarBrotli, c as createTarBrotli } from './archive-UQHAnZUa.js';
33
+ import { resolveOutputs } from './resolveOutputs-n6MCKoTe.js';
32
34
  import { u as uniqueId } from './utils-zO0ZRgtf.js';
33
35
 
34
36
  const DEFAULT_MAX_CACHE_AGE = 7 * 24 * 60 * 60 * 1e3;
35
37
  const DEFAULT_CACHE_DIRECTORY_NAME = ".task-runner-cache";
38
+ const assertSafeNamespace = (namespace) => {
39
+ if (namespace.includes("\0")) {
40
+ throw new Error("cacheNamespace: null bytes are not allowed.");
41
+ }
42
+ if (namespace.includes("/") || namespace.includes("\\")) {
43
+ throw new Error(`cacheNamespace: path separators are not allowed (received ${JSON.stringify(namespace)}).`);
44
+ }
45
+ if (namespace === "." || namespace === "..") {
46
+ throw new Error(`cacheNamespace: "${namespace}" would escape the cache subtree.`);
47
+ }
48
+ };
36
49
  const removeEntry = async (entryPath) => {
37
50
  try {
38
51
  await rm(entryPath, { force: true, recursive: true });
@@ -73,7 +86,11 @@ class Cache {
73
86
  #indexWriteQueue = Promise.resolve();
74
87
  constructor(options) {
75
88
  this.#workspaceRoot = options.workspaceRoot;
76
- this.#cacheDirectory = options.cacheDirectory ?? join(options.workspaceRoot, DEFAULT_CACHE_DIRECTORY_NAME);
89
+ const baseDirectory = options.cacheDirectory ?? join(options.workspaceRoot, DEFAULT_CACHE_DIRECTORY_NAME);
90
+ if (options.cacheNamespace !== void 0 && options.cacheNamespace.length > 0) {
91
+ assertSafeNamespace(options.cacheNamespace);
92
+ }
93
+ this.#cacheDirectory = options.cacheNamespace && options.cacheNamespace.length > 0 ? join(baseDirectory, "ns", options.cacheNamespace) : baseDirectory;
77
94
  this.#maxCacheAge = options.maxCacheAge ?? DEFAULT_MAX_CACHE_AGE;
78
95
  this.#maxCacheSize = options.maxCacheSize ? parseCacheSize(options.maxCacheSize) : void 0;
79
96
  }
@@ -117,8 +134,14 @@ class Cache {
117
134
  *
118
135
  * Uses atomic write: builds the entry in a temporary directory,
119
136
  * then renames into place to avoid partial reads by concurrent processes.
137
+ *
138
+ * `outputs` accepts the richer `OutputSpec[]` shape — glob
139
+ * patterns, negative globs, and `{ auto: true }` entries. Pass
140
+ * `autoWrites` alongside `{ auto: true }` so the resolver knows
141
+ * which files the task actually wrote; otherwise auto entries
142
+ * contribute nothing.
120
143
  */
121
- async put(hash, terminalOutput, outputs, code, fingerprint) {
144
+ async put(hash, terminalOutput, outputs, code, fingerprint, autoWrites) {
122
145
  const cacheEntryDirectory = join(this.#cacheDirectory, hash);
123
146
  const temporaryDirectory = join(this.#cacheDirectory, `.tmp-${hash}-${uniqueId()}`);
124
147
  try {
@@ -126,7 +149,7 @@ class Cache {
126
149
  const writes = [
127
150
  writeFile(join(temporaryDirectory, "code"), String(code)),
128
151
  writeFile(join(temporaryDirectory, "terminalOutput"), terminalOutput),
129
- this.#archiveOutputs(temporaryDirectory, outputs)
152
+ this.#archiveOutputs(temporaryDirectory, outputs, autoWrites)
130
153
  ];
131
154
  if (fingerprint) {
132
155
  writes.push(writeFile(join(temporaryDirectory, "fingerprint.json"), JSON.stringify(fingerprint)));
@@ -140,36 +163,21 @@ class Cache {
140
163
  }
141
164
  }
142
165
  /**
143
- * Restores cached outputs to their original locations.
166
+ * Restores cached outputs from the compressed `outputs.tar.br`
167
+ * archive. Returns `true` either when the archive was extracted
168
+ * successfully OR when the entry simply has no outputs to restore.
169
+ *
170
+ * The restore flow stages into a temp directory, then swaps each
171
+ * top-level entry into place (see {@link restoreOutputsCompressed})
172
+ * so a mid-restore failure never destroys the user's working tree.
173
+ * The `outputs` parameter is no longer consulted at restore time —
174
+ * the archive is authoritative, and top-level entries in the
175
+ * extracted staging become the set of swap roots. Still accepted
176
+ * for backward compat.
144
177
  */
145
- async restoreOutputs(hash, outputs) {
178
+ async restoreOutputs(hash, _outputs) {
146
179
  const cacheEntryDirectory = join(this.#cacheDirectory, hash);
147
- const outputsDirectory = join(cacheEntryDirectory, "outputs");
148
- try {
149
- await stat(outputsDirectory);
150
- } catch {
151
- return true;
152
- }
153
- try {
154
- await Promise.all(
155
- outputs.map(async (output) => {
156
- const absoluteOutput = resolve(this.#workspaceRoot, output);
157
- const cachedOutput = join(outputsDirectory, output);
158
- try {
159
- await stat(cachedOutput);
160
- } catch {
161
- return;
162
- }
163
- const parentDirectory = dirname(absoluteOutput);
164
- await mkdir(parentDirectory, { recursive: true });
165
- await rm(absoluteOutput, { force: true, recursive: true });
166
- await cp(cachedOutput, absoluteOutput, { recursive: true });
167
- })
168
- );
169
- return true;
170
- } catch {
171
- return false;
172
- }
180
+ return restoreOutputsCompressed(cacheEntryDirectory, this.#workspaceRoot);
173
181
  }
174
182
  /**
175
183
  * Retrieves the most recent cached result for a task by its ID.
@@ -276,23 +284,105 @@ class Cache {
276
284
  }
277
285
  }
278
286
  /**
279
- * Archives task output files into the cache.
287
+ * Archives task output files into the cache as a single
288
+ * brotli-compressed tarball (`outputs.tar.br`). See
289
+ * {@link archiveOutputsCompressed} for the staging + compression
290
+ * flow.
280
291
  */
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);
292
+ async #archiveOutputs(cacheEntryDirectory, outputs, autoWrites) {
293
+ await archiveOutputsCompressed(this.#workspaceRoot, cacheEntryDirectory, outputs, autoWrites);
294
+ }
295
+ }
296
+ const OUTPUTS_ARCHIVE_FILENAME = "outputs.tar.br";
297
+ const archiveOutputsCompressed = async (workspaceRoot, cacheEntryDirectory, outputs, autoWrites) => {
298
+ if (outputs.length === 0) {
299
+ return;
300
+ }
301
+ const resolvedPaths = await resolveOutputs(workspaceRoot, outputs, autoWrites);
302
+ if (resolvedPaths.length === 0) {
303
+ return;
304
+ }
305
+ const stagingDirectory = join(cacheEntryDirectory, `.outputs-stage-${uniqueId()}`);
306
+ const finalPath = join(cacheEntryDirectory, OUTPUTS_ARCHIVE_FILENAME);
307
+ try {
308
+ await mkdir(stagingDirectory, { recursive: true });
309
+ const stagedFlags = await runBounded(STAGE_CONCURRENCY, resolvedPaths, async (relativePath) => {
310
+ const absoluteOutput = resolve(workspaceRoot, relativePath);
311
+ const stagedOutput = join(stagingDirectory, relativePath);
312
+ try {
313
+ await mkdir(dirname(stagedOutput), { recursive: true });
314
+ await cp(absoluteOutput, stagedOutput, { recursive: true });
315
+ return true;
316
+ } catch {
317
+ return false;
318
+ }
319
+ });
320
+ const stagedCount = stagedFlags.filter(Boolean).length;
321
+ if (stagedCount === 0) {
322
+ return;
323
+ }
324
+ await createTarBrotli(stagingDirectory, finalPath);
325
+ } finally {
326
+ await rm(stagingDirectory, { force: true, recursive: true }).catch(() => {
327
+ });
328
+ }
329
+ };
330
+ const STAGE_CONCURRENCY = 16;
331
+ const runBounded = async (limit, items, fn) => {
332
+ const results = Array.from({ length: items.length });
333
+ let cursor = 0;
334
+ const worker = async () => {
335
+ while (cursor < items.length) {
336
+ const index = cursor;
337
+ cursor += 1;
338
+ results[index] = await fn(items[index], index);
339
+ }
340
+ };
341
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
342
+ await Promise.all(workers);
343
+ return results;
344
+ };
345
+ const restoreOutputsCompressed = async (cacheEntryDirectory, workspaceRoot) => {
346
+ const archivePath = join(cacheEntryDirectory, OUTPUTS_ARCHIVE_FILENAME);
347
+ try {
348
+ await stat(archivePath);
349
+ } catch {
350
+ return true;
351
+ }
352
+ const stagingDirectory = join(cacheEntryDirectory, `.restore-${uniqueId()}`);
353
+ const backups = [];
354
+ try {
355
+ await mkdir(stagingDirectory, { recursive: true });
356
+ await extractTarBrotli(archivePath, stagingDirectory);
357
+ const topLevel = await readdir(stagingDirectory);
358
+ await runBounded(STAGE_CONCURRENCY, topLevel, async (entry) => {
359
+ const absoluteOutput = resolve(workspaceRoot, entry);
360
+ const stagedOutput = join(stagingDirectory, entry);
361
+ await mkdir(dirname(absoluteOutput), { recursive: true });
287
362
  try {
288
363
  await stat(absoluteOutput);
289
- const cachedOutputParent = join(cachedOutput, "..");
290
- await mkdir(cachedOutputParent, { recursive: true });
291
- await cp(absoluteOutput, cachedOutput, { recursive: true });
364
+ const backup = `${absoluteOutput}.pre-restore-${uniqueId()}`;
365
+ await rename(absoluteOutput, backup);
366
+ backups.push({ backup, original: absoluteOutput });
292
367
  } catch {
293
368
  }
369
+ await cp(stagedOutput, absoluteOutput, { recursive: true });
370
+ });
371
+ await Promise.all(backups.map(({ backup }) => rm(backup, { force: true, recursive: true }).catch(() => {
372
+ })));
373
+ return true;
374
+ } catch {
375
+ for (const { backup, original } of backups) {
376
+ await rm(original, { force: true, recursive: true }).catch(() => {
377
+ });
378
+ await rename(backup, original).catch(() => {
379
+ });
294
380
  }
381
+ return false;
382
+ } finally {
383
+ await rm(stagingDirectory, { force: true, recursive: true }).catch(() => {
384
+ });
295
385
  }
296
- }
386
+ };
297
387
 
298
388
  export { Cache, DEFAULT_CACHE_DIRECTORY_NAME, formatCacheSize, parseCacheSize };
@@ -59,6 +59,16 @@ class CompositeLifeCycle {
59
59
  lc.printCacheMiss?.(task, reasons);
60
60
  }
61
61
  }
62
+ onTaskStdout(task, chunk) {
63
+ for (const lc of this.#lifeCycles) {
64
+ lc.onTaskStdout?.(task, chunk);
65
+ }
66
+ }
67
+ onTaskStderr(task, chunk) {
68
+ for (const lc of this.#lifeCycles) {
69
+ lc.onTaskStderr?.(task, chunk);
70
+ }
71
+ }
62
72
  }
63
73
  class ConsoleLifeCycle {
64
74
  #verbose;
@@ -47,11 +47,19 @@ const isStraceAvailable = () => {
47
47
  return straceAvailable;
48
48
  };
49
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" }
50
+ { kind: "open", pattern: /openat\(AT_FDCWD,\s*"([^"]+)"/ },
51
+ { kind: "open", pattern: /^(?:\d+\s+)?open\("([^"]+)"/ },
52
+ { kind: "creat", pattern: /(?:^|\s)creat\("([^"]+)"/ },
53
+ { kind: "stat", pattern: /(?:stat|lstat|newfstatat)\((?:AT_FDCWD,\s*)?"([^"]+)"/ },
54
+ { kind: "stat", pattern: /access\("([^"]+)"/ },
55
+ { kind: "getdents", pattern: /getdents(?:64)?\(\d+/ }
54
56
  ];
57
+ const parseOpenAccessType = (line) => {
58
+ if (/O_WRONLY|O_RDWR|O_CREAT|O_TRUNC|O_APPEND/.test(line)) {
59
+ return "write";
60
+ }
61
+ return "read";
62
+ };
55
63
  class FileAccessTracker {
56
64
  #workspaceRoot;
57
65
  #excludePatterns;
@@ -93,7 +101,7 @@ class FileAccessTracker {
93
101
  const traceDirectory = join(this.#workspaceRoot, "node_modules", ".cache", "task-runner");
94
102
  await mkdir(traceDirectory, { recursive: true });
95
103
  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}`;
104
+ const straceCommand = `strace -f -qq -e trace=open,openat,creat,stat,lstat,newfstatat,access,getdents,getdents64,unlink,unlinkat,rename,renameat,renameat2 -o ${traceFile} -- ${command}`;
97
105
  return new Promise((_resolve) => {
98
106
  const child = exec(
99
107
  straceCommand,
@@ -125,14 +133,23 @@ class FileAccessTracker {
125
133
  }
126
134
  /**
127
135
  * Parses strace output to extract file accesses.
136
+ *
137
+ * A single path may appear with both `read` and `write` types when a
138
+ * task reads a file and later rewrites it — self-modifying detection
139
+ * relies on seeing both, so we dedupe per `(path, type)` rather than
140
+ * by path alone.
128
141
  */
129
142
  #parseStraceOutput(traceContent, cwd) {
130
143
  const accesses = [];
131
- const seenPaths = /* @__PURE__ */ new Set();
144
+ const seen = /* @__PURE__ */ new Set();
132
145
  for (const line of traceContent.split("\n")) {
133
146
  const parsed = this.#parseStraceLine(line, cwd);
134
- if (parsed && !seenPaths.has(parsed.path)) {
135
- seenPaths.add(parsed.path);
147
+ if (!parsed) {
148
+ continue;
149
+ }
150
+ const key = `${parsed.type}:${parsed.path}`;
151
+ if (!seen.has(key)) {
152
+ seen.add(key);
136
153
  accesses.push(parsed);
137
154
  }
138
155
  }
@@ -140,23 +157,46 @@ class FileAccessTracker {
140
157
  }
141
158
  /**
142
159
  * Parses a single strace output line.
143
- * Each entry maps a regex to the file access type it represents.
160
+ * Each entry maps a regex to the file access kind; the actual type
161
+ * (read vs write) depends on flag inspection for `open`/`openat`.
144
162
  */
145
163
  #parseStraceLine(line, cwd) {
146
164
  const isMissing = line.includes("ENOENT");
147
- for (const { pattern, type } of STRACE_PATTERNS) {
165
+ for (const { kind, pattern } of STRACE_PATTERNS) {
148
166
  const match = pattern.exec(line);
149
- if (!match?.[1]) {
167
+ if (!match) {
150
168
  continue;
151
169
  }
152
- let path = match[1];
170
+ if (kind === "getdents") {
171
+ return void 0;
172
+ }
173
+ const capturedPath = match[1];
174
+ if (!capturedPath) {
175
+ continue;
176
+ }
177
+ let path = capturedPath;
153
178
  if (!path.startsWith("/")) {
154
179
  path = resolve(cwd, path);
155
180
  }
156
181
  if (this.#shouldExclude(path) || !path.startsWith(this.#workspaceRoot)) {
157
182
  return void 0;
158
183
  }
159
- return { path, type: isMissing ? "missing" : type };
184
+ if (isMissing) {
185
+ return { path, type: "missing" };
186
+ }
187
+ const type = kind === "open" ? parseOpenAccessType(line) : kind === "creat" ? "write" : "stat";
188
+ return { path, type };
189
+ }
190
+ const destructive = /(?:^|\s)(?:unlink|unlinkat|rename|renameat|renameat2)\((?:AT_FDCWD,\s*)?"([^"]+)"/.exec(line);
191
+ if (destructive?.[1]) {
192
+ let path = destructive[1];
193
+ if (!path.startsWith("/")) {
194
+ path = resolve(cwd, path);
195
+ }
196
+ if (this.#shouldExclude(path) || !path.startsWith(this.#workspaceRoot)) {
197
+ return void 0;
198
+ }
199
+ return { path, type: "write" };
160
200
  }
161
201
  return void 0;
162
202
  }
@@ -220,19 +260,31 @@ const patch = (obj, method, type) => {
220
260
  };
221
261
  };
222
262
 
223
- // Sync
263
+ // Reads / stats / directory listing
224
264
  patch(fs, "readFileSync", "read");
225
265
  patch(fs, "statSync", "stat");
226
266
  patch(fs, "readdirSync", "readdir");
227
- // Async (callback)
228
267
  patch(fs, "readFile", "read");
229
268
  patch(fs, "stat", "stat");
230
269
  patch(fs, "readdir", "readdir");
231
- // Async (promises)
232
270
  patch(fsp, "readFile", "read");
233
271
  patch(fsp, "stat", "stat");
234
272
  patch(fsp, "readdir", "readdir");
235
273
 
274
+ // Writes and path-mutating ops — needed for self-modifying task detection
275
+ patch(fs, "writeFileSync", "write");
276
+ patch(fs, "appendFileSync", "write");
277
+ patch(fs, "unlinkSync", "write");
278
+ patch(fs, "renameSync", "write");
279
+ patch(fs, "writeFile", "write");
280
+ patch(fs, "appendFile", "write");
281
+ patch(fs, "unlink", "write");
282
+ patch(fs, "rename", "write");
283
+ patch(fsp, "writeFile", "write");
284
+ patch(fsp, "appendFile", "write");
285
+ patch(fsp, "unlink", "write");
286
+ patch(fsp, "rename", "write");
287
+
236
288
  process.on("beforeExit", () => { logStream.end(); });
237
289
  `;
238
290
 
@@ -34,6 +34,8 @@ class FingerprintManager {
34
34
  const fileHashes = {};
35
35
  const missingPaths = /* @__PURE__ */ new Set();
36
36
  const directoryListings = {};
37
+ const readPaths = /* @__PURE__ */ new Set();
38
+ const writePaths = /* @__PURE__ */ new Set();
37
39
  for (const access of accesses) {
38
40
  const relativePath = relative(this.#workspaceRoot, access.path);
39
41
  switch (access.type) {
@@ -43,6 +45,7 @@ class FingerprintManager {
43
45
  }
44
46
  case "read":
45
47
  case "stat": {
48
+ readPaths.add(relativePath);
46
49
  if (!fileHashes[relativePath]) {
47
50
  const hash = await this.#hashFileWithCache(access.path);
48
51
  if (hash) {
@@ -62,8 +65,19 @@ class FingerprintManager {
62
65
  }
63
66
  break;
64
67
  }
68
+ case "write": {
69
+ writePaths.add(relativePath);
70
+ break;
71
+ }
72
+ }
73
+ }
74
+ const modifiedInputs = [];
75
+ for (const path of writePaths) {
76
+ if (readPaths.has(path)) {
77
+ modifiedInputs.push(path);
65
78
  }
66
79
  }
80
+ modifiedInputs.sort();
67
81
  const commandHash = hashStrings(`${command}:${JSON.stringify(sortObjectKeys(args))}`);
68
82
  const envHashes = {};
69
83
  const matchedEnvVariables = FingerprintManager.#resolveEnvPatterns(envPatterns, envVariables);
@@ -75,7 +89,14 @@ class FingerprintManager {
75
89
  envHashes[key] = hashStrings(`${key}=${value ?? ""}`);
76
90
  }
77
91
  const missingFiles = [...missingPaths].toSorted();
78
- return { commandHash, directoryListings, envHashes, fileHashes, missingFiles };
92
+ return {
93
+ commandHash,
94
+ directoryListings,
95
+ envHashes,
96
+ fileHashes,
97
+ missingFiles,
98
+ ...modifiedInputs.length > 0 ? { modifiedInputs } : {}
99
+ };
79
100
  }
80
101
  /**
81
102
  * Validates a stored fingerprint against the current state.
@@ -146,6 +146,32 @@ class IncrementalFileHasher {
146
146
  clear() {
147
147
  this.#snapshot.clear();
148
148
  }
149
+ /**
150
+ * Looks up the cached hash for `absolutePath` when its mtime and
151
+ * size match the snapshot; returns `undefined` otherwise. Callers
152
+ * that already have a `stat` result (e.g. `InProcessTaskHasher`)
153
+ * skip an extra syscall by passing it through directly.
154
+ *
155
+ * The snapshot is considered loaded on first call — lazy-load is
156
+ * synchronous-friendly here because the caller already performed
157
+ * an async `stat` before calling this method.
158
+ */
159
+ getSnapshotHash(absolutePath, mtimeMs, size) {
160
+ const entry = this.#snapshot.get(absolutePath);
161
+ if (!entry) {
162
+ return void 0;
163
+ }
164
+ return entry.mtimeMs === mtimeMs && entry.size === size ? entry.hash : void 0;
165
+ }
166
+ /**
167
+ * Writes a fresh snapshot entry after the caller has computed the
168
+ * hash. Pairs with {@link IncrementalFileHasher.getSnapshotHash}
169
+ * — after a miss, the caller hashes the file and records the
170
+ * result here so the next run can reuse it.
171
+ */
172
+ recordSnapshot(absolutePath, hash, mtimeMs, size) {
173
+ this.#snapshot.set(absolutePath, { hash, mtimeMs, size });
174
+ }
149
175
  }
150
176
 
151
177
  export { IncrementalFileHasher };
@@ -0,0 +1,44 @@
1
+ const formatTaskLabel = (task) => `${task.target.project}#${task.target.target}`;
2
+ const prefixLines = (text, label) => {
3
+ const trailing = text.endsWith("\n") ? "\n" : "";
4
+ const body = trailing ? text.slice(0, -1) : text;
5
+ if (body.length === 0) {
6
+ return trailing;
7
+ }
8
+ return `${body.split("\n").map((line) => `[${label}] ${line}`).join("\n")}${trailing}`;
9
+ };
10
+ class LogReporter {
11
+ #mode;
12
+ #write;
13
+ constructor(mode, write = (chunk) => process.stdout.write(chunk)) {
14
+ this.#mode = mode;
15
+ this.#write = write;
16
+ }
17
+ printTaskTerminalOutput(task, _status, terminalOutput) {
18
+ if (terminalOutput.length === 0) {
19
+ return;
20
+ }
21
+ if (this.#mode === "interleaved") {
22
+ this.#write(terminalOutput.endsWith("\n") ? terminalOutput : `${terminalOutput}
23
+ `);
24
+ return;
25
+ }
26
+ const label = formatTaskLabel(task);
27
+ if (this.#mode === "labeled") {
28
+ this.#write(prefixLines(terminalOutput.endsWith("\n") ? terminalOutput : `${terminalOutput}
29
+ `, label));
30
+ return;
31
+ }
32
+ const body = terminalOutput.endsWith("\n") ? terminalOutput : `${terminalOutput}
33
+ `;
34
+ this.#write(`── ${label} ──
35
+ ${body}
36
+ `);
37
+ }
38
+ // eslint-disable-next-line class-methods-use-this
39
+ endTasks(_taskResults) {
40
+ }
41
+ }
42
+ const createLogReporter = (mode, write) => new LogReporter(mode, write);
43
+
44
+ export { LogReporter, createLogReporter };