@vzn/vx 0.0.16 → 0.0.21
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 +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// uses for predictive priority and `vx info --history` surfaces.
|
|
3
3
|
//
|
|
4
4
|
// The data has been in cache.db.runs since schema v11; what's new is
|
|
5
|
-
// surfacing it.
|
|
6
|
-
// (project, task) pair
|
|
7
|
-
// the run's lifetime. Loaded once
|
|
5
|
+
// surfacing it. One SQL statement per call pulls the last N executed rows
|
|
6
|
+
// per (project, task) pair out of a rowid-bounded slice of the table; the
|
|
7
|
+
// result becomes a read-only snapshot for the run's lifetime. Loaded once
|
|
8
|
+
// by the `schedule` stage (or `--dry`); never mutated mid-run.
|
|
8
9
|
//
|
|
9
10
|
// Two providers:
|
|
10
11
|
// LocalHistoryProvider — reads cache.db directly (zero-config).
|
|
@@ -12,6 +13,9 @@
|
|
|
12
13
|
// when such an RPC actually exists.
|
|
13
14
|
|
|
14
15
|
import type { Database } from 'bun:sqlite'
|
|
16
|
+
import { EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
17
|
+
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
18
|
+
import type { FailureMode } from './failure-mode.js'
|
|
15
19
|
|
|
16
20
|
const DEFAULT_RECENT = 50
|
|
17
21
|
|
|
@@ -27,8 +31,22 @@ export interface TaskHistory {
|
|
|
27
31
|
successRate: number
|
|
28
32
|
/** Cache hit rate over the recent window ([0, 1]). */
|
|
29
33
|
hitRate: number
|
|
30
|
-
/** Failure mode classification
|
|
31
|
-
|
|
34
|
+
/** Failure mode classification over the same window (`failureModeOf`,
|
|
35
|
+
* the one rule `vx why`'s all-time query also applies). */
|
|
36
|
+
failureMode: FailureMode
|
|
37
|
+
/**
|
|
38
|
+
* Largest peak RSS (bytes) any successful execution in the window
|
|
39
|
+
* reported; undefined when none did (hits report nothing, and a
|
|
40
|
+
* platform may not expose rusage). The floor a memory reservation
|
|
41
|
+
* learned from history packs on.
|
|
42
|
+
*/
|
|
43
|
+
maxPeakRssBytes?: number
|
|
44
|
+
/**
|
|
45
|
+
* The most CPU parallelism any successful execution in the window
|
|
46
|
+
* showed: cpu time over wall time, so 2.0 means two cores busy for the
|
|
47
|
+
* whole task. Undefined when no row carried both numbers.
|
|
48
|
+
*/
|
|
49
|
+
maxCpuParallelism?: number
|
|
32
50
|
}
|
|
33
51
|
|
|
34
52
|
/** Map keyed by `project#task`. */
|
|
@@ -52,92 +70,141 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
52
70
|
private readonly recent: number = DEFAULT_RECENT,
|
|
53
71
|
) {}
|
|
54
72
|
|
|
73
|
+
/**
|
|
74
|
+
* The window is the last `recent` INVOCATIONS, read as one rowid slice.
|
|
75
|
+
*
|
|
76
|
+
* `runs` has no (project, task) index: it cost every run a scattered
|
|
77
|
+
* B-tree write per task (2026-09-09, 1,000 warm hits: the record stage
|
|
78
|
+
* 57–79 ms with it, 14–19 ms without) and bought this reader nothing —
|
|
79
|
+
* ranking every pair's rows newest-first (`ROW_NUMBER` over a partition)
|
|
80
|
+
* sorted the whole table either way, 230 ms at 116k rows. Rows are
|
|
81
|
+
* appended in time order, one contiguous block per invocation, so the
|
|
82
|
+
* rows of the newest `recent` invocations are exactly `id >= MIN(id)` of
|
|
83
|
+
* the `recent`-th newest header — a primary-key range, no sort, plain
|
|
84
|
+
* aggregates, and at most one row per pair per invocation, so "the last
|
|
85
|
+
* `recent` invocations" IS the last `recent` runs for a pair that runs
|
|
86
|
+
* every time and whatever fewer it has for one that runs less often. A
|
|
87
|
+
* hint for ordering and prediction, not a ledger; `vx why` / `vx last`
|
|
88
|
+
* read by `run_id` and stay exact.
|
|
89
|
+
*/
|
|
55
90
|
async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
|
|
56
91
|
const out = new Map<string, TaskHistory>()
|
|
57
92
|
if (taskIds.length === 0) return out
|
|
58
93
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// hitRate are computed over ALL recent rows.
|
|
64
|
-
const sql = `
|
|
65
|
-
WITH recent AS (
|
|
66
|
-
SELECT
|
|
67
|
-
project,
|
|
68
|
-
task,
|
|
69
|
-
status,
|
|
70
|
-
duration_ms,
|
|
71
|
-
cache_hit,
|
|
72
|
-
ROW_NUMBER() OVER (PARTITION BY project, task ORDER BY started_at DESC) AS rn
|
|
73
|
-
FROM runs
|
|
74
|
-
WHERE (project || '#' || task) IN (${taskIds.map(() => '?').join(',')})
|
|
94
|
+
const floorRow = this.db
|
|
95
|
+
.query(
|
|
96
|
+
`SELECT MIN(id) AS id FROM runs WHERE run_id =
|
|
97
|
+
(SELECT run_id FROM invocations ORDER BY started_at DESC LIMIT 1 OFFSET ?)`,
|
|
75
98
|
)
|
|
99
|
+
.get(this.recent - 1) as { id: number | null }
|
|
100
|
+
// Fewer invocations than the window (or none): the whole table is the window.
|
|
101
|
+
const floor = floorRow.id ?? 0
|
|
102
|
+
|
|
103
|
+
// `skipped` rows are excluded: a skip is a task the run never executed,
|
|
104
|
+
// so it belongs in no success/hit RATE and no duration. Percentiles are
|
|
105
|
+
// over the executed-success rows of the slice — work the runner actually
|
|
106
|
+
// did — while the rates count every executed row.
|
|
107
|
+
const sql = `
|
|
76
108
|
SELECT
|
|
77
|
-
project,
|
|
78
|
-
task,
|
|
109
|
+
r.project,
|
|
110
|
+
r.task,
|
|
79
111
|
COUNT(*) AS total,
|
|
80
|
-
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS successes,
|
|
81
|
-
SUM(CASE WHEN cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
|
|
82
|
-
SUM(CASE WHEN
|
|
83
|
-
SUM(CASE WHEN
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
SUM(CASE WHEN r.status = 'success' THEN 1 ELSE 0 END) AS successes,
|
|
113
|
+
SUM(CASE WHEN r.cache_hit = 1 THEN 1 ELSE 0 END) AS hits,
|
|
114
|
+
SUM(CASE WHEN r.status = 'failed' THEN 1 ELSE 0 END) AS failures,
|
|
115
|
+
SUM(CASE WHEN r.attempts > 1 THEN 1 ELSE 0 END) AS retried,
|
|
116
|
+
GROUP_CONCAT(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
|
|
117
|
+
THEN r.duration_ms END) AS ds,
|
|
118
|
+
MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
|
|
119
|
+
THEN r.peak_rss_bytes
|
|
120
|
+
WHEN r.cache_hit = 1 THEN e.peak_rss_bytes END) AS rss,
|
|
121
|
+
MAX(CASE WHEN (r.cache_hit IS NULL OR r.cache_hit = 0) AND r.status = 'success'
|
|
122
|
+
AND r.cpu_ms IS NOT NULL AND r.duration_ms > 0
|
|
123
|
+
THEN r.cpu_ms * 1.0 / r.duration_ms
|
|
124
|
+
WHEN r.cache_hit = 1 AND e.cpu_ms IS NOT NULL AND e.duration_ms > 0
|
|
125
|
+
THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
|
|
126
|
+
FROM runs r
|
|
127
|
+
-- A hit executed nothing here, but its entry carries what the
|
|
128
|
+
-- PRODUCING execution used (the artifact's sidecar, indexed at save
|
|
129
|
+
-- and ingest alike), so a task this machine has only ever restored
|
|
130
|
+
-- — a fresh runner behind a remote cache — still has a number. The
|
|
131
|
+
-- join is by primary key and only for hit rows; an entry pruned
|
|
132
|
+
-- since contributes nothing.
|
|
133
|
+
LEFT JOIN entries e ON r.cache_hit = 1 AND e.hash = r.hash
|
|
134
|
+
WHERE r.id >= ? AND ${EXECUTED_RUNS_SQL}
|
|
135
|
+
GROUP BY r.project, r.task
|
|
87
136
|
`
|
|
88
|
-
|
|
89
137
|
type Row = {
|
|
90
138
|
project: string
|
|
91
139
|
task: string
|
|
92
140
|
total: number
|
|
93
141
|
successes: number
|
|
94
142
|
hits: number
|
|
95
|
-
executed: number
|
|
96
143
|
failures: number
|
|
144
|
+
retried: number
|
|
145
|
+
ds: string | null
|
|
146
|
+
rss: number | null
|
|
147
|
+
cpu: number | null
|
|
97
148
|
}
|
|
98
|
-
|
|
149
|
+
// Every pair in the slice is grouped (the slice is bounded, so this is
|
|
150
|
+
// cheap) and the asked-for ones are picked out here — a per-row IN-list
|
|
151
|
+
// probe in SQL cost more than the grouping it would have saved.
|
|
152
|
+
const wanted = new Set(taskIds)
|
|
153
|
+
const rows = (this.db.query(sql).all(floor) as Row[]).filter((r) =>
|
|
154
|
+
wanted.has(`${r.project}#${r.task}`),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
// The flakiness signal needs per-key outcomes, which the aggregate above
|
|
158
|
+
// folded away — one more pass over the slice, only for the pairs whose
|
|
159
|
+
// verdict actually depends on it (failed, and no retry already proving
|
|
160
|
+
// nondeterminism); a green history never pays for it.
|
|
161
|
+
const mixed = this.mixedOutcomeKeys(
|
|
162
|
+
floor,
|
|
163
|
+
rows.filter((r) => (r.failures || 0) > 0 && (r.retried || 0) === 0),
|
|
164
|
+
)
|
|
99
165
|
|
|
100
166
|
for (const row of rows) {
|
|
101
167
|
const key = `${row.project}#${row.task}`
|
|
102
168
|
const total = row.total || 0
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
const { p50, p99 } = this.percentilesFor(row.project, row.task)
|
|
169
|
+
const counts = { total, failures: row.failures || 0, retried: row.retried || 0 }
|
|
170
|
+
const durations = row.ds === null ? undefined : row.ds.split(',').map(Number)
|
|
171
|
+
if (durations !== undefined) durations.sort((a, b) => a - b)
|
|
107
172
|
out.set(key, {
|
|
108
173
|
runs: total,
|
|
109
|
-
p50DurationMs:
|
|
110
|
-
p99DurationMs:
|
|
174
|
+
p50DurationMs: durations ? pickPercentile(durations, 0.5) : undefined,
|
|
175
|
+
p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
|
|
111
176
|
successRate: total > 0 ? (row.successes || 0) / total : 0,
|
|
112
177
|
hitRate: total > 0 ? (row.hits || 0) / total : 0,
|
|
113
|
-
failureMode,
|
|
178
|
+
failureMode: failureModeOf(counts, () => mixed.get(key) ?? 0),
|
|
179
|
+
...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
|
|
180
|
+
...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
|
|
114
181
|
})
|
|
115
182
|
}
|
|
116
183
|
return out
|
|
117
184
|
}
|
|
118
185
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
project: string,
|
|
123
|
-
|
|
124
|
-
|
|
186
|
+
/** Mixed-outcome key count per `project#task`, over the slice, for `pairs` only. */
|
|
187
|
+
private mixedOutcomeKeys(
|
|
188
|
+
floor: number,
|
|
189
|
+
pairs: readonly { project: string; task: string }[],
|
|
190
|
+
): Map<string, number> {
|
|
191
|
+
const out = new Map<string, number>()
|
|
192
|
+
if (pairs.length === 0) return out
|
|
193
|
+
const placeholders = pairs.map(() => '(?,?)').join(',')
|
|
125
194
|
const rows = this.db
|
|
126
195
|
.query(
|
|
127
|
-
`SELECT
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
ORDER BY started_at DESC
|
|
132
|
-
LIMIT ?`,
|
|
196
|
+
`SELECT project, task, COUNT(*) AS n FROM (${mixedOutcomeKeysSql(
|
|
197
|
+
'runs',
|
|
198
|
+
` AND id >= ? AND (project, task) IN (VALUES ${placeholders})`,
|
|
199
|
+
)}) GROUP BY project, task`,
|
|
133
200
|
)
|
|
134
|
-
.all(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
201
|
+
.all(floor, ...pairs.flatMap((p) => [p.project, p.task])) as {
|
|
202
|
+
project: string
|
|
203
|
+
task: string
|
|
204
|
+
n: number
|
|
205
|
+
}[]
|
|
206
|
+
for (const r of rows) out.set(`${r.project}#${r.task}`, r.n)
|
|
207
|
+
return out
|
|
141
208
|
}
|
|
142
209
|
}
|
|
143
210
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// What a HIT leaves behind: the outputs of a confirmed cache entry
|
|
2
|
+
// materialised in the project — or proved already current and left alone —
|
|
3
|
+
// the exact changed paths marked so a same-project consumer need not
|
|
4
|
+
// re-spawn git, the stored stdout replayed, and the cache-hit outcome
|
|
5
|
+
// built. There is ONE restore path: `executeCachedTask` and the local
|
|
6
|
+
// short-circuit (a stable hit restored ahead of its deps) both come here,
|
|
7
|
+
// so the two cannot drift. Stale-hit-critical: a line moved here changes
|
|
8
|
+
// which bytes a green run replays. Moved out of execute-task.ts
|
|
9
|
+
// 2026-09-10 as pure code motion, the mirror of miss-save.ts.
|
|
10
|
+
|
|
11
|
+
import path from 'node:path'
|
|
12
|
+
import type { CacheConfig } from '../config.js'
|
|
13
|
+
import {
|
|
14
|
+
type CacheEntry,
|
|
15
|
+
cleanOutputs,
|
|
16
|
+
cleanWorkspaceOutputs,
|
|
17
|
+
resolveOutputs,
|
|
18
|
+
resolveWorkspaceOutputs,
|
|
19
|
+
WORKSPACE_OUTPUT_PREFIX,
|
|
20
|
+
} from '../cache/index.js'
|
|
21
|
+
import type { TaskOutcome } from '../graph/index.js'
|
|
22
|
+
import { span, wholeSubtreePrefixes } from '../util/index.js'
|
|
23
|
+
import type { ExecuteArgs } from './execute-task.js'
|
|
24
|
+
|
|
25
|
+
export interface RestoreHitArgs {
|
|
26
|
+
args: ExecuteArgs
|
|
27
|
+
hash: string
|
|
28
|
+
hit: CacheEntry
|
|
29
|
+
/** `performance.now()` when the cache op (probe) started — the
|
|
30
|
+
* user-perceived restore duration is measured from here. */
|
|
31
|
+
cacheOpStart: number
|
|
32
|
+
/** ns offset from run start for the outcome's wallclock window. */
|
|
33
|
+
taskStartNs: bigint
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Materialize a confirmed cache hit: decide skip-restore, clean +
|
|
38
|
+
* restore declared outputs, mark the exact changed paths so downstream
|
|
39
|
+
* same-project tasks needn't re-spawn git, replay stored stdout, and
|
|
40
|
+
* build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
|
|
41
|
+
* so the local short-circuit can restore a stable-key hit ahead of the
|
|
42
|
+
* schedule using the IDENTICAL logic — there is one restore path, not
|
|
43
|
+
* two that could drift.
|
|
44
|
+
*/
|
|
45
|
+
export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
|
|
46
|
+
const { args, hash, hit, cacheOpStart, taskStartNs } = restore
|
|
47
|
+
const { node, log } = args
|
|
48
|
+
const cacheCfg: CacheConfig | undefined = node.config.cache
|
|
49
|
+
const outputs = cacheCfg?.outputs.files ?? []
|
|
50
|
+
const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
|
|
51
|
+
const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
|
|
52
|
+
const dirPrefixes = wholeSubtreePrefixes(outputs)
|
|
53
|
+
const cleanArgs = {
|
|
54
|
+
projectDir: node.projectDir,
|
|
55
|
+
outputs,
|
|
56
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
57
|
+
}
|
|
58
|
+
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
59
|
+
|
|
60
|
+
// "Tree is already current" short-circuit — skip cleanOutputs
|
|
61
|
+
// + restoreOutputs when the on-disk state matches what this
|
|
62
|
+
// entry recorded at save time. Integrity-preserving: we
|
|
63
|
+
// require BOTH that the output-glob walk yields exactly the
|
|
64
|
+
// expected paths (no strays, no missing) AND that every file's
|
|
65
|
+
// (size, mode, mtime) matches the stored fingerprint. Any
|
|
66
|
+
// divergence falls through to a real clean + restore.
|
|
67
|
+
//
|
|
68
|
+
// The output-file fingerprints can't be batch-loaded at
|
|
69
|
+
// prepareRun time — non-leaf task hashes depend on upstream
|
|
70
|
+
// outputs that haven't been written yet, so hashes are
|
|
71
|
+
// necessarily computed mid-run. The short-circuit's batched probe
|
|
72
|
+
// loads the rows with the entry (`hit.outputRows`); only the lazy
|
|
73
|
+
// path pays one SELECT here. Either beats reading the manifest from
|
|
74
|
+
// the tar (decompress + parse) at the same point.
|
|
75
|
+
// A task with no declared outputs has nothing to clean or restore: its
|
|
76
|
+
// stdout replays from the row (`hit.stdout`), and its artifact holds
|
|
77
|
+
// only logs. Extracting it anyway cost an `exists` + a tar read per hit
|
|
78
|
+
// — 1.7 ms each on a four-task real repo whose `link` and `element`
|
|
79
|
+
// tasks declare `outputs: []` (2026-09-10, VX_TIMING).
|
|
80
|
+
let skipRestore = !anyOutputs
|
|
81
|
+
if (anyOutputs) {
|
|
82
|
+
const endRows = span('output rows')
|
|
83
|
+
const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
84
|
+
endRows()
|
|
85
|
+
if (expected.length > 0) {
|
|
86
|
+
// Two namespaces in the rows: bare rels are project outputs,
|
|
87
|
+
// `workspace-outputs/<rel>` rows anchor at the workspace root.
|
|
88
|
+
const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
89
|
+
const wsExpected = expected
|
|
90
|
+
.filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
91
|
+
.map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
|
|
92
|
+
// The directory short-circuit: for whole-subtree globs, unchanged
|
|
93
|
+
// mtimes on every directory recorded at the last save/restore prove
|
|
94
|
+
// the output SET is unchanged (a file added or removed anywhere the
|
|
95
|
+
// glob could see bumps a recorded directory), so the walk that cost
|
|
96
|
+
// 0.36 ms per warm hit is replaced by a few stats. The per-file
|
|
97
|
+
// fingerprint check below still runs; only the enumeration is skipped.
|
|
98
|
+
let setKnown = false
|
|
99
|
+
if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
|
|
100
|
+
const endDirs = span('output dirs')
|
|
101
|
+
// Loaded with the entry (batched by getMany, or with the lazy get).
|
|
102
|
+
const dirRows = hit.outputDirRows ?? []
|
|
103
|
+
const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
|
|
104
|
+
setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
|
|
105
|
+
endDirs()
|
|
106
|
+
}
|
|
107
|
+
let actualAbs: string[]
|
|
108
|
+
let actualWsAbs: string[]
|
|
109
|
+
if (setKnown) {
|
|
110
|
+
actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
|
|
111
|
+
actualWsAbs = []
|
|
112
|
+
} else {
|
|
113
|
+
const endGlob = span('output glob')
|
|
114
|
+
actualAbs = await resolveOutputs({
|
|
115
|
+
projectDir: node.projectDir,
|
|
116
|
+
outputs,
|
|
117
|
+
nestedProjectDirs: args.nestedProjectDirs,
|
|
118
|
+
})
|
|
119
|
+
actualWsAbs = await resolveWorkspaceOutputs({
|
|
120
|
+
workspaceRoot: args.workspaceRoot,
|
|
121
|
+
outputs: wsOutputs,
|
|
122
|
+
})
|
|
123
|
+
endGlob()
|
|
124
|
+
}
|
|
125
|
+
const setsMatch = (
|
|
126
|
+
actual: readonly string[],
|
|
127
|
+
exp: ReadonlyArray<{ path: string }>,
|
|
128
|
+
): boolean => {
|
|
129
|
+
const expSet = new Set(exp.map((e) => e.path))
|
|
130
|
+
return actual.length === expSet.size && actual.every((r) => expSet.has(r))
|
|
131
|
+
}
|
|
132
|
+
const actualRels = actualAbs.map((p) =>
|
|
133
|
+
path.relative(node.projectDir, p).split(path.sep).join('/'),
|
|
134
|
+
)
|
|
135
|
+
const actualWsRels = actualWsAbs.map((p) =>
|
|
136
|
+
path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
|
|
137
|
+
)
|
|
138
|
+
if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
|
|
139
|
+
const endStat = span('output stat')
|
|
140
|
+
skipRestore =
|
|
141
|
+
(await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
|
|
142
|
+
(await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
|
|
143
|
+
endStat()
|
|
144
|
+
// The walk ran and proved the tree current (rows absent, or a
|
|
145
|
+
// directory had moved — a benign touch): snapshot the directories
|
|
146
|
+
// so the next hit skips the walk.
|
|
147
|
+
if (skipRestore && !setKnown && dirPrefixes !== null) {
|
|
148
|
+
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!skipRestore) {
|
|
154
|
+
let cleanedRels: string[] = []
|
|
155
|
+
let cleanedWsRels: string[] = []
|
|
156
|
+
if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
|
|
157
|
+
if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
|
|
158
|
+
await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
|
|
159
|
+
// The directory snapshot behind the NEXT hit's skip-restore, taken at
|
|
160
|
+
// run end when the run keeps a list (as the miss path does, see
|
|
161
|
+
// miss-save.ts): the restore renamed into these directories
|
|
162
|
+
// microseconds ago, inside the snapshot's racy window, so a snapshot
|
|
163
|
+
// taken here was refused and the first warm run after every restore
|
|
164
|
+
// walked its output trees — 41 of payload's 45 tasks, 14,430 files,
|
|
165
|
+
// ~75 ms of the no-op's CPU (2026-09-11).
|
|
166
|
+
if (dirPrefixes !== null) {
|
|
167
|
+
if (args.outputDirSnapshots !== undefined) {
|
|
168
|
+
args.outputDirSnapshots.push({ hash, projectDir: node.projectDir, prefixes: dirPrefixes })
|
|
169
|
+
} else {
|
|
170
|
+
await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Restored outputs changed the project's tree — but on this
|
|
174
|
+
// path we know the EXACT changed paths (wiped declared
|
|
175
|
+
// outputs + the artifact's files). Record them instead of
|
|
176
|
+
// dropping the snapshot; downstream same-project tasks only
|
|
177
|
+
// re-spawn git when their input globs can actually see one of
|
|
178
|
+
// these paths. The cache-miss save path keeps the
|
|
179
|
+
// unconditional drop (an executed task may write undeclared
|
|
180
|
+
// files only git can see).
|
|
181
|
+
if (outputs.length > 0) {
|
|
182
|
+
args.gitFilesCache?.markOutputsChanged(node.projectDir, [
|
|
183
|
+
...cleanedRels,
|
|
184
|
+
...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
|
|
185
|
+
])
|
|
186
|
+
}
|
|
187
|
+
if (wsOutputs.length > 0) {
|
|
188
|
+
args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
|
|
189
|
+
...cleanedWsRels,
|
|
190
|
+
...hit.outputFiles
|
|
191
|
+
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
192
|
+
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
|
|
193
|
+
])
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (hit.stdout) log.taskStdout(node, hit.stdout)
|
|
197
|
+
const status =
|
|
198
|
+
hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
|
|
199
|
+
// `restored` distinguishes "we just wrote files to disk" from
|
|
200
|
+
// "disk already matched the cached snapshot". Drives the
|
|
201
|
+
// "up-to-date" vs "local-cache" / "remote-cache" label in the
|
|
202
|
+
// framed block. Only meaningful when at least one output was
|
|
203
|
+
// declared — no-outputs tasks never materialize anything, so
|
|
204
|
+
// they're vacuously up-to-date.
|
|
205
|
+
const restored = !skipRestore && anyOutputs
|
|
206
|
+
return {
|
|
207
|
+
node,
|
|
208
|
+
status,
|
|
209
|
+
exitCode: hit.exitCode,
|
|
210
|
+
// What THIS run spent: probe + restore. NOT the stored exec time — see
|
|
211
|
+
// `storedDurationMs`, which is what the hit skipped.
|
|
212
|
+
durationMs: Math.round(performance.now() - cacheOpStart),
|
|
213
|
+
storedDurationMs: hit.durationMs,
|
|
214
|
+
...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
|
|
215
|
+
...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
|
|
216
|
+
hash,
|
|
217
|
+
restored,
|
|
218
|
+
wallclockStartNs: taskStartNs,
|
|
219
|
+
wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
|
|
220
|
+
}
|
|
221
|
+
}
|