@vzn/vx 0.0.636 → 0.0.637
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 +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -532,7 +532,8 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
532
532
|
// Red, not amber: a violation fails the task, so it reads as a failure.
|
|
533
533
|
// vx's note, after the evidence: the grants that would have allowed it.
|
|
534
534
|
// A read the output depends on is a key input too; the sandbox never
|
|
535
|
-
// adds one (owner, 2026-10-09)
|
|
535
|
+
// adds one (owner, 2026-10-09), so the note names the reads no input
|
|
536
|
+
// glob matches and the user decides.
|
|
536
537
|
const fix = outcome.sandboxFix
|
|
537
538
|
const line = fix === undefined ? undefined : sandboxFixLine(fix)
|
|
538
539
|
const dim = (text: string): string => paint('', text, colors, { dim: true })
|
|
@@ -541,8 +542,12 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
541
542
|
? []
|
|
542
543
|
: [
|
|
543
544
|
...(line !== undefined ? [dim(`fix: ${line}`)] : []),
|
|
544
|
-
...(fix.
|
|
545
|
-
? [
|
|
545
|
+
...(fix.inputs !== undefined
|
|
546
|
+
? [
|
|
547
|
+
dim(
|
|
548
|
+
`and, if the output depends on them, cache.inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
|
|
549
|
+
),
|
|
550
|
+
]
|
|
546
551
|
: []),
|
|
547
552
|
...(fix.withheld !== undefined
|
|
548
553
|
? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
|
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// CLI's `--affected` task selection (`vx show <task> --affected`), so both
|
|
3
3
|
// select over the graph the hooks leave.
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
import type { TaskNode } from '../graph/index.js'
|
|
5
|
+
import type { GraphRules, TaskNode } from '../graph/index.js'
|
|
7
6
|
import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
|
|
8
7
|
import { applyGraphHooks } from './plugin-host.js'
|
|
9
8
|
import type { VxPlugin } from './plugin.js'
|
|
@@ -17,7 +16,7 @@ export async function applyGraphStage(
|
|
|
17
16
|
cacheDir: string
|
|
18
17
|
projectMetas: readonly ProjectMeta[]
|
|
19
18
|
warn: (message: string) => void
|
|
20
|
-
rules?:
|
|
19
|
+
rules?: GraphRules | undefined
|
|
21
20
|
},
|
|
22
21
|
): Promise<void> {
|
|
23
22
|
// A hook edits each node's config in place, as `project` does, so the
|
|
@@ -36,10 +35,11 @@ export async function applyGraphStage(
|
|
|
36
35
|
(plugin) => {
|
|
37
36
|
for (const n of nodes.values()) {
|
|
38
37
|
if (isDefaultBuild(n.config)) continue
|
|
39
|
-
const
|
|
38
|
+
const own = configPaths.get(n.projectName) ?? null
|
|
40
39
|
validateProjectConfig(
|
|
41
40
|
{ tasks: { [n.taskName]: n.config } },
|
|
42
|
-
`${
|
|
41
|
+
`${own ?? `${n.projectName} (no config file)`} (after plugin '${plugin.name}')`,
|
|
42
|
+
own,
|
|
43
43
|
)
|
|
44
44
|
}
|
|
45
45
|
},
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
// (zero-config); the interface is the seam a plugin fills for any other.
|
|
13
13
|
|
|
14
14
|
import type { Database } from 'bun:sqlite'
|
|
15
|
-
import { EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
15
|
+
import { branchSource, EXECUTED_RUNS_SQL } from '../cache/index.js'
|
|
16
|
+
import { splitTaskId } from '../util/index.js'
|
|
16
17
|
import { failureModeOf, mixedOutcomeKeysSql } from './failure-mode.js'
|
|
17
18
|
import type { FailureMode } from './failure-mode.js'
|
|
18
19
|
import { isPassStatus, TASK_STATUSES } from './telemetry.js'
|
|
19
20
|
|
|
20
21
|
const DEFAULT_RECENT = 50
|
|
21
22
|
|
|
23
|
+
/** A task's forecast is the median of this many of its newest timings. */
|
|
24
|
+
const FORECAST_RUNS = 5
|
|
25
|
+
|
|
22
26
|
// A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
|
|
23
27
|
// success; the list is derived so a new status cannot miss it.
|
|
24
28
|
/** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
|
|
@@ -86,7 +90,9 @@ export class EmptyHistoryProvider implements HistoryProvider {
|
|
|
86
90
|
export class LocalHistoryProvider implements HistoryProvider {
|
|
87
91
|
constructor(
|
|
88
92
|
private readonly db: Database,
|
|
89
|
-
|
|
93
|
+
readonly recent: number = DEFAULT_RECENT,
|
|
94
|
+
/** The run's branch (`currentBranch`); undefined: a forecast reads every branch's timings. */
|
|
95
|
+
private readonly branch?: string | null,
|
|
90
96
|
) {}
|
|
91
97
|
|
|
92
98
|
/**
|
|
@@ -107,8 +113,17 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
107
113
|
* read by `run_id` and stay exact.
|
|
108
114
|
*/
|
|
109
115
|
async loadFor(taskIds: readonly string[]): Promise<HistoryTable> {
|
|
116
|
+
if (taskIds.length === 0) return new Map()
|
|
117
|
+
return this.load(new Set(taskIds))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Every task the window holds, as `loadFor` answers each (`vx last --stats`). */
|
|
121
|
+
async loadAll(): Promise<HistoryTable> {
|
|
122
|
+
return this.load(null)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private load(wanted: ReadonlySet<string> | null): HistoryTable {
|
|
110
126
|
const out = new Map<string, TaskHistory>()
|
|
111
|
-
if (taskIds.length === 0) return out
|
|
112
127
|
const floor = this.floor()
|
|
113
128
|
|
|
114
129
|
// `skipped` rows are excluded: a skip is a task the run never executed,
|
|
@@ -160,15 +175,15 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
160
175
|
// Every pair in the slice is grouped (the slice is bounded, so this is
|
|
161
176
|
// cheap) and the asked-for ones are picked out here — a per-row IN-list
|
|
162
177
|
// probe in SQL cost more than the grouping it would have saved.
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
wanted.has(`${r.project}#${r.task}`),
|
|
178
|
+
const rows = (this.db.query(sql).all(floor) as Row[]).filter(
|
|
179
|
+
(r) => wanted === null || wanted.has(`${r.project}#${r.task}`),
|
|
166
180
|
)
|
|
167
181
|
|
|
168
182
|
// The flakiness signal needs per-key outcomes, which the aggregate above
|
|
169
183
|
// folded away — one more pass over the slice, only for the pairs whose
|
|
170
184
|
// verdict actually depends on it (failed, and no retry already proving
|
|
171
185
|
// nondeterminism); a green history never pays for it.
|
|
186
|
+
const p50s = this.forecasts(new Set(rows.map((r) => `${r.project}#${r.task}`)))
|
|
172
187
|
const mixed = this.mixedOutcomeKeys(
|
|
173
188
|
floor,
|
|
174
189
|
rows.filter((r) => r.failures > 0 && r.retried === 0),
|
|
@@ -182,7 +197,7 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
182
197
|
if (durations !== undefined) durations.sort((a, b) => a - b)
|
|
183
198
|
out.set(key, {
|
|
184
199
|
runs: total,
|
|
185
|
-
p50DurationMs:
|
|
200
|
+
p50DurationMs: p50s.get(key),
|
|
186
201
|
p99DurationMs: durations ? pickPercentile(durations, 0.99) : undefined,
|
|
187
202
|
successRate: row.successes / total,
|
|
188
203
|
hitRate: row.hits / total,
|
|
@@ -195,27 +210,52 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
195
210
|
}
|
|
196
211
|
|
|
197
212
|
/**
|
|
198
|
-
* The
|
|
199
|
-
*
|
|
200
|
-
*
|
|
213
|
+
* The forecasts alone. `loadFor`'s rates and resource maxima read every
|
|
214
|
+
* row of the window and join each hit to its entry: 38 ms at 27,000 rows
|
|
215
|
+
* against 4 for the executed successes' durations alone.
|
|
201
216
|
*/
|
|
202
217
|
async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
|
|
218
|
+
if (taskIds.length === 0) return new Map()
|
|
219
|
+
return this.forecasts(new Set(taskIds))
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Each task's duration forecast (owner, 2026-10-14): the median of its
|
|
224
|
+
* last `FORECAST_RUNS` timings on this branch, else on any branch, else
|
|
225
|
+
* none. The timings are the shared store's `task_durations`, so every
|
|
226
|
+
* worktree of the repo reads them and a new one forecasts on its first
|
|
227
|
+
* run. A point read per task on `(project, task, source, id)`: exact
|
|
228
|
+
* however old the rows, where the runs window holds only the newest
|
|
229
|
+
* invocations.
|
|
230
|
+
*/
|
|
231
|
+
private forecasts(wanted: ReadonlySet<string>): Map<string, number> {
|
|
203
232
|
const out = new Map<string, number>()
|
|
204
|
-
if (
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
233
|
+
if (wanted.size === 0) return out
|
|
234
|
+
// Tiers, first answer wins. The remote cache's default-branch timings
|
|
235
|
+
// (#3563) slot in between the two.
|
|
236
|
+
type Tier = (project: string, task: string) => Array<{ ms: number }>
|
|
237
|
+
const tiers: Tier[] = []
|
|
238
|
+
const newest = (where: string) =>
|
|
239
|
+
this.db.query(
|
|
240
|
+
`SELECT duration_ms AS ms FROM task_durations WHERE project = ? AND task = ? AND ${where}
|
|
241
|
+
ORDER BY id DESC LIMIT ${FORECAST_RUNS}`,
|
|
210
242
|
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
243
|
+
if (this.branch !== undefined) {
|
|
244
|
+
const source = branchSource(this.branch)
|
|
245
|
+
const q = newest('source = ?')
|
|
246
|
+
tiers.push((p, t) => q.all(p, t, source) as Array<{ ms: number }>)
|
|
247
|
+
}
|
|
248
|
+
const any = newest(`source LIKE 'branch:%'`)
|
|
249
|
+
tiers.push((p, t) => any.all(p, t) as Array<{ ms: number }>)
|
|
250
|
+
for (const id of wanted) {
|
|
251
|
+
const [project, task] = splitTaskId(id)
|
|
252
|
+
for (const tier of tiers) {
|
|
253
|
+
const rows = tier(project, task)
|
|
254
|
+
if (rows.length === 0) continue
|
|
255
|
+
const durations = rows.map((r) => r.ms).sort((x, y) => x - y)
|
|
256
|
+
out.set(id, pickPercentile(durations, 0.5))
|
|
257
|
+
break
|
|
258
|
+
}
|
|
219
259
|
}
|
|
220
260
|
return out
|
|
221
261
|
}
|
|
@@ -236,13 +276,12 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
236
276
|
type Executed = {
|
|
237
277
|
project: string
|
|
238
278
|
task: string
|
|
239
|
-
ds: string
|
|
240
279
|
rss: number | null
|
|
241
280
|
cpu: number | null
|
|
242
281
|
}
|
|
243
282
|
const executed = this.db
|
|
244
283
|
.query(
|
|
245
|
-
`SELECT project, task,
|
|
284
|
+
`SELECT project, task, MAX(peak_rss_bytes) AS rss,
|
|
246
285
|
MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
|
|
247
286
|
THEN cpu_ms * 1.0 / duration_ms END) AS cpu
|
|
248
287
|
FROM runs
|
|
@@ -250,13 +289,12 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
250
289
|
GROUP BY project, task`,
|
|
251
290
|
)
|
|
252
291
|
.all(floor) as Executed[]
|
|
292
|
+
const p50s = this.forecasts(wanted)
|
|
253
293
|
for (const row of executed) {
|
|
254
294
|
const key = `${row.project}#${row.task}`
|
|
255
295
|
if (!wanted.has(key)) continue
|
|
256
|
-
const durations = row.ds.split(',').map(Number)
|
|
257
|
-
durations.sort((a, b) => a - b)
|
|
258
296
|
out.set(key, {
|
|
259
|
-
p50DurationMs:
|
|
297
|
+
p50DurationMs: p50s.get(key),
|
|
260
298
|
...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
|
|
261
299
|
...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
|
|
262
300
|
})
|
|
@@ -279,11 +317,13 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
279
317
|
const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
|
|
280
318
|
const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
|
|
281
319
|
out.set(key, {
|
|
282
|
-
p50DurationMs:
|
|
320
|
+
p50DurationMs: p50s.get(key),
|
|
283
321
|
...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
|
|
284
322
|
...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
|
|
285
323
|
})
|
|
286
324
|
}
|
|
325
|
+
// A forecast reads past the window: a task with none in it still has one.
|
|
326
|
+
for (const [key, p50] of p50s) if (!out.has(key)) out.set(key, { p50DurationMs: p50 })
|
|
287
327
|
return out
|
|
288
328
|
}
|
|
289
329
|
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
cleanOutputPaths,
|
|
15
15
|
cleanOutputs,
|
|
16
16
|
cleanWorkspaceOutputs,
|
|
17
|
+
committedFiles,
|
|
17
18
|
resolveOutputs,
|
|
18
19
|
resolveWorkspaceOutputs,
|
|
19
20
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -67,8 +68,13 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
67
68
|
projectDir: node.projectDir,
|
|
68
69
|
outputs,
|
|
69
70
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
71
|
+
committed: committedFiles(args.gitFilesCache, node.projectDir),
|
|
72
|
+
}
|
|
73
|
+
const wsCleanArgs = {
|
|
74
|
+
workspaceRoot: args.workspaceRoot,
|
|
75
|
+
outputs: wsOutputs,
|
|
76
|
+
committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
|
|
70
77
|
}
|
|
71
|
-
const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
|
|
72
78
|
// The two sides of an overlapping pair (item 588). An ADDITIVE task owns
|
|
73
79
|
// only its recorded rows: its glob also selects the upstream's files it
|
|
74
80
|
// adds beside, so "no strays" is not a claim it can make, and its clean
|
|
@@ -101,7 +107,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
101
107
|
// output replays from the row (`hit.stdout`), and its artifact holds
|
|
102
108
|
// only logs. Extracting it anyway cost an `exists` + a tar read per hit
|
|
103
109
|
// — 1.7 ms each on a four-task real repo whose `link` and `element`
|
|
104
|
-
// tasks declare `outputs: []` (2026-09-10,
|
|
110
|
+
// tasks declare `outputs: []` (2026-09-10, `VX_DEBUG_TIMING`).
|
|
105
111
|
let skipRestore = !anyOutputs
|
|
106
112
|
// The walk that found the tree stale, handed to the clean when nothing
|
|
107
113
|
// was awaited between them: the clean's own walk would only repeat it
|
|
@@ -230,7 +236,12 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
230
236
|
let cleanedWsRels: string[] = []
|
|
231
237
|
if (additive) {
|
|
232
238
|
cleanedRels = hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
233
|
-
await cleanOutputPaths({
|
|
239
|
+
await cleanOutputPaths({
|
|
240
|
+
projectDir: node.projectDir,
|
|
241
|
+
rels: cleanedRels,
|
|
242
|
+
outputs,
|
|
243
|
+
committed: cleanArgs.committed,
|
|
244
|
+
})
|
|
234
245
|
cleanedWsRels = hit.outputFiles
|
|
235
246
|
.filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
|
|
236
247
|
.map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
@@ -238,6 +249,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
238
249
|
projectDir: args.workspaceRoot,
|
|
239
250
|
rels: cleanedWsRels,
|
|
240
251
|
outputs: wsOutputs,
|
|
252
|
+
committed: wsCleanArgs.committed,
|
|
241
253
|
})
|
|
242
254
|
} else {
|
|
243
255
|
if (outputs.length > 0) {
|
|
@@ -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 { applyGraphStage } from './graph-stage.js'
|
|
7
8
|
export type { AffectedReason } from './affected-tasks.js'
|
|
8
9
|
export {
|
|
9
10
|
discoverProjects,
|
|
@@ -13,6 +14,8 @@ export {
|
|
|
13
14
|
loadProjects,
|
|
14
15
|
loadResolvedProjects,
|
|
15
16
|
loadWorkspacePlugins,
|
|
17
|
+
lockOfDiscovery,
|
|
18
|
+
lockReader,
|
|
16
19
|
type LoadedProjects,
|
|
17
20
|
type LoadProjectsArgs,
|
|
18
21
|
} from './projects.js'
|
|
@@ -21,12 +24,17 @@ export type { HeldPersistent, RunOptions, RunSummary } from './options.js'
|
|
|
21
24
|
// Re-surface the cache policy contract (defined in the cache module) so
|
|
22
25
|
// embedders constructing RunOptions.cache and the package façade can
|
|
23
26
|
// reach it without importing the cache module directly.
|
|
24
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
type CacheFlags,
|
|
29
|
+
type CachePolicy,
|
|
30
|
+
FULL_CACHE_POLICY,
|
|
31
|
+
parseCachePolicy,
|
|
32
|
+
} from '../cache/index.js'
|
|
25
33
|
// The CLI reads a recorded exit code; the runner's convention decodes it.
|
|
26
34
|
// It records its own argv quoted as the runner quotes forwarded args.
|
|
27
35
|
export { exitSignal, shellQuote } from '../exec/index.js'
|
|
28
36
|
export { forwardedSignal } from './signals.js'
|
|
29
|
-
// `vx cache
|
|
37
|
+
// `vx cache clean` waits for a run on the workspace before it evicts.
|
|
30
38
|
export { acquireRunLock } from './run-lock.js'
|
|
31
39
|
export { defaultLogger, resolveOutputView } from './logger.js'
|
|
32
40
|
export { detectColors, paint, type ColorSupport } from './colors.js'
|
|
@@ -70,6 +78,7 @@ export {
|
|
|
70
78
|
installPlugins,
|
|
71
79
|
type CacheContext,
|
|
72
80
|
type ExecutorContext,
|
|
81
|
+
type OutputContext,
|
|
73
82
|
type CommandContext,
|
|
74
83
|
type GraphHookContext,
|
|
75
84
|
type KeyHookContext,
|
|
@@ -79,7 +88,7 @@ export {
|
|
|
79
88
|
type AdmitContext,
|
|
80
89
|
type WorkspaceHookContext,
|
|
81
90
|
type DiscoverContext,
|
|
82
|
-
type
|
|
91
|
+
type DiscoveredProject,
|
|
83
92
|
type InstallPluginsArgs,
|
|
84
93
|
type Plugin,
|
|
85
94
|
type PluginContext,
|
|
@@ -105,8 +114,9 @@ export {
|
|
|
105
114
|
claimedAffected,
|
|
106
115
|
fingerprintClaims,
|
|
107
116
|
hasHook,
|
|
108
|
-
|
|
117
|
+
REMOTE_CACHE_METHODS,
|
|
109
118
|
resolveCache,
|
|
119
|
+
type CacheHost,
|
|
110
120
|
resolveExecutors,
|
|
111
121
|
} from './plugin-host.js'
|
|
112
122
|
export {
|
|
@@ -114,6 +124,7 @@ export {
|
|
|
114
124
|
reachDigests,
|
|
115
125
|
type LockfileClaimHooks,
|
|
116
126
|
type LockfileClaimOptions,
|
|
127
|
+
type LockfileImporter,
|
|
117
128
|
type ReachGraph,
|
|
118
129
|
} from './lockfile-claim.js'
|
|
119
130
|
export { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
@@ -147,6 +158,7 @@ export {
|
|
|
147
158
|
captureGitContext,
|
|
148
159
|
captureHostContext,
|
|
149
160
|
captureWorkspaceIdentity,
|
|
161
|
+
currentBranch,
|
|
150
162
|
detectCi,
|
|
151
163
|
} from './run-context.js'
|
|
152
164
|
export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './run-context.js'
|
|
@@ -158,6 +170,7 @@ export {
|
|
|
158
170
|
latestRunId,
|
|
159
171
|
listInvocations,
|
|
160
172
|
listRuns,
|
|
173
|
+
inputWriters,
|
|
161
174
|
rootCauses,
|
|
162
175
|
whyDidThisRerun as whyDidThisRerunQuery,
|
|
163
176
|
} from './metrics.js'
|
|
@@ -173,6 +186,7 @@ export { resolveRunId, shortRunId } from './run-id.js'
|
|
|
173
186
|
export type {
|
|
174
187
|
CacheEntryRow,
|
|
175
188
|
CacheKeyDiff,
|
|
189
|
+
InputWriter,
|
|
176
190
|
RootCause,
|
|
177
191
|
CacheKeyExplanation,
|
|
178
192
|
InputDiffEntry,
|
|
@@ -52,7 +52,8 @@ export interface ShortCircuitArgs {
|
|
|
52
52
|
nodes: Map<string, TaskNode>
|
|
53
53
|
cache: CacheLayer
|
|
54
54
|
workspaceRoot: string
|
|
55
|
-
|
|
55
|
+
/** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
|
|
56
|
+
claimedFiles: readonly string[]
|
|
56
57
|
forwardArgs?: readonly string[] | undefined
|
|
57
58
|
nestedDirsByProject: Map<string, string[]>
|
|
58
59
|
gitFilesCache: GitFilesCache
|
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from './plugin.js'
|
|
26
26
|
|
|
27
27
|
export interface LockfileClaimOptions {
|
|
28
|
-
/** The
|
|
28
|
+
/** The lockfile's name at the workspace root. */
|
|
29
29
|
readonly file: string
|
|
30
30
|
/**
|
|
31
31
|
* Every project's digest from the file's text: importer directory
|
|
@@ -46,8 +46,7 @@ export interface LockfileClaimOptions {
|
|
|
46
46
|
readonly version: number
|
|
47
47
|
/**
|
|
48
48
|
* `'project'` (default): each task folds its own project's digest.
|
|
49
|
-
* `'workspace'`: the whole file's hash into every task — the coarse key
|
|
50
|
-
* core would fold, through the plugin.
|
|
49
|
+
* `'workspace'`: the whole file's hash into every task — the coarse key.
|
|
51
50
|
*/
|
|
52
51
|
readonly scope?: 'project' | 'workspace'
|
|
53
52
|
/**
|
|
@@ -55,18 +54,46 @@ export interface LockfileClaimOptions {
|
|
|
55
54
|
* (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
|
|
56
55
|
*/
|
|
57
56
|
readonly part?: string
|
|
57
|
+
/**
|
|
58
|
+
* Each importer's links to other importers, from the same text, kept in
|
|
59
|
+
* the memo beside the digests so a `discover` hook reads them without a
|
|
60
|
+
* parse. Called once per distinct content, under either scope.
|
|
61
|
+
*/
|
|
62
|
+
readonly importers?: (text: string) => ReadonlyMap<string, LockfileImporter>
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* What a lockfile records of one importer's links to the workspace's other
|
|
67
|
+
* importers: dependency name → importer directory (root-relative POSIX,
|
|
68
|
+
* `.` for the root).
|
|
69
|
+
*/
|
|
70
|
+
export interface LockfileImporter {
|
|
71
|
+
/** The entries it installs that resolve to an importer. */
|
|
72
|
+
readonly deps: Readonly<Record<string, string>>
|
|
73
|
+
/** Its peers that resolve to an importer, each with no installed entry of the same name. */
|
|
74
|
+
readonly peers: Readonly<Record<string, string>>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What a lockfile plugin spreads into `definePlugin` (`fingerprint`, `key`), and `importers` for its own stages. */
|
|
61
78
|
export interface LockfileClaimHooks {
|
|
62
79
|
readonly fingerprint: FingerprintClaim
|
|
63
80
|
key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
|
|
81
|
+
/**
|
|
82
|
+
* `importers`' answer for the file as it is now, through the same memo
|
|
83
|
+
* and gates as `key`; undefined when the file is absent. Not a hook: a
|
|
84
|
+
* plugin calls it from its own `discover` or `project`.
|
|
85
|
+
*/
|
|
86
|
+
importers(ctx: {
|
|
87
|
+
readonly workspaceRoot: string
|
|
88
|
+
readonly cacheDir: string
|
|
89
|
+
}): Promise<ReadonlyMap<string, LockfileImporter> | undefined>
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
interface Digests {
|
|
67
93
|
/** xxh3 of the lockfile bytes; '' when the file is absent. */
|
|
68
94
|
readonly lock: string
|
|
69
95
|
readonly importers: ReadonlyMap<string, string>
|
|
96
|
+
readonly edges: ReadonlyMap<string, LockfileImporter>
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
interface Memo {
|
|
@@ -76,6 +103,7 @@ interface Memo {
|
|
|
76
103
|
/** `extraFiles` → content hash when the digests were computed. */
|
|
77
104
|
extras?: Record<string, string>
|
|
78
105
|
importers: Record<string, string>
|
|
106
|
+
edges?: Record<string, LockfileImporter>
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
/** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
|
|
@@ -125,7 +153,16 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
125
153
|
// at the same number read this one's memo: one that named no extra file
|
|
126
154
|
// left the other's patch edits out of its key (X-130). The functions'
|
|
127
155
|
// source stands for the claimant; a rebuild that changes it is a miss.
|
|
128
|
-
|
|
156
|
+
// The scope too: a workspace-scope memo holds no digests.
|
|
157
|
+
const claimant = xxh3hex(
|
|
158
|
+
[
|
|
159
|
+
part,
|
|
160
|
+
scope,
|
|
161
|
+
String(digest),
|
|
162
|
+
String(options.extraFiles ?? ''),
|
|
163
|
+
String(options.importers ?? ''),
|
|
164
|
+
].join('\0'),
|
|
165
|
+
)
|
|
129
166
|
if (scope !== 'project' && scope !== 'workspace') {
|
|
130
167
|
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
131
168
|
}
|
|
@@ -149,7 +186,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
149
186
|
try {
|
|
150
187
|
identity = identityOf(await stat(full))
|
|
151
188
|
} catch {
|
|
152
|
-
const digests: Digests = { lock: '', importers: new Map() }
|
|
189
|
+
const digests: Digests = { lock: '', importers: new Map(), edges: new Map() }
|
|
153
190
|
seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
|
|
154
191
|
return digests
|
|
155
192
|
}
|
|
@@ -169,12 +206,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
169
206
|
let extras: Record<string, string> = {}
|
|
170
207
|
let extraStats: Record<string, string> = {}
|
|
171
208
|
let importers: ReadonlyMap<string, string> | undefined
|
|
209
|
+
let edges: ReadonlyMap<string, LockfileImporter> = new Map()
|
|
172
210
|
if (memo !== undefined) {
|
|
173
211
|
const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
|
|
174
212
|
if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
|
|
175
213
|
extras = memo.extras
|
|
176
214
|
extraStats = now.stats
|
|
177
215
|
importers = memo.importers
|
|
216
|
+
edges = memo.edges
|
|
178
217
|
}
|
|
179
218
|
}
|
|
180
219
|
if (importers === undefined) {
|
|
@@ -186,19 +225,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
186
225
|
importers =
|
|
187
226
|
scope === 'workspace'
|
|
188
227
|
? new Map<string, string>()
|
|
189
|
-
:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
228
|
+
: digest(text, new Map(Object.entries(extras)))
|
|
229
|
+
edges = options.importers?.(text) ?? new Map()
|
|
230
|
+
// Workspace scope with no edges to keep has nothing a memo saves.
|
|
231
|
+
if (scope === 'project' || options.importers !== undefined) {
|
|
232
|
+
await writeMemo(memoFile, { version, claimant, lock: lockHash }, extras, importers, edges)
|
|
233
|
+
}
|
|
195
234
|
}
|
|
196
235
|
const rels = Object.keys(extras)
|
|
197
236
|
const lock =
|
|
198
237
|
rels.length === 0
|
|
199
238
|
? lockHash
|
|
200
239
|
: xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
|
|
201
|
-
const digests: Digests = { lock, importers }
|
|
240
|
+
const digests: Digests = { lock, importers, edges }
|
|
202
241
|
seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
|
|
203
242
|
return digests
|
|
204
243
|
}
|
|
@@ -216,7 +255,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
216
255
|
files: [file],
|
|
217
256
|
affected(change: FingerprintChange, ctx: FingerprintContext) {
|
|
218
257
|
// One side absent: the file appeared or went — every project's
|
|
219
|
-
// installed tree is in question
|
|
258
|
+
// installed tree is in question.
|
|
220
259
|
if (change.before === null || change.after === null) return undefined
|
|
221
260
|
if (scope === 'workspace') return undefined
|
|
222
261
|
// WHICH side failed is the whole difference between "your
|
|
@@ -248,6 +287,10 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
248
287
|
[part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
|
|
249
288
|
}
|
|
250
289
|
},
|
|
290
|
+
async importers(ctx) {
|
|
291
|
+
const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
|
|
292
|
+
return digests.lock === '' ? undefined : digests.edges
|
|
293
|
+
},
|
|
251
294
|
}
|
|
252
295
|
}
|
|
253
296
|
|
|
@@ -280,25 +323,42 @@ async function readMemo(
|
|
|
280
323
|
version: number,
|
|
281
324
|
claimant: string,
|
|
282
325
|
lock: string,
|
|
283
|
-
): Promise<
|
|
326
|
+
): Promise<
|
|
327
|
+
| {
|
|
328
|
+
extras: Record<string, string>
|
|
329
|
+
importers: Map<string, string>
|
|
330
|
+
edges: Map<string, LockfileImporter>
|
|
331
|
+
}
|
|
332
|
+
| undefined
|
|
333
|
+
> {
|
|
284
334
|
try {
|
|
285
335
|
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
286
336
|
if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
|
|
287
337
|
return undefined
|
|
288
338
|
}
|
|
289
|
-
return {
|
|
339
|
+
return {
|
|
340
|
+
extras: memo.extras ?? {},
|
|
341
|
+
importers: new Map(Object.entries(memo.importers)),
|
|
342
|
+
edges: new Map(Object.entries(memo.edges ?? {})),
|
|
343
|
+
}
|
|
290
344
|
} catch {
|
|
291
345
|
return undefined
|
|
292
346
|
}
|
|
293
347
|
}
|
|
294
348
|
|
|
295
|
-
async function
|
|
349
|
+
async function writeMemo(
|
|
296
350
|
memoFile: string,
|
|
297
351
|
identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
|
|
298
352
|
extras: Record<string, string>,
|
|
299
353
|
importers: ReadonlyMap<string, string>,
|
|
300
|
-
|
|
301
|
-
|
|
354
|
+
edges: ReadonlyMap<string, LockfileImporter>,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
const memo: Memo = {
|
|
357
|
+
...identity,
|
|
358
|
+
extras,
|
|
359
|
+
importers: Object.fromEntries(importers),
|
|
360
|
+
...(edges.size > 0 ? { edges: Object.fromEntries(edges) } : {}),
|
|
361
|
+
}
|
|
302
362
|
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
303
363
|
// concurrent runs each land a whole one.
|
|
304
364
|
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
@@ -309,7 +369,6 @@ async function computeAndMemo(
|
|
|
309
369
|
// The memo is a speed-up, never a requirement: the digests are
|
|
310
370
|
// computed either way, and the next run computes them again.
|
|
311
371
|
}
|
|
312
|
-
return importers
|
|
313
372
|
}
|
|
314
373
|
|
|
315
374
|
function decode(bytes: Uint8Array): string {
|