@vzn/vx 0.0.18 → 0.0.22
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/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -15,6 +15,8 @@ import type { Database } from 'bun:sqlite'
|
|
|
15
15
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
16
16
|
import { splitTaskId } from '../graph/index.js'
|
|
17
17
|
import { clampInt } from '../util/index.js'
|
|
18
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
19
|
+
import { isPassStatus } from './telemetry.js'
|
|
18
20
|
|
|
19
21
|
// ---------------------------------------------------------------------------
|
|
20
22
|
// Run listing + detail
|
|
@@ -30,6 +32,8 @@ export interface RunSummaryRow {
|
|
|
30
32
|
startedAt: number
|
|
31
33
|
endedAt: number
|
|
32
34
|
cacheHit: boolean | null
|
|
35
|
+
/** Whether the task declared a `cache` block; null on rows older than the column. */
|
|
36
|
+
cached: boolean | null
|
|
33
37
|
hash: string
|
|
34
38
|
cpuMs: number | null
|
|
35
39
|
peakRssBytes: number | null
|
|
@@ -37,6 +41,11 @@ export interface RunSummaryRow {
|
|
|
37
41
|
// (bigints aren't JSON-safe); use BigInt or Number on the client.
|
|
38
42
|
wallclockStartNs: string | null
|
|
39
43
|
wallclockEndNs: string | null
|
|
44
|
+
/** Why the task failed or was skipped (v27); null where the reason does not apply. */
|
|
45
|
+
blockedBy: string | null
|
|
46
|
+
timedOut: boolean | null
|
|
47
|
+
sandboxViolations: number | null
|
|
48
|
+
notReady: 'timeout' | 'exited' | 'spawn' | null
|
|
40
49
|
}
|
|
41
50
|
|
|
42
51
|
export interface ListRunsArgs {
|
|
@@ -65,24 +74,38 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
|
|
|
65
74
|
params.push(args.runId)
|
|
66
75
|
}
|
|
67
76
|
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
|
68
|
-
type RawRow = Omit<
|
|
77
|
+
type RawRow = Omit<
|
|
78
|
+
RunSummaryRow,
|
|
79
|
+
'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs' | 'timedOut'
|
|
80
|
+
> & {
|
|
69
81
|
cacheHit: number | null
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
cached: number | null
|
|
83
|
+
// NOT bigint: `bun:sqlite` hands these back as JS numbers (the handle
|
|
84
|
+
// does not set `safeIntegers`), so the annotation claimed a precision
|
|
85
|
+
// the read never had. Harmless in practice — these are ns RELATIVE to
|
|
86
|
+
// run t=0, and 2^53 ns is 104 days of run time — but a `bigint` here
|
|
87
|
+
// was simply false. `.toString()` reads the same on either.
|
|
88
|
+
wallclockStartNs: number | null
|
|
89
|
+
wallclockEndNs: number | null
|
|
90
|
+
timedOut: number | null
|
|
72
91
|
}
|
|
73
92
|
const rows = db
|
|
74
93
|
.query(
|
|
75
94
|
`SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
|
|
76
95
|
duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
|
|
77
|
-
cache_hit AS cacheHit, hash,
|
|
96
|
+
cache_hit AS cacheHit, cached, hash,
|
|
78
97
|
cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
|
|
79
|
-
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
|
|
80
|
-
|
|
98
|
+
wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
|
|
99
|
+
blocked_by AS blockedBy, timed_out AS timedOut,
|
|
100
|
+
sandbox_violations AS sandboxViolations, not_ready AS notReady
|
|
101
|
+
FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
|
|
81
102
|
)
|
|
82
103
|
.all(...params, limit) as RawRow[]
|
|
83
104
|
return rows.map((r) => ({
|
|
84
105
|
...r,
|
|
85
106
|
cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
|
|
107
|
+
cached: r.cached === null ? null : Boolean(r.cached),
|
|
108
|
+
timedOut: r.timedOut === null ? null : Boolean(r.timedOut),
|
|
86
109
|
wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
|
|
87
110
|
wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
|
|
88
111
|
}))
|
|
@@ -257,7 +280,7 @@ export function listInvocations(
|
|
|
257
280
|
const rows = db
|
|
258
281
|
.query(
|
|
259
282
|
`SELECT ${INVOCATION_COLUMNS} FROM invocations ${clause}
|
|
260
|
-
ORDER BY
|
|
283
|
+
ORDER BY rowid DESC LIMIT ?`,
|
|
261
284
|
)
|
|
262
285
|
.all(...params, limit) as InvocationRawRow[]
|
|
263
286
|
return rows.map(mapInvocation)
|
|
@@ -350,31 +373,91 @@ export interface WhyDidThisRerun {
|
|
|
350
373
|
runId: string
|
|
351
374
|
taskId: string
|
|
352
375
|
found: boolean
|
|
353
|
-
thisRun?: {
|
|
376
|
+
thisRun?: {
|
|
377
|
+
hash: string
|
|
378
|
+
status: string
|
|
379
|
+
cacheHit: boolean | null
|
|
380
|
+
/** Whether the task declared a `cache` block; null on rows older than the column. */
|
|
381
|
+
cached: boolean | null
|
|
382
|
+
startedAt: number
|
|
383
|
+
}
|
|
354
384
|
previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
|
|
355
385
|
hashChanged?: boolean | null
|
|
356
386
|
note: string
|
|
357
387
|
}
|
|
358
388
|
|
|
359
|
-
/**
|
|
360
|
-
|
|
361
|
-
|
|
389
|
+
/**
|
|
390
|
+
* The endings an UNCHANGED cache key can have. A re-execution is told apart
|
|
391
|
+
* by the evidence the index holds: every one used to blame `--no-cache` /
|
|
392
|
+
* `--force`, when the previous run on the key had failed (a failure saves
|
|
393
|
+
* nothing) or the entry had been pruned, and the invocation recorded that
|
|
394
|
+
* no flag was passed (item 1009).
|
|
395
|
+
*/
|
|
396
|
+
function unchangedKeyNote(
|
|
397
|
+
db: Database,
|
|
398
|
+
runId: string,
|
|
399
|
+
this_: { hash: string; cacheHit: number | null; startedAt: number },
|
|
400
|
+
prev: { status: string },
|
|
401
|
+
): string {
|
|
402
|
+
if (this_.cacheHit === null) {
|
|
362
403
|
return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
|
|
363
404
|
}
|
|
364
|
-
return
|
|
365
|
-
|
|
366
|
-
|
|
405
|
+
if (this_.cacheHit) return 'cache key unchanged — this run was served from cache, nothing re-ran'
|
|
406
|
+
if (prev.status === 'failed') {
|
|
407
|
+
return 'cache key unchanged — the previous run on this key failed and saved nothing, so there was nothing to hit'
|
|
408
|
+
}
|
|
409
|
+
const policy = (
|
|
410
|
+
db.query('SELECT cache_policy AS p FROM invocations WHERE run_id = ?').get(runId) as {
|
|
411
|
+
p: string
|
|
412
|
+
} | null
|
|
413
|
+
)?.p
|
|
414
|
+
if (policy !== undefined && !policy.split(',').some((axis) => axis === 'lR' || axis === 'rR')) {
|
|
415
|
+
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
|
|
416
|
+
}
|
|
417
|
+
// `get` answers null, not undefined, for no row.
|
|
418
|
+
const createdAt = (
|
|
419
|
+
db.query('SELECT created_at AS at FROM entries WHERE hash = ?').get(this_.hash) as {
|
|
420
|
+
at: number
|
|
421
|
+
} | null
|
|
422
|
+
)?.at
|
|
423
|
+
if (createdAt !== undefined && createdAt >= this_.startedAt) {
|
|
424
|
+
return 'cache key unchanged — no entry for this key was in the cache when it ran (pruned or evicted), so it executed and saved one'
|
|
425
|
+
}
|
|
426
|
+
return 'cache key unchanged — re-executed on the same key (--no-cache / --force, or unrelated)'
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The latest recorded run of a task (run_id may be NULL on very old rows).
|
|
431
|
+
* One query for `vx why` and `vx mcp`: the CLI defaulted to it while the
|
|
432
|
+
* tool demanded a run id an agent had to fetch first (2026-09-16).
|
|
433
|
+
*/
|
|
434
|
+
export function latestRunId(db: Database, taskId: string): string | null {
|
|
435
|
+
const [project, task] = splitTaskId(taskId)
|
|
436
|
+
const row = db
|
|
437
|
+
.query(
|
|
438
|
+
`SELECT run_id AS runId FROM runs WHERE project = ? AND task = ?
|
|
439
|
+
ORDER BY id DESC LIMIT 1`,
|
|
440
|
+
)
|
|
441
|
+
.get(project, task) as { runId: string | null } | undefined
|
|
442
|
+
return row?.runId ?? null
|
|
367
443
|
}
|
|
368
444
|
|
|
369
445
|
export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
|
|
370
446
|
const [project, task] = splitTaskId(taskId)
|
|
371
447
|
const this_ = db
|
|
372
448
|
.query(
|
|
373
|
-
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
|
|
449
|
+
`SELECT id, hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
|
|
374
450
|
FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
|
|
375
451
|
)
|
|
376
452
|
.get(runId, project, task) as
|
|
377
|
-
| {
|
|
453
|
+
| {
|
|
454
|
+
id: number
|
|
455
|
+
hash: string
|
|
456
|
+
status: string
|
|
457
|
+
cacheHit: number | null
|
|
458
|
+
cached: number | null
|
|
459
|
+
startedAt: number
|
|
460
|
+
}
|
|
378
461
|
| undefined
|
|
379
462
|
if (!this_) {
|
|
380
463
|
return {
|
|
@@ -391,10 +474,10 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
391
474
|
const prev = db
|
|
392
475
|
.query(
|
|
393
476
|
`SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
|
|
394
|
-
FROM runs WHERE project = ? AND task = ? AND
|
|
395
|
-
ORDER BY
|
|
477
|
+
FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
478
|
+
ORDER BY id DESC LIMIT 1`,
|
|
396
479
|
)
|
|
397
|
-
.get(project, task, this_.
|
|
480
|
+
.get(project, task, this_.id) as
|
|
398
481
|
| { hash: string; status: string; cacheHit: number | null; startedAt: number }
|
|
399
482
|
| undefined
|
|
400
483
|
// …and this run must have one too, or there is nothing to compare.
|
|
@@ -403,24 +486,34 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
|
|
|
403
486
|
runId,
|
|
404
487
|
taskId,
|
|
405
488
|
found: true,
|
|
406
|
-
thisRun: {
|
|
489
|
+
thisRun: {
|
|
490
|
+
hash: this_.hash,
|
|
491
|
+
status: this_.status,
|
|
492
|
+
cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
|
|
493
|
+
cached: this_.cached === null ? null : Boolean(this_.cached),
|
|
494
|
+
startedAt: this_.startedAt,
|
|
495
|
+
},
|
|
407
496
|
previousRun: prev
|
|
408
497
|
? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
|
|
409
498
|
: null,
|
|
410
499
|
hashChanged: prev && !noKey ? prev.hash !== this_.hash : null,
|
|
411
500
|
note: noKey
|
|
412
501
|
? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
|
|
413
|
-
:
|
|
414
|
-
?
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
//
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
502
|
+
: this_.cached === 0
|
|
503
|
+
? // Not a cache decision at all: the task runs every time by design,
|
|
504
|
+
// and its key is derived only so dependents can fold it.
|
|
505
|
+
'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
|
|
506
|
+
: prev && prev.hash !== this_.hash
|
|
507
|
+
? 'cache key changed between the previous run and this one (inputs differ)'
|
|
508
|
+
: prev
|
|
509
|
+
? // An unchanged key has two very different endings, and calling
|
|
510
|
+
// both a "re-run" answered the question wrong: a cache HIT did not
|
|
511
|
+
// re-run at all, so blaming `--no-cache` named a cause that cannot
|
|
512
|
+
// have applied. Only a run that EXECUTED on an unchanged key is
|
|
513
|
+
// the case this verb exists to explain. A row with no recorded
|
|
514
|
+
// cacheHit (older rows) is neither — say that, do not guess.
|
|
515
|
+
unchangedKeyNote(db, runId, this_, prev)
|
|
516
|
+
: 'no prior run for this (project, task)',
|
|
424
517
|
}
|
|
425
518
|
}
|
|
426
519
|
|
|
@@ -450,17 +543,55 @@ export interface CacheKeyDiff {
|
|
|
450
543
|
note: string
|
|
451
544
|
}
|
|
452
545
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
546
|
+
function loadEntryInputs(db: Database, entryHash: string): TaskInputComponent[] {
|
|
547
|
+
return db
|
|
548
|
+
.query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
|
|
549
|
+
.all(entryHash) as TaskInputComponent[]
|
|
457
550
|
}
|
|
458
551
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
552
|
+
/**
|
|
553
|
+
* The rule `vx why` and the site's playground both name a moved key by: join
|
|
554
|
+
* two keys' components over `(kind, name)`. A component in both with a
|
|
555
|
+
* different hash is `changed`, one only in `after` is `added`, one only in
|
|
556
|
+
* `before` is `removed`, and an equal one is counted. Entries are ordered by
|
|
557
|
+
* kind, then name.
|
|
558
|
+
*/
|
|
559
|
+
export function diffKeyComponents(
|
|
560
|
+
before: readonly TaskInputComponent[],
|
|
561
|
+
after: readonly TaskInputComponent[],
|
|
562
|
+
): { entries: InputDiffEntry[]; unchangedCount: number } {
|
|
563
|
+
const id = (c: TaskInputComponent): string => `${c.kind}\0${c.name}`
|
|
564
|
+
const cur = new Map(after.map((c) => [id(c), c]))
|
|
565
|
+
const old = new Map(before.map((c) => [id(c), c]))
|
|
566
|
+
const entries: InputDiffEntry[] = []
|
|
567
|
+
let unchangedCount = 0
|
|
568
|
+
const keys = new Set<string>([...cur.keys(), ...old.keys()])
|
|
569
|
+
for (const key of keys) {
|
|
570
|
+
const a = cur.get(key)
|
|
571
|
+
const b = old.get(key)
|
|
572
|
+
if (a && b) {
|
|
573
|
+
if (a.hash === b.hash) unchangedCount++
|
|
574
|
+
else
|
|
575
|
+
entries.push({
|
|
576
|
+
kind: a.kind,
|
|
577
|
+
name: a.name,
|
|
578
|
+
change: 'changed',
|
|
579
|
+
before: b.hash,
|
|
580
|
+
after: a.hash,
|
|
581
|
+
})
|
|
582
|
+
} else if (a) {
|
|
583
|
+
entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
|
|
584
|
+
} else if (b) {
|
|
585
|
+
entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Code-unit order, not `localeCompare`: collation follows the machine's
|
|
589
|
+
// locale, and the playground runs this same join in the reader's browser,
|
|
590
|
+
// so the CLI and the page could list one diff in two orders. Code units
|
|
591
|
+
// are also SQLite's BINARY order, the `entry_inputs` scan's own.
|
|
592
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
|
|
593
|
+
entries.sort((x, y) => byCodeUnit(x.kind, y.kind) || byCodeUnit(x.name, y.name))
|
|
594
|
+
return { entries, unchangedCount }
|
|
464
595
|
}
|
|
465
596
|
|
|
466
597
|
/**
|
|
@@ -475,7 +606,8 @@ function loadEntryInputs(db: Database, entryHash: string): Map<string, EntryInpu
|
|
|
475
606
|
* - only in the previous run → `removed`
|
|
476
607
|
* - equal → counted as unchanged
|
|
477
608
|
*
|
|
478
|
-
* Pure SQL + an app-side set join — no config
|
|
609
|
+
* Pure SQL + an app-side set join (`diffKeyComponents`) — no config
|
|
610
|
+
* re-evaluation, no re-hash.
|
|
479
611
|
* Always returns a value (never throws); `found:false` when the run/task pair
|
|
480
612
|
* has no row, `entries:[]` for the first run of a task.
|
|
481
613
|
*/
|
|
@@ -483,9 +615,11 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
483
615
|
const [project, task] = splitTaskId(taskId)
|
|
484
616
|
const this_ = db
|
|
485
617
|
.query(
|
|
486
|
-
'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
618
|
+
'SELECT id, hash, cached, status, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
|
|
487
619
|
)
|
|
488
|
-
.get(runId, project, task) as
|
|
620
|
+
.get(runId, project, task) as
|
|
621
|
+
| { id: number; hash: string; cached: number | null; status: string; startedAt: number }
|
|
622
|
+
| undefined
|
|
489
623
|
if (!this_) {
|
|
490
624
|
return {
|
|
491
625
|
runId,
|
|
@@ -515,11 +649,13 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
515
649
|
|
|
516
650
|
const prev = db
|
|
517
651
|
.query(
|
|
518
|
-
`SELECT run_id AS runId, hash FROM runs
|
|
519
|
-
WHERE project = ? AND task = ? AND
|
|
520
|
-
ORDER BY
|
|
652
|
+
`SELECT run_id AS runId, hash, status FROM runs
|
|
653
|
+
WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
|
|
654
|
+
ORDER BY id DESC LIMIT 1`,
|
|
521
655
|
)
|
|
522
|
-
.get(project, task, this_.
|
|
656
|
+
.get(project, task, this_.id) as
|
|
657
|
+
| { runId: string | null; hash: string; status: string }
|
|
658
|
+
| undefined
|
|
523
659
|
|
|
524
660
|
if (!prev) {
|
|
525
661
|
return {
|
|
@@ -551,7 +687,15 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
551
687
|
// Input fingerprints are pruned with their entry (ON DELETE CASCADE); if
|
|
552
688
|
// either side's rows are gone, we can name the hash change but not the
|
|
553
689
|
// component-level diff.
|
|
554
|
-
if (cur.
|
|
690
|
+
if (cur.length === 0 || old.length === 0) {
|
|
691
|
+
// A run that did not pass saved no entry, so it kept no fingerprints:
|
|
692
|
+
// that is the cause to name, not a prune (item 898).
|
|
693
|
+
const unsaved =
|
|
694
|
+
cur.length === 0 && !isPassStatus(this_.status)
|
|
695
|
+
? `this run ended ${this_.status}`
|
|
696
|
+
: old.length === 0 && !isPassStatus(prev.status)
|
|
697
|
+
? `the previous run ended ${prev.status}`
|
|
698
|
+
: null
|
|
555
699
|
return {
|
|
556
700
|
runId,
|
|
557
701
|
taskId,
|
|
@@ -559,33 +703,18 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
|
|
|
559
703
|
previousRunId: prev.runId,
|
|
560
704
|
entries: [],
|
|
561
705
|
unchangedCount: 0,
|
|
562
|
-
note:
|
|
706
|
+
note:
|
|
707
|
+
this_.cached === 0
|
|
708
|
+
? // The verdict already says the task is uncached; this line says
|
|
709
|
+
// what moved a key that declares nothing.
|
|
710
|
+
'no `cache` block declares its inputs, so its key folds every file in its project — a file it wrote, or any untracked file, moves it — and no fingerprints are kept to say which'
|
|
711
|
+
: unsaved !== null
|
|
712
|
+
? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
|
|
713
|
+
: 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
|
|
563
714
|
}
|
|
564
715
|
}
|
|
565
716
|
|
|
566
|
-
const entries
|
|
567
|
-
let unchangedCount = 0
|
|
568
|
-
const keys = new Set<string>([...cur.keys(), ...old.keys()])
|
|
569
|
-
for (const key of keys) {
|
|
570
|
-
const a = cur.get(key)
|
|
571
|
-
const b = old.get(key)
|
|
572
|
-
if (a && b) {
|
|
573
|
-
if (a.hash === b.hash) unchangedCount++
|
|
574
|
-
else
|
|
575
|
-
entries.push({
|
|
576
|
-
kind: a.kind,
|
|
577
|
-
name: a.name,
|
|
578
|
-
change: 'changed',
|
|
579
|
-
before: b.hash,
|
|
580
|
-
after: a.hash,
|
|
581
|
-
})
|
|
582
|
-
} else if (a) {
|
|
583
|
-
entries.push({ kind: a.kind, name: a.name, change: 'added', before: null, after: a.hash })
|
|
584
|
-
} else if (b) {
|
|
585
|
-
entries.push({ kind: b.kind, name: b.name, change: 'removed', before: b.hash, after: null })
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
entries.sort((x, y) => x.kind.localeCompare(y.kind) || x.name.localeCompare(y.name))
|
|
717
|
+
const { entries, unchangedCount } = diffKeyComponents(old, cur)
|
|
589
718
|
|
|
590
719
|
return {
|
|
591
720
|
runId,
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// What a MISS leaves behind once the command exited 0 and the task will
|
|
2
|
+
// save: the declared outputs resolved, the artifact and its rows saved in
|
|
3
|
+
// one transaction, the whole-subtree output prefixes recorded for the
|
|
4
|
+
// next hit's skip-restore, and the exact written paths marked against the
|
|
5
|
+
// git snapshot so a same-project consumer re-spawns git only when its
|
|
6
|
+
// globs can see them. Stale-hit-critical: a line moved here changes what
|
|
7
|
+
// a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
|
|
8
|
+
// code motion.
|
|
9
|
+
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
type CacheLayer,
|
|
13
|
+
type GitFilesCache,
|
|
14
|
+
resolveOutputs,
|
|
15
|
+
resolveWorkspaceOutputs,
|
|
16
|
+
} from '../cache/index.js'
|
|
17
|
+
import type { TaskNode } from '../graph/index.js'
|
|
18
|
+
import { span, wholeSubtreePrefixes } from '../util/index.js'
|
|
19
|
+
import type { Logger } from './logger.js'
|
|
20
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
21
|
+
|
|
22
|
+
/** A snapshot request for `recordOutputDirs`, taken at run end. */
|
|
23
|
+
export interface OutputDirSnapshot {
|
|
24
|
+
hash: string
|
|
25
|
+
projectDir: string
|
|
26
|
+
prefixes: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SaveMissArgs {
|
|
30
|
+
node: TaskNode
|
|
31
|
+
hash: string
|
|
32
|
+
cache: CacheLayer
|
|
33
|
+
log: Logger
|
|
34
|
+
workspaceRoot: string
|
|
35
|
+
nestedProjectDirs: string[]
|
|
36
|
+
gitFilesCache?: GitFilesCache | undefined
|
|
37
|
+
/** Declared `cache.outputs.files` / `.workspaceFiles`. */
|
|
38
|
+
outputs: string[]
|
|
39
|
+
wsOutputs: string[]
|
|
40
|
+
/**
|
|
41
|
+
* An ADDITIVE task's own set (item 588): the files its run added or
|
|
42
|
+
* changed under its declared outputs, resolved by execute-task against
|
|
43
|
+
* the stamp it took before the run. Replaces the glob walk below, which
|
|
44
|
+
* would also take the upstream's files the task adds beside.
|
|
45
|
+
*/
|
|
46
|
+
ownOutputFiles?: string[] | undefined
|
|
47
|
+
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
48
|
+
captured: readonly TaskInputComponent[]
|
|
49
|
+
command: string
|
|
50
|
+
durationMs: number
|
|
51
|
+
stdout: string
|
|
52
|
+
/** The execution's usage, when the runner reported it — rides the artifact's sidecar. */
|
|
53
|
+
cpuMs?: number | undefined
|
|
54
|
+
peakRssBytes?: number | undefined
|
|
55
|
+
/** When present, the directory snapshot is queued here instead of taken now. */
|
|
56
|
+
outputDirSnapshots?: OutputDirSnapshot[] | undefined
|
|
57
|
+
/**
|
|
58
|
+
* When present, the cache save itself runs off the execution slot: the
|
|
59
|
+
* outputs are resolved and the git snapshot marked here, in the slot
|
|
60
|
+
* (a same-project downstream task reads both), and the pack + write +
|
|
61
|
+
* index go to the run's save lane (save-lane.ts).
|
|
62
|
+
*/
|
|
63
|
+
deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `landed` settles when the entry is in the cache — at once without a lane. */
|
|
67
|
+
export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void> }> {
|
|
68
|
+
const { node, cache, log } = a
|
|
69
|
+
const endResolve = span('miss: resolve outputs')
|
|
70
|
+
const outputFiles =
|
|
71
|
+
a.ownOutputFiles ??
|
|
72
|
+
(await resolveOutputs({
|
|
73
|
+
projectDir: node.projectDir,
|
|
74
|
+
outputs: a.outputs,
|
|
75
|
+
nestedProjectDirs: a.nestedProjectDirs,
|
|
76
|
+
}))
|
|
77
|
+
const wsOutputFiles = await resolveWorkspaceOutputs({
|
|
78
|
+
workspaceRoot: a.workspaceRoot,
|
|
79
|
+
outputs: a.wsOutputs,
|
|
80
|
+
})
|
|
81
|
+
endResolve()
|
|
82
|
+
// The mirror of the input warning: declared outputs that resolve to
|
|
83
|
+
// nothing save an empty artifact, and the next hit "restores" it —
|
|
84
|
+
// the build that ran nowhere looks like a build that ran. `files: []`
|
|
85
|
+
// is a deliberate cached no-op and says nothing; a glob that matched
|
|
86
|
+
// nothing is a glob against the wrong directory.
|
|
87
|
+
if (
|
|
88
|
+
a.outputs.length + a.wsOutputs.length > 0 &&
|
|
89
|
+
outputFiles.length + wsOutputFiles.length === 0
|
|
90
|
+
) {
|
|
91
|
+
// One cause is common enough to name, because the task shows no other
|
|
92
|
+
// symptom: a sandboxed task with no write grant. Its writes land in the
|
|
93
|
+
// sandbox's own scratch and never reach disk, and whether the shell
|
|
94
|
+
// even notices depends on the layout — in a single-package workspace
|
|
95
|
+
// (project dir === workspace root) the write SUCCEEDS into that
|
|
96
|
+
// scratch and the task exits 0, so this warning is the only thing that
|
|
97
|
+
// says the build produced nothing (item 444). A declared
|
|
98
|
+
// `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
|
|
99
|
+
const sandboxed = node.config.exec?.sandbox !== undefined
|
|
100
|
+
const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
|
|
101
|
+
log.status(
|
|
102
|
+
`[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
103
|
+
`an empty artifact is saved; a later hit restores nothing` +
|
|
104
|
+
(sandboxed && !grantsWrite
|
|
105
|
+
? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
|
|
106
|
+
: ''),
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
const save = async (): Promise<void> => {
|
|
110
|
+
const endSave = span('miss: save')
|
|
111
|
+
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
112
|
+
// describe above, persisted with the entry inside `cache.save`'s
|
|
113
|
+
// transaction. Miss path only — the warm/hit path never reaches here.
|
|
114
|
+
await cache.save({
|
|
115
|
+
hash: a.hash,
|
|
116
|
+
projectDir: node.projectDir,
|
|
117
|
+
outputFiles,
|
|
118
|
+
...(wsOutputFiles.length > 0
|
|
119
|
+
? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
|
|
120
|
+
: {}),
|
|
121
|
+
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
122
|
+
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
123
|
+
// the contract no longer accepts one — so the invariant is enforced by
|
|
124
|
+
// the type rather than by every call site remembering the gate.
|
|
125
|
+
entry: {
|
|
126
|
+
taskId: node.id,
|
|
127
|
+
command: a.command,
|
|
128
|
+
durationMs: a.durationMs,
|
|
129
|
+
stdout: a.stdout,
|
|
130
|
+
...(a.cpuMs !== undefined ? { cpuMs: a.cpuMs } : {}),
|
|
131
|
+
...(a.peakRssBytes !== undefined ? { peakRssBytes: a.peakRssBytes } : {}),
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
endSave()
|
|
135
|
+
// The files the task just wrote are the entry's bytes: stamp them,
|
|
136
|
+
// so the next hit can tell them from another entry's with the same
|
|
137
|
+
// size and mtime (item 886).
|
|
138
|
+
cache.recordOutputStamps?.(a.hash, node.projectDir, a.workspaceRoot)
|
|
139
|
+
// The directory snapshot behind the next hit's skip-restore, taken at
|
|
140
|
+
// run end from the run's list: the task wrote these directories
|
|
141
|
+
// milliseconds ago, inside the snapshot's racy window, so a snapshot
|
|
142
|
+
// taken here was refused and the next hit walked every output tree —
|
|
143
|
+
// 1,000 walks, 296 ms accumulated, on the first warm run after a cold
|
|
144
|
+
// build of the 1,000-project bench (2026-09-10). A caller with no
|
|
145
|
+
// list gets no snapshot: recording here was that same refusal (item
|
|
146
|
+
// 637 deleted the fallback and nothing reddened), and the next hit
|
|
147
|
+
// walks, as it did.
|
|
148
|
+
const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
|
|
149
|
+
if (savedDirPrefixes !== null && a.outputDirSnapshots !== undefined) {
|
|
150
|
+
a.outputDirSnapshots.push({
|
|
151
|
+
hash: a.hash,
|
|
152
|
+
projectDir: node.projectDir,
|
|
153
|
+
prefixes: savedDirPrefixes,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
markWritten(a, outputFiles, wsOutputFiles)
|
|
158
|
+
// Off the slot when the run keeps a lane (the save lane bounds and
|
|
159
|
+
// drains it); in the slot otherwise — an embedder without a lane gets
|
|
160
|
+
// the entry before the outcome.
|
|
161
|
+
if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
|
|
162
|
+
await save()
|
|
163
|
+
return { landed: Promise.resolve() }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** What `markUnsaved` reads of a miss: where its declared outputs are. */
|
|
167
|
+
export type UnsavedArgs = Pick<
|
|
168
|
+
SaveMissArgs,
|
|
169
|
+
'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'gitFilesCache' | 'outputs' | 'wsOutputs'
|
|
170
|
+
>
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A miss that ran here and saves nothing (it failed, the policy writes
|
|
174
|
+
* nothing, an upstream failed, its key no longer held) still wrote its
|
|
175
|
+
* outputs, and a same-project reader after it must see them as it sees a
|
|
176
|
+
* save's: resolved and marked the same way.
|
|
177
|
+
*/
|
|
178
|
+
export async function markUnsaved(a: UnsavedArgs): Promise<void> {
|
|
179
|
+
const endResolve = span('miss: resolve outputs')
|
|
180
|
+
const outputFiles = await resolveOutputs({
|
|
181
|
+
projectDir: a.node.projectDir,
|
|
182
|
+
outputs: a.outputs,
|
|
183
|
+
nestedProjectDirs: a.nestedProjectDirs,
|
|
184
|
+
})
|
|
185
|
+
const wsOutputFiles = await resolveWorkspaceOutputs({
|
|
186
|
+
workspaceRoot: a.workspaceRoot,
|
|
187
|
+
outputs: a.wsOutputs,
|
|
188
|
+
})
|
|
189
|
+
endResolve()
|
|
190
|
+
markWritten(a, outputFiles, wsOutputFiles)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The task just wrote outputs to the project's tree. Record the exact
|
|
195
|
+
* declared-output paths as changed (same as the cache-hit restore path)
|
|
196
|
+
* instead of dropping the whole snapshot: a downstream same-project task
|
|
197
|
+
* then re-spawns git ONLY when its input globs can actually see one of
|
|
198
|
+
* these paths. On a 1000-package cold run this removes ~one synchronous
|
|
199
|
+
* `git ls-files` spawn per project (the single largest cold-run cost —
|
|
200
|
+
* 22% of CPU in profiling). Contract: outputs must be declared — an
|
|
201
|
+
* executed task that writes files outside `cache.outputs.files` which a
|
|
202
|
+
* same-project downstream task reads is undeclared behavior (the restore
|
|
203
|
+
* path already assumes it).
|
|
204
|
+
*/
|
|
205
|
+
function markWritten(
|
|
206
|
+
a: Pick<SaveMissArgs, 'node' | 'workspaceRoot' | 'gitFilesCache'>,
|
|
207
|
+
outputFiles: readonly string[],
|
|
208
|
+
wsOutputFiles: readonly string[],
|
|
209
|
+
): void {
|
|
210
|
+
const { projectDir } = a.node
|
|
211
|
+
if (outputFiles.length > 0) {
|
|
212
|
+
a.gitFilesCache?.markOutputsChanged(
|
|
213
|
+
projectDir,
|
|
214
|
+
outputFiles.map((p) => path.relative(projectDir, p).split(path.sep).join('/')),
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
// Declared workspace outputs may have landed inside OTHER projects' dirs
|
|
218
|
+
// (no-boundary escape hatch) — mark the exact paths against every
|
|
219
|
+
// partition that can see them.
|
|
220
|
+
if (wsOutputFiles.length > 0) {
|
|
221
|
+
a.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
222
|
+
a.workspaceRoot,
|
|
223
|
+
wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
// The workspace-wide partition (when one exists) spans this project's
|
|
227
|
+
// subtree, so it inherits the same "undeclared writes are only visible
|
|
228
|
+
// to git" rule as the project drop above.
|
|
229
|
+
if (outputFiles.length + wsOutputFiles.length > 0) {
|
|
230
|
+
a.gitFilesCache?.invalidateWorkspacePartition()
|
|
231
|
+
}
|
|
232
|
+
}
|