@vzn/vx 0.0.630 → 0.0.632
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/README.md +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +1 -1
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +65 -0
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/workspace/workspace.ts +1 -0
|
@@ -41,7 +41,12 @@ import {
|
|
|
41
41
|
maskCaptured,
|
|
42
42
|
BoundedCapture,
|
|
43
43
|
} from '../exec/index.js'
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
encodeOutputLog,
|
|
46
|
+
FAILED_OUTPUT_HEAD_CHARS,
|
|
47
|
+
FAILED_OUTPUT_TAIL_CHARS,
|
|
48
|
+
failedOutputLog,
|
|
49
|
+
} from './output-log.js'
|
|
45
50
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
46
51
|
import {
|
|
47
52
|
killGraceMs,
|
|
@@ -260,9 +265,87 @@ export interface ExecuteArgs {
|
|
|
260
265
|
export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
261
266
|
if (isGroupTask(args.node)) return executeGroupTask(args)
|
|
262
267
|
if (args.node.config.exec?.persistent !== undefined) return executePersistentTask(args)
|
|
268
|
+
// A probed hit needs none of the run path's set-up: the upstream folding,
|
|
269
|
+
// timers and closures `executeCachedTask` builds on entry were ~15 µs of
|
|
270
|
+
// each of a warm run's hits (X-193). The same branch there handles it
|
|
271
|
+
// when this one does not apply.
|
|
272
|
+
const probed = args.preProbed
|
|
273
|
+
if (probed?.hit != null && readsCache(args) && args.remoteOnlyNoop !== true) {
|
|
274
|
+
return restoreProbed(
|
|
275
|
+
args,
|
|
276
|
+
probed.hash,
|
|
277
|
+
probed.hit,
|
|
278
|
+
process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
263
281
|
return executeCachedTask(args)
|
|
264
282
|
}
|
|
265
283
|
|
|
284
|
+
/** Whether this task reads the cache: a cached, not remote-only task with a read axis on. */
|
|
285
|
+
function readsCache(args: ExecuteArgs): boolean {
|
|
286
|
+
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
287
|
+
return (
|
|
288
|
+
args.remoteOnly !== true &&
|
|
289
|
+
args.node.config.cache !== undefined &&
|
|
290
|
+
(policy.localRead || policy.remoteRead)
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Restore the up-front probe's hit, or demote it. A key taken before a task
|
|
296
|
+
* rewrote the lockfile names an install the tree no longer holds, and the
|
|
297
|
+
* lazy path never probes one (X-124): demoted like a vanished hit, it runs
|
|
298
|
+
* dep-gated. The hit may be restoring AHEAD of this task's deps, and a
|
|
299
|
+
* command run now would build from outputs they have not written yet and
|
|
300
|
+
* save that under the good key: the scheduler runs it again once they are
|
|
301
|
+
* done (admission drops the probe).
|
|
302
|
+
*/
|
|
303
|
+
async function restoreProbed(
|
|
304
|
+
args: ExecuteArgs,
|
|
305
|
+
hash: string,
|
|
306
|
+
hit: CacheEntry,
|
|
307
|
+
taskStartNs: bigint,
|
|
308
|
+
): Promise<TaskOutcome> {
|
|
309
|
+
const cacheOpStart = performance.now()
|
|
310
|
+
const restored = fingerprintMoved(args)
|
|
311
|
+
? null
|
|
312
|
+
: await restoreOrMiss(args, hash, hit, cacheOpStart, taskStartNs)
|
|
313
|
+
if (restored !== null) return restored
|
|
314
|
+
throw new RestoreDemoted(args.node.id)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A hit restored, or null when its artifact was removed after the probe (a
|
|
319
|
+
* prune, another workspace's retention on a shared cache directory): a
|
|
320
|
+
* miss. The restore has wiped the declared outputs by then; the run path
|
|
321
|
+
* cleans them again and the task writes them.
|
|
322
|
+
*/
|
|
323
|
+
async function restoreOrMiss(
|
|
324
|
+
args: ExecuteArgs,
|
|
325
|
+
hash: string,
|
|
326
|
+
hit: CacheEntry,
|
|
327
|
+
cacheOpStart: number,
|
|
328
|
+
taskStartNs: bigint,
|
|
329
|
+
): Promise<TaskOutcome | null> {
|
|
330
|
+
try {
|
|
331
|
+
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
332
|
+
} catch (err) {
|
|
333
|
+
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
334
|
+
args.log.status(`[vx] ${args.node.id}: ${err.message} — running it`)
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* The workspace fingerprint every key here folded has moved (a task
|
|
341
|
+
* rewrote the lockfile), said once per run.
|
|
342
|
+
*/
|
|
343
|
+
function fingerprintMoved(args: ExecuteArgs): boolean {
|
|
344
|
+
if (args.fingerprintWatch?.moved() === undefined) return false
|
|
345
|
+
args.fingerprintWatch.say(args.log)
|
|
346
|
+
return true
|
|
347
|
+
}
|
|
348
|
+
|
|
266
349
|
/**
|
|
267
350
|
* Group task: no `exec`. The scheduler has already ensured every
|
|
268
351
|
* dependency completed; we just return success with a hash rolled up
|
|
@@ -270,35 +353,65 @@ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
270
353
|
* through us.
|
|
271
354
|
*/
|
|
272
355
|
async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
356
|
+
if (args.node.config.cache === undefined) {
|
|
357
|
+
return unkeyedGroupOutcome(
|
|
358
|
+
args.node,
|
|
359
|
+
args.upstream,
|
|
360
|
+
args.upfrontGroupKey,
|
|
361
|
+
args.runStartHrTimeNs,
|
|
362
|
+
)
|
|
363
|
+
}
|
|
273
364
|
const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
365
|
+
const hash = await computeTaskHash({
|
|
366
|
+
node: args.node,
|
|
367
|
+
upstream: args.upstream,
|
|
368
|
+
workspaceRoot: args.workspaceRoot,
|
|
369
|
+
workspaceFingerprint: args.workspaceFingerprint,
|
|
370
|
+
cache: args.cache,
|
|
371
|
+
forwardArgs: args.forwardArgs,
|
|
372
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
373
|
+
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
374
|
+
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
375
|
+
})
|
|
376
|
+
return groupOutcome(args.node, args.upstream, hash, wallclockNs)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* An unkeyed group's outcome (no `cache`, no key of its own): its key is
|
|
381
|
+
* its upstream's alone (`computeGroupKey`), so the up-front one stands when
|
|
382
|
+
* that upstream is unchanged. Synchronous: the scheduler settles such a
|
|
383
|
+
* group in place (`ScheduleOptions.settleNow`), and `upstream` is the key's
|
|
384
|
+
* (`keyUpstream`).
|
|
385
|
+
*/
|
|
386
|
+
export function unkeyedGroupOutcome(
|
|
387
|
+
node: TaskNode,
|
|
388
|
+
upstream: TaskOutcome[],
|
|
389
|
+
upfront: UpfrontGroupKey | undefined,
|
|
390
|
+
runStartHrTimeNs: bigint,
|
|
391
|
+
): TaskOutcome {
|
|
392
|
+
const wallclockNs = process.hrtime.bigint() - runStartHrTimeNs
|
|
277
393
|
const hash =
|
|
278
|
-
|
|
279
|
-
? upfront
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
|
|
291
|
-
...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
|
|
292
|
-
})
|
|
394
|
+
upfront !== undefined && sameUpstream(upstream, upfront.upstream)
|
|
395
|
+
? upfront.key
|
|
396
|
+
: computeGroupHash(upstream)
|
|
397
|
+
return groupOutcome(node, upstream, hash, wallclockNs)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function groupOutcome(
|
|
401
|
+
node: TaskNode,
|
|
402
|
+
upstream: TaskOutcome[],
|
|
403
|
+
hash: string,
|
|
404
|
+
wallclockNs: bigint,
|
|
405
|
+
): TaskOutcome {
|
|
293
406
|
return {
|
|
294
|
-
node
|
|
407
|
+
node,
|
|
295
408
|
status: 'success',
|
|
296
409
|
exitCode: 0,
|
|
297
410
|
durationMs: 0,
|
|
298
411
|
hash,
|
|
299
412
|
// What this group stands for. A dependent expands it to describe the
|
|
300
413
|
// real tasks in its input closure — see `TaskOutcome.groupUpstream`.
|
|
301
|
-
groupUpstream:
|
|
414
|
+
groupUpstream: upstream,
|
|
302
415
|
wallclockStartNs: wallclockNs,
|
|
303
416
|
wallclockEndNs: wallclockNs,
|
|
304
417
|
}
|
|
@@ -823,40 +936,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
823
936
|
// the probe already ran — restore its hit, or (null hit) fall straight
|
|
824
937
|
// to the run path as a known stable miss. No second cache.get.
|
|
825
938
|
if (willRead) {
|
|
826
|
-
const cacheOpStart = performance.now()
|
|
827
|
-
// A hit whose artifact was removed after the probe (a prune, another
|
|
828
|
-
// workspace's retention on a shared cache directory) is a miss. The
|
|
829
|
-
// restore has wiped the declared outputs by then; the run path cleans
|
|
830
|
-
// them again and the task writes them.
|
|
831
|
-
const restoreOrMiss = async (hit: CacheEntry): Promise<TaskOutcome | null> => {
|
|
832
|
-
try {
|
|
833
|
-
return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
|
|
834
|
-
} catch (err) {
|
|
835
|
-
if (!(err instanceof ArtifactVanishedError)) throw err
|
|
836
|
-
log.status(`[vx] ${node.id}: ${err.message} — running it`)
|
|
837
|
-
return null
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
939
|
if (preProbed !== undefined) {
|
|
841
|
-
if (preProbed.hit !== null)
|
|
842
|
-
// A key taken before a task rewrote the lockfile names an install
|
|
843
|
-
// the tree no longer holds, and the lazy path below never probes
|
|
844
|
-
// one (X-124): demoted like a vanished hit, it runs dep-gated.
|
|
845
|
-
const restored = fingerprintMoved() ? null : await restoreOrMiss(preProbed.hit)
|
|
846
|
-
if (restored !== null) return restored
|
|
847
|
-
// The up-front probe's hit may be restoring AHEAD of this task's
|
|
848
|
-
// deps, and a command run now would build from outputs they have
|
|
849
|
-
// not written yet and save that under the good key. The scheduler
|
|
850
|
-
// runs it again once they are done (admission drops the probe).
|
|
851
|
-
throw new RestoreDemoted(node.id)
|
|
852
|
-
}
|
|
940
|
+
if (preProbed.hit !== null) return restoreProbed(args, hash, preProbed.hit, taskStartNs)
|
|
853
941
|
// Confirmed stable miss — skip the probe, fall through to run.
|
|
854
|
-
} else if (!fingerprintMoved()) {
|
|
942
|
+
} else if (!fingerprintMoved(args)) {
|
|
943
|
+
const cacheOpStart = performance.now()
|
|
855
944
|
const endProbe = span('cache.get')
|
|
856
945
|
const hit = await cache.get(hash, getContext(node, step.command))
|
|
857
946
|
endProbe()
|
|
858
947
|
if (hit) {
|
|
859
|
-
const restored = await restoreOrMiss(hit)
|
|
948
|
+
const restored = await restoreOrMiss(args, hash, hit, cacheOpStart, taskStartNs)
|
|
860
949
|
if (restored !== null) return restored
|
|
861
950
|
}
|
|
862
951
|
}
|
|
@@ -1198,7 +1287,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1198
1287
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
1199
1288
|
const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
1200
1289
|
let flushMasked = (): void => {}
|
|
1201
|
-
// The attempt's output as the logger got it (masked)
|
|
1290
|
+
// The attempt's output as the logger got it (masked): the entry's when
|
|
1291
|
+
// the task saves, else only what a failure reports (`failedOutput`).
|
|
1202
1292
|
let output: BoundedCapture | undefined
|
|
1203
1293
|
const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
|
|
1204
1294
|
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
@@ -1290,14 +1380,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1290
1380
|
}
|
|
1291
1381
|
|
|
1292
1382
|
async function buildRequest(local: boolean): Promise<ExecuteRequest> {
|
|
1293
|
-
const kept = willSave
|
|
1383
|
+
const kept = willSave
|
|
1384
|
+
? new BoundedCapture()
|
|
1385
|
+
: new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
1294
1386
|
output = kept
|
|
1295
1387
|
const toOut = (t: string): void => {
|
|
1296
|
-
kept
|
|
1388
|
+
kept.push(t)
|
|
1297
1389
|
log.taskStdout(node, t)
|
|
1298
1390
|
}
|
|
1299
1391
|
const toErr = (t: string): void => {
|
|
1300
|
-
kept
|
|
1392
|
+
kept.push(t, true)
|
|
1301
1393
|
log.taskStderr(node, t)
|
|
1302
1394
|
}
|
|
1303
1395
|
const out = secrets && maskedEmitter(secrets, toOut)
|
|
@@ -1436,16 +1528,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1436
1528
|
}
|
|
1437
1529
|
}
|
|
1438
1530
|
|
|
1439
|
-
/**
|
|
1440
|
-
* The workspace fingerprint every key here folded has moved (a task
|
|
1441
|
-
* rewrote the lockfile), said once per run.
|
|
1442
|
-
*/
|
|
1443
|
-
function fingerprintMoved(): boolean {
|
|
1444
|
-
if (args.fingerprintWatch?.moved() === undefined) return false
|
|
1445
|
-
args.fingerprintWatch.say(log)
|
|
1446
|
-
return true
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
1531
|
/**
|
|
1450
1532
|
* What an additive run saves as its own, or undefined when it removed a
|
|
1451
1533
|
* file it found: no entry replays a removal (`ownOutputsSince`), so the
|
|
@@ -1491,7 +1573,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1491
1573
|
* withholds it too: the key folded the old one.
|
|
1492
1574
|
*/
|
|
1493
1575
|
async function keyStillTrue(): Promise<boolean> {
|
|
1494
|
-
if (fingerprintMoved()) {
|
|
1576
|
+
if (fingerprintMoved(args)) {
|
|
1495
1577
|
unkeyed = true
|
|
1496
1578
|
return false
|
|
1497
1579
|
}
|
|
@@ -1540,6 +1622,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1540
1622
|
: {}),
|
|
1541
1623
|
...(inputChanges !== undefined ? { inputChanges } : {}),
|
|
1542
1624
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1625
|
+
...(effectiveExitCode !== 0 && output !== undefined
|
|
1626
|
+
? { failedOutput: failedOutputLog(output, willSave) }
|
|
1627
|
+
: {}),
|
|
1543
1628
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1544
1629
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
1545
1630
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
@@ -27,7 +27,9 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
|
|
|
27
27
|
* and how often it failed AFTER its first pass (`relapses`). A task with
|
|
28
28
|
* no cache block keys on its config alone and runs every time, so its key
|
|
29
29
|
* says nothing about its inputs: no reader counts it, as no run's row
|
|
30
|
-
* calls it flaky.
|
|
30
|
+
* calls it flaky. Nor is a row that names a `blocked_by`: it ran behind a
|
|
31
|
+
* failure (`--continue=always`), on bytes its key never named, so its
|
|
32
|
+
* pass or failure is not the key's. Every flakiness reader is a filter over this one
|
|
31
33
|
* projection.
|
|
32
34
|
*/
|
|
33
35
|
function keyOutcomesSql(source: string, where = ''): string {
|
|
@@ -42,7 +44,7 @@ function keyOutcomesSql(source: string, where = ''): string {
|
|
|
42
44
|
CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
|
|
43
45
|
THEN 1 ELSE 0 END AS passed
|
|
44
46
|
FROM ${source}
|
|
45
|
-
WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0${where}))
|
|
47
|
+
WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL${where}))
|
|
46
48
|
GROUP BY project, task, hash`
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -148,7 +150,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
|
|
|
148
150
|
const rows = db
|
|
149
151
|
.query(
|
|
150
152
|
`SELECT DISTINCT project, task, hash FROM runs
|
|
151
|
-
WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
153
|
+
WHERE status = 'failed' AND blocked_by IS NULL AND hash IN (${chunk.map(() => '?').join(', ')})`,
|
|
152
154
|
)
|
|
153
155
|
.all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
|
|
154
156
|
for (const r of rows) everFailed.add(keyOf(r))
|
|
@@ -31,6 +31,7 @@ import { formatElapsed, maskedCommand } from '../util/index.js'
|
|
|
31
31
|
import { paint, type ColorSupport } from './colors.js'
|
|
32
32
|
import { neverStarted } from './summary.js'
|
|
33
33
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
|
+
import { fileMemo, linkPaths } from './path-links.js'
|
|
34
35
|
|
|
35
36
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
36
37
|
|
|
@@ -88,6 +89,8 @@ export function formatTaskBlock(
|
|
|
88
89
|
// ran or was cached — you asked for it, you see what it would run.
|
|
89
90
|
forceCommand = false,
|
|
90
91
|
forwardArgs: readonly string[] = [],
|
|
92
|
+
// The terminal opens OSC 8 links: a failed task's paths become links.
|
|
93
|
+
links = false,
|
|
91
94
|
): string {
|
|
92
95
|
// Group tasks (no `exec`) do no work and have no body — they're
|
|
93
96
|
// organizational nodes the user wrote so a `vx run ci` invocation
|
|
@@ -113,7 +116,16 @@ export function formatTaskBlock(
|
|
|
113
116
|
lines.push('', corner(`$ ${shownCommand(node, forwardArgs)}`), '')
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
|
|
119
|
+
const isFile = links && outcome.status === 'failed' ? fileMemo() : undefined
|
|
120
|
+
const link =
|
|
121
|
+
isFile === undefined ? undefined : (t: string) => linkPaths(t, node.projectDir, isFile)
|
|
122
|
+
pushOutputSection(
|
|
123
|
+
lines,
|
|
124
|
+
output,
|
|
125
|
+
(body.droppedStdout ?? 0) + (body.droppedStderr ?? 0),
|
|
126
|
+
colors,
|
|
127
|
+
link,
|
|
128
|
+
)
|
|
117
129
|
|
|
118
130
|
lines.push(...violationSection(outcome, colors))
|
|
119
131
|
|
|
@@ -156,6 +168,7 @@ function pushOutputSection(
|
|
|
156
168
|
output: readonly OutputChunk[],
|
|
157
169
|
lost: number,
|
|
158
170
|
colors: ColorSupport,
|
|
171
|
+
link?: (line: string) => string,
|
|
159
172
|
): void {
|
|
160
173
|
if (output.every((c) => c.text.trim().length === 0)) return
|
|
161
174
|
lines.push(sectionLine('OUTPUT', '', colors), '')
|
|
@@ -179,8 +192,9 @@ function pushOutputSection(
|
|
|
179
192
|
}
|
|
180
193
|
if (open !== null) body.push({ text: open.parts.join(''), err: open.err })
|
|
181
194
|
for (const l of body) {
|
|
182
|
-
|
|
183
|
-
|
|
195
|
+
const text = link === undefined ? l.text : link(l.text)
|
|
196
|
+
if (!l.err || !colors.enabled) lines.push(text)
|
|
197
|
+
else lines.push(l.text.includes('\x1b') ? text : paint(ERROR, text, colors))
|
|
184
198
|
}
|
|
185
199
|
lines.push('')
|
|
186
200
|
}
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
|
|
5
5
|
export { prepareRun, type PreparedRun } from './prepare.js'
|
|
6
6
|
export { affectedTaskProjects } from './affected-tasks.js'
|
|
7
|
+
export type { AffectedReason } from './affected-tasks.js'
|
|
7
8
|
export {
|
|
8
9
|
discoverProjects,
|
|
9
10
|
gitOfDiscovery,
|
|
@@ -156,12 +157,22 @@ export {
|
|
|
156
157
|
latestRunId,
|
|
157
158
|
listInvocations,
|
|
158
159
|
listRuns,
|
|
160
|
+
rootCauses,
|
|
159
161
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
160
162
|
} from './metrics.js'
|
|
163
|
+
export {
|
|
164
|
+
runFailures,
|
|
165
|
+
type RunFailures,
|
|
166
|
+
type TaskFailure,
|
|
167
|
+
type TaskLog,
|
|
168
|
+
taskLog,
|
|
169
|
+
} from './run-failures.js'
|
|
170
|
+
export type { OutputLocation } from './path-links.js'
|
|
161
171
|
export { resolveRunId, shortRunId } from './run-id.js'
|
|
162
172
|
export type {
|
|
163
173
|
CacheEntryRow,
|
|
164
174
|
CacheKeyDiff,
|
|
175
|
+
RootCause,
|
|
165
176
|
CacheKeyExplanation,
|
|
166
177
|
InputDiffEntry,
|
|
167
178
|
InvocationDetail,
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
notifyEscape,
|
|
38
38
|
PROGRESS_CLEAR,
|
|
39
39
|
progressEscape,
|
|
40
|
+
terminalLinks,
|
|
40
41
|
terminalSignals,
|
|
41
42
|
} from './terminal-signals.js'
|
|
42
43
|
|
|
@@ -298,6 +299,7 @@ export function defaultLogger(
|
|
|
298
299
|
const signals = writer.enabled
|
|
299
300
|
? terminalSignals(opts.env ?? process.env)
|
|
300
301
|
: { progress: false, notify: false }
|
|
302
|
+
const links = writer.enabled && terminalLinks(opts.env ?? process.env)
|
|
301
303
|
let progressShown = -1
|
|
302
304
|
let progressRed = false
|
|
303
305
|
|
|
@@ -753,7 +755,15 @@ export function defaultLogger(
|
|
|
753
755
|
}
|
|
754
756
|
if (framed && output.some((c) => c.text.length > 0)) {
|
|
755
757
|
emitBlock(
|
|
756
|
-
formatTaskBlock(
|
|
758
|
+
formatTaskBlock(
|
|
759
|
+
node,
|
|
760
|
+
outcome,
|
|
761
|
+
{ output, ...dropped },
|
|
762
|
+
colors,
|
|
763
|
+
true,
|
|
764
|
+
forwardArgs,
|
|
765
|
+
links,
|
|
766
|
+
),
|
|
757
767
|
)
|
|
758
768
|
return
|
|
759
769
|
}
|
|
@@ -846,7 +856,15 @@ export function defaultLogger(
|
|
|
846
856
|
if (outcome.status !== 'failed') return
|
|
847
857
|
emitLine(failureRow(node, outcome))
|
|
848
858
|
deferredFailures.push(
|
|
849
|
-
formatTaskBlock(
|
|
859
|
+
formatTaskBlock(
|
|
860
|
+
node,
|
|
861
|
+
outcome,
|
|
862
|
+
{ output, ...dropped },
|
|
863
|
+
colors,
|
|
864
|
+
false,
|
|
865
|
+
forwardArgs,
|
|
866
|
+
links,
|
|
867
|
+
),
|
|
850
868
|
)
|
|
851
869
|
return
|
|
852
870
|
case 'broad':
|
|
@@ -858,7 +876,15 @@ export function defaultLogger(
|
|
|
858
876
|
// ◼ row now; the full frame replays at runEnd.
|
|
859
877
|
emitLine(failureRow(node, outcome))
|
|
860
878
|
deferredFailures.push(
|
|
861
|
-
formatTaskBlock(
|
|
879
|
+
formatTaskBlock(
|
|
880
|
+
node,
|
|
881
|
+
outcome,
|
|
882
|
+
{ output, ...dropped },
|
|
883
|
+
colors,
|
|
884
|
+
false,
|
|
885
|
+
forwardArgs,
|
|
886
|
+
links,
|
|
887
|
+
),
|
|
862
888
|
)
|
|
863
889
|
} else if (outcome.status === 'success') {
|
|
864
890
|
emitLine(formatTaskExecutedLine(node, outcome, colors))
|
|
@@ -895,14 +921,30 @@ export function defaultLogger(
|
|
|
895
921
|
if (outcome.status === 'failed') {
|
|
896
922
|
emitLine(failureRow(node, outcome))
|
|
897
923
|
deferredFailures.push(
|
|
898
|
-
formatTaskBlock(
|
|
924
|
+
formatTaskBlock(
|
|
925
|
+
node,
|
|
926
|
+
outcome,
|
|
927
|
+
{ output, ...dropped },
|
|
928
|
+
colors,
|
|
929
|
+
false,
|
|
930
|
+
forwardArgs,
|
|
931
|
+
links,
|
|
932
|
+
),
|
|
899
933
|
)
|
|
900
934
|
return
|
|
901
935
|
}
|
|
902
936
|
// forceCommand: a requested task's frame shows `$ cmd`
|
|
903
937
|
// whether it ran or was cached — same frame every run.
|
|
904
938
|
emitBlock(
|
|
905
|
-
formatTaskBlock(
|
|
939
|
+
formatTaskBlock(
|
|
940
|
+
node,
|
|
941
|
+
outcome,
|
|
942
|
+
{ output, ...dropped },
|
|
943
|
+
colors,
|
|
944
|
+
true,
|
|
945
|
+
forwardArgs,
|
|
946
|
+
links,
|
|
947
|
+
),
|
|
906
948
|
)
|
|
907
949
|
return
|
|
908
950
|
}
|
|
@@ -913,7 +955,15 @@ export function defaultLogger(
|
|
|
913
955
|
} else if (outcome.status === 'failed') {
|
|
914
956
|
emitLine(failureRow(node, outcome))
|
|
915
957
|
deferredFailures.push(
|
|
916
|
-
formatTaskBlock(
|
|
958
|
+
formatTaskBlock(
|
|
959
|
+
node,
|
|
960
|
+
outcome,
|
|
961
|
+
{ output, ...dropped },
|
|
962
|
+
colors,
|
|
963
|
+
false,
|
|
964
|
+
forwardArgs,
|
|
965
|
+
links,
|
|
966
|
+
),
|
|
917
967
|
)
|
|
918
968
|
}
|
|
919
969
|
return
|
|
@@ -939,6 +989,7 @@ export function defaultLogger(
|
|
|
939
989
|
colors,
|
|
940
990
|
false,
|
|
941
991
|
forwardArgs,
|
|
992
|
+
links,
|
|
942
993
|
)
|
|
943
994
|
if (block.length === 0) return
|
|
944
995
|
if (view.gha) {
|
|
@@ -757,6 +757,36 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
757
757
|
const cur = loadEntryInputs(db, this_.hash)
|
|
758
758
|
const old = loadEntryInputs(db, prev.hash)
|
|
759
759
|
|
|
760
|
+
// A failed previous run kept no fingerprints; the last run that saved an
|
|
761
|
+
// entry still names what moved since then (the edit an agent made to fix
|
|
762
|
+
// the failure, or the one that broke it).
|
|
763
|
+
if (cur.length > 0 && old.length === 0 && !isPassStatus(prev.status)) {
|
|
764
|
+
const saved = db
|
|
765
|
+
.query(
|
|
766
|
+
`SELECT run_id AS runId, hash FROM runs
|
|
767
|
+
WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
768
|
+
AND hash IN (SELECT entry_hash FROM entry_inputs)
|
|
769
|
+
ORDER BY id DESC LIMIT 1`,
|
|
770
|
+
)
|
|
771
|
+
.get(project, task, this_.id) as { runId: string | null; hash: string } | undefined
|
|
772
|
+
if (saved) {
|
|
773
|
+
const { entries, unchangedCount } = diffKeyComponents(loadEntryInputs(db, saved.hash), cur)
|
|
774
|
+
const since = `run ${saved.runId}, the last that saved an entry (the previous run ended ${prev.status})`
|
|
775
|
+
return {
|
|
776
|
+
runId,
|
|
777
|
+
taskId,
|
|
778
|
+
found: true,
|
|
779
|
+
previousRunId: saved.runId,
|
|
780
|
+
entries,
|
|
781
|
+
unchangedCount,
|
|
782
|
+
note:
|
|
783
|
+
entries.length > 0
|
|
784
|
+
? `${entries.length} cache-key component(s) changed since ${since}`
|
|
785
|
+
: `cache key matches ${since}`,
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
760
790
|
// Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
|
|
761
791
|
// either side's rows are gone, we can name the hash change but not the
|
|
762
792
|
// component-level diff.
|
|
@@ -803,6 +833,41 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
803
833
|
}
|
|
804
834
|
}
|
|
805
835
|
|
|
836
|
+
export interface RootCause {
|
|
837
|
+
/** From the asked task down to the one whose own inputs moved. */
|
|
838
|
+
chain: string[]
|
|
839
|
+
entries: CacheKeyDiff['entries']
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* The tasks under `taskId`'s moved upstreams whose OWN key components
|
|
844
|
+
* moved in the same run, each with the chain that carried it up. A task
|
|
845
|
+
* visited once is not walked again (a diamond names its root once).
|
|
846
|
+
*/
|
|
847
|
+
export function rootCauses(
|
|
848
|
+
db: Database,
|
|
849
|
+
runId: string,
|
|
850
|
+
taskId: string,
|
|
851
|
+
entries: CacheKeyDiff['entries'],
|
|
852
|
+
): RootCause[] {
|
|
853
|
+
const roots: RootCause[] = []
|
|
854
|
+
const seen = new Set([taskId])
|
|
855
|
+
const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
|
|
856
|
+
for (const e of moved) {
|
|
857
|
+
if (e.kind !== 'upstream' || seen.has(e.name)) continue
|
|
858
|
+
seen.add(e.name)
|
|
859
|
+
const next = [...chain, e.name]
|
|
860
|
+
const d = cacheKeyDiff(db, runId, e.name)
|
|
861
|
+
if (!d.found) continue
|
|
862
|
+
const own = d.entries.filter((x) => x.kind !== 'upstream')
|
|
863
|
+
if (own.length > 0) roots.push({ chain: next, entries: own })
|
|
864
|
+
walk(next, d.entries)
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
walk([taskId], entries)
|
|
868
|
+
return roots
|
|
869
|
+
}
|
|
870
|
+
|
|
806
871
|
// ---------------------------------------------------------------------------
|
|
807
872
|
// Run comparison — diff a run against the immediately-previous invocation
|
|
808
873
|
// ---------------------------------------------------------------------------
|
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import type { CachePolicy, RemoteCacheLayer } from '../cache/index.js'
|
|
6
6
|
import type { ContinueMode } from '../graph/index.js'
|
|
7
7
|
import type { TaskOutcome } from '../graph/index.js'
|
|
8
|
+
import type { AffectedReason } from './affected-tasks.js'
|
|
8
9
|
import type { EventBus } from './events.js'
|
|
9
10
|
import type { TelemetrySink } from './telemetry.js'
|
|
10
11
|
import type { Logger } from './logger.js'
|
|
12
|
+
import type { RunSummaryJson } from './run-artifacts.js'
|
|
11
13
|
import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
|
|
12
14
|
|
|
13
15
|
export interface RunOptions {
|
|
@@ -41,6 +43,8 @@ export interface RunOptions {
|
|
|
41
43
|
* whether or not `affected` reaches them (X-10).
|
|
42
44
|
*/
|
|
43
45
|
selectedOutright?: readonly string[]
|
|
46
|
+
/** Filled with why `--affected` kept each requested task; `planRun` (`--dry`) passes it. */
|
|
47
|
+
affectedReasons?: Map<string, AffectedReason>
|
|
44
48
|
/**
|
|
45
49
|
* Configs a selection pass in this same process already loaded and
|
|
46
50
|
* staged with the same `cacheDir` and `frozen` (the CLI walks every
|
|
@@ -171,6 +175,12 @@ export interface RunOptions {
|
|
|
171
175
|
* else is treated as the literal file path (cwd-relative).
|
|
172
176
|
*/
|
|
173
177
|
summarize?: string
|
|
178
|
+
/**
|
|
179
|
+
* `vx run --format json`: the terminal renderer writes stderr, and the
|
|
180
|
+
* run hands back `RunSummary.json`, the `--summarize` document, for the
|
|
181
|
+
* caller to print on stdout.
|
|
182
|
+
*/
|
|
183
|
+
json?: boolean
|
|
174
184
|
/**
|
|
175
185
|
* Text printed just above the run's footer, given the outcomes the
|
|
176
186
|
* footer counts, after the `summaryTable`: nothing prints below the
|
|
@@ -312,6 +322,8 @@ export interface RunSummary {
|
|
|
312
322
|
* caller prints it; `vx run` writes it to stderr.
|
|
313
323
|
*/
|
|
314
324
|
refused?: string
|
|
325
|
+
/** The `--summarize` document, when `RunOptions.json` asked for it. */
|
|
326
|
+
json?: RunSummaryJson
|
|
315
327
|
}
|
|
316
328
|
|
|
317
329
|
export interface HeldPersistent {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
|
|
5
5
|
// a literal RS doubled. Output that never touched stderr is its own text.
|
|
6
6
|
|
|
7
|
-
import type
|
|
7
|
+
import { BoundedCapture, type CapturedChunk } from '../exec/index.js'
|
|
8
8
|
|
|
9
9
|
const RS = '\x1e'
|
|
10
10
|
|
|
@@ -51,3 +51,16 @@ export function decodeOutputLog(log: string): CapturedChunk[] {
|
|
|
51
51
|
flush()
|
|
52
52
|
return chunks
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
// What a failed task reports of its output (`vx last`, an agent): the
|
|
56
|
+
// error is usually at the end, and a run's history keeps one per failure.
|
|
57
|
+
export const FAILED_OUTPUT_HEAD_CHARS = 8 * 1024
|
|
58
|
+
export const FAILED_OUTPUT_TAIL_CHARS = 56 * 1024
|
|
59
|
+
|
|
60
|
+
/** A failed attempt's capture, re-bounded to the failure's limits when it is the entry's. */
|
|
61
|
+
export function failedOutputLog(capture: BoundedCapture, entrySized: boolean): string {
|
|
62
|
+
if (!entrySized) return encodeOutputLog(capture.chunks())
|
|
63
|
+
const small = new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
|
|
64
|
+
for (const c of capture.chunks()) small.push(c.text, c.err)
|
|
65
|
+
return encodeOutputLog(small.chunks())
|
|
66
|
+
}
|