@vzn/vx 0.0.634 → 0.0.636
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 +51 -52
- package/docs/caching.md +14 -2
- package/docs/cli.md +94 -36
- package/docs/execution.md +1 -1
- package/docs/features.md +4 -3
- package/docs/patterns.md +8 -7
- package/docs/schema.md +73 -3
- package/docs/security.md +7 -1
- package/launcher.cjs +0 -0
- package/launcher.sh +50 -0
- package/package.json +17 -8
- package/schemas/last.json +5 -1
- package/schemas/show.json +8 -0
- package/schemas/why.json +4 -1
- package/src/cache/layered-cache.ts +3 -1
- package/src/cli/help.ts +3 -1
- package/src/cli/last.ts +9 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +35 -7
- package/src/cli/select.ts +17 -11
- package/src/cli/show.ts +2 -2
- package/src/config.ts +36 -3
- package/src/graph/scheduler.ts +14 -3
- package/src/graph/task-graph.ts +22 -5
- package/src/index.ts +7 -1
- package/src/orchestrator/execute-task.ts +62 -6
- package/src/orchestrator/framed-output.ts +27 -1
- package/src/orchestrator/history.ts +75 -1
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/options.ts +2 -0
- package/src/orchestrator/path-links.ts +5 -3
- package/src/orchestrator/plugin.ts +3 -1
- package/src/orchestrator/prepare.ts +17 -14
- package/src/orchestrator/run-failures.ts +20 -3
- package/src/orchestrator/run.ts +25 -2
- package/src/orchestrator/sandbox-fix.ts +123 -0
- package/src/orchestrator/task-hash.ts +7 -1
- package/src/workspace/affected.ts +16 -4
- package/src/workspace/config-schema.ts +85 -4
- package/src/workspace/index.ts +2 -0
- package/src/workspace/migration.ts +51 -4
- package/src/workspace/project-loader.ts +3 -1
package/src/config.ts
CHANGED
|
@@ -42,7 +42,8 @@ export interface WorkspaceConfig {
|
|
|
42
42
|
* then the trusted ones, and writes go to the scope's only, so a PR never
|
|
43
43
|
* writes what the default branch reads. A client-side convention, not a
|
|
44
44
|
* security boundary: only a cache server that scopes writes by token
|
|
45
|
-
* can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it
|
|
45
|
+
* can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it, and
|
|
46
|
+
* `vx run --cache-scope` beats both. Never
|
|
46
47
|
* folded into a cache key.
|
|
47
48
|
*/
|
|
48
49
|
cacheScope?: string
|
|
@@ -55,6 +56,12 @@ export interface WorkspaceConfig {
|
|
|
55
56
|
* `schedule` plugin's weights still come first; this breaks their ties.
|
|
56
57
|
*/
|
|
57
58
|
schedule?: 'most-work' | 'critical-path' | 'direct-dependents' | 'ready-order'
|
|
59
|
+
/**
|
|
60
|
+
* The `cache.inputStability` of every task that does not set its own
|
|
61
|
+
* field. `VX_INPUT_STABILITY` / `VX_INPUT_STABILITY_SEVERITY` override
|
|
62
|
+
* both for the run.
|
|
63
|
+
*/
|
|
64
|
+
inputStability?: InputStability
|
|
58
65
|
/**
|
|
59
66
|
* Graph rules a run checks before it schedules anything. Each is on unless
|
|
60
67
|
* set to `false`; turning one off allows a shape vx handles correctly but
|
|
@@ -65,9 +72,11 @@ export interface WorkspaceConfig {
|
|
|
65
72
|
* Plugins registered for this workspace, consulted in this order once
|
|
66
73
|
* per `vx run`: each fills any of the pipeline stages and capabilities
|
|
67
74
|
* below, and core's own executor and cache store sit at the tail of
|
|
68
|
-
* every list. See `docs/design/pipeline-2026-09.md`.
|
|
75
|
+
* every list. See `docs/design/pipeline-2026-09.md`. An `undefined`,
|
|
76
|
+
* `null` or `false` entry is skipped, so a factory that declines
|
|
77
|
+
* (`github()` off Actions) or `cond && plugin()` needs no filter.
|
|
69
78
|
*/
|
|
70
|
-
plugins?: readonly Plugin[]
|
|
79
|
+
plugins?: readonly (Plugin | undefined | null | false)[]
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
/** `WorkspaceConfig.rules`. */
|
|
@@ -488,6 +497,30 @@ export interface CacheConfig {
|
|
|
488
497
|
inputs: CacheInputs
|
|
489
498
|
/** What this task produces. Captured for restore on a cache hit. */
|
|
490
499
|
outputs: CacheOutputs
|
|
500
|
+
/**
|
|
501
|
+
* What a write to an input while the command runs means. Each field,
|
|
502
|
+
* highest first: its env var, this, the workspace's `inputStability`,
|
|
503
|
+
* the default (`'immutable'`, `'warn'`).
|
|
504
|
+
*/
|
|
505
|
+
inputStability?: InputStability
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export interface InputStability {
|
|
509
|
+
/**
|
|
510
|
+
* `'immutable'` (the default): any write to an input during the command
|
|
511
|
+
* withholds the entry, whatever the file holds after, since an edit
|
|
512
|
+
* undone mid-run cannot be told from no edit. `'mutable'`: a file
|
|
513
|
+
* rewritten to the bytes the key folded counts as unchanged, for a task
|
|
514
|
+
* that rewrites its own input identically (tsdown's `package.json`).
|
|
515
|
+
* `'unchecked'`: no check.
|
|
516
|
+
*/
|
|
517
|
+
strategy?: 'immutable' | 'mutable' | 'unchecked'
|
|
518
|
+
/**
|
|
519
|
+
* What a withheld entry does to the run. `'warn'` (the default): the
|
|
520
|
+
* result stands, vx says why, and the run goes on. `'error'`: the task
|
|
521
|
+
* fails, so the run fails.
|
|
522
|
+
*/
|
|
523
|
+
severity?: 'warn' | 'error'
|
|
491
524
|
}
|
|
492
525
|
|
|
493
526
|
export interface CacheOutputs {
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -208,6 +208,15 @@ export interface TaskOutcome {
|
|
|
208
208
|
* inline in the task's block instead of as loose status output.
|
|
209
209
|
*/
|
|
210
210
|
sandboxViolationLines?: string[]
|
|
211
|
+
/** The `exec.sandbox.allow` entries that would have granted the denials. */
|
|
212
|
+
sandboxFix?: {
|
|
213
|
+
read?: string[]
|
|
214
|
+
write?: string[]
|
|
215
|
+
network?: string[]
|
|
216
|
+
systemInfo?: string[]
|
|
217
|
+
/** Credential paths denied and left out of the grants: an agent applies the fix as given. */
|
|
218
|
+
withheld?: string[]
|
|
219
|
+
}
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
@@ -804,14 +813,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
804
813
|
// tasks are never asked, so they never park.
|
|
805
814
|
const takeFitting = (): string | undefined => {
|
|
806
815
|
if (holding) return undefined
|
|
807
|
-
// No pools
|
|
808
|
-
//
|
|
816
|
+
// No pools and a full exec lane: nothing on the exec queue can be
|
|
817
|
+
// admitted, so it is not scanned — an admit policy only narrows
|
|
818
|
+
// the count gate, never widens it (scanning under one cost the
|
|
819
|
+
// history plugin's cold bench run 10 s, 2026-10-09) — scanning it
|
|
809
820
|
// would pop and re-park every ready exec task on every tick, and
|
|
810
821
|
// with a wide frontier that is O(R) per tick, O(R²) per run (the
|
|
811
822
|
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
812
823
|
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
813
824
|
// This is the legacy O(1) gate, kept per lane.
|
|
814
|
-
const execAdmissible = poolOf !== undefined ||
|
|
825
|
+
const execAdmissible = poolOf !== undefined || execRoom()
|
|
815
826
|
if (execAdmissible) {
|
|
816
827
|
while (execReady.size > 0) {
|
|
817
828
|
const seq = execReady.peekSeq()
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -302,6 +302,7 @@ export interface BuildGraphOptions {
|
|
|
302
302
|
export function undeclaredDepsError(taskId: string, name: string): UserError {
|
|
303
303
|
return new UserError(
|
|
304
304
|
`Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
|
|
305
|
+
'VX_E_DEPENDS_ON',
|
|
305
306
|
)
|
|
306
307
|
}
|
|
307
308
|
|
|
@@ -492,7 +493,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
492
493
|
spec = parseDependencySpec(raw)
|
|
493
494
|
} catch (err) {
|
|
494
495
|
if (err instanceof DependencySpecError) {
|
|
495
|
-
throw new UserError(`Task ${id}: ${err.message}
|
|
496
|
+
throw new UserError(`Task ${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
|
|
496
497
|
}
|
|
497
498
|
throw err
|
|
498
499
|
}
|
|
@@ -503,14 +504,21 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
503
504
|
// operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
|
|
504
505
|
// they name a namespace of tasks to add (Nx 19.5 parity).
|
|
505
506
|
if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
|
|
506
|
-
throw new UserError(
|
|
507
|
+
throw new UserError(
|
|
508
|
+
`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
|
|
509
|
+
'VX_E_DEPENDS_ON',
|
|
510
|
+
)
|
|
507
511
|
}
|
|
508
512
|
if (spec.negated) {
|
|
509
|
-
throw new UserError(
|
|
513
|
+
throw new UserError(
|
|
514
|
+
`Task ${id}: dependsOn does not accept negation (got "${raw}")`,
|
|
515
|
+
'VX_E_DEPENDS_ON',
|
|
516
|
+
)
|
|
510
517
|
}
|
|
511
518
|
if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
|
|
512
519
|
throw new UserError(
|
|
513
520
|
`Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
|
|
521
|
+
'VX_E_DEPENDS_ON',
|
|
514
522
|
)
|
|
515
523
|
}
|
|
516
524
|
if (spec.kind === 'self') {
|
|
@@ -530,6 +538,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
530
538
|
if (!visit(frame, projectName, spec.task, false)) {
|
|
531
539
|
throw new UserError(
|
|
532
540
|
`Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
|
|
541
|
+
'VX_E_DEPENDS_ON',
|
|
533
542
|
)
|
|
534
543
|
}
|
|
535
544
|
goPast(frame, projectName, spec.task, new Set([projectName]))
|
|
@@ -594,9 +603,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
594
603
|
if (spec.project === '//')
|
|
595
604
|
throw new UserError(
|
|
596
605
|
`Task ${id} depends on //#${spec.task}, Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config with that task`,
|
|
606
|
+
'VX_E_DEPENDS_ON',
|
|
597
607
|
)
|
|
598
608
|
throw new UserError(
|
|
599
609
|
`Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
|
|
610
|
+
'VX_E_DEPENDS_ON',
|
|
600
611
|
)
|
|
601
612
|
}
|
|
602
613
|
goPast(frame, spec.project, spec.task, new Set([projectName, spec.project]))
|
|
@@ -1109,7 +1120,7 @@ function collideAcross(
|
|
|
1109
1120
|
`cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
1110
1121
|
`cache-hit restore, so whichever of these runs second DELETES the other's output`
|
|
1111
1122
|
if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
|
|
1112
|
-
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}
|
|
1123
|
+
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
|
|
1113
1124
|
}
|
|
1114
1125
|
if (reaches(files.id, ws.id)) {
|
|
1115
1126
|
;(files.addsToOutputsOf ??= []).push(ws.id)
|
|
@@ -1132,11 +1143,13 @@ function collideAcross(
|
|
|
1132
1143
|
`${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
|
|
1133
1144
|
`cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
|
|
1134
1145
|
`and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
|
|
1146
|
+
'VX_E_OUTPUT_OVERLAP',
|
|
1135
1147
|
)
|
|
1136
1148
|
}
|
|
1137
1149
|
throw new UserError(
|
|
1138
1150
|
`${head} and the run still reports success. Give each task its own output path` +
|
|
1139
1151
|
(exclusive ? '.' : ', or make one depend on the other.'),
|
|
1152
|
+
'VX_E_OUTPUT_OVERLAP',
|
|
1140
1153
|
)
|
|
1141
1154
|
}
|
|
1142
1155
|
}
|
|
@@ -1373,7 +1386,8 @@ function collide(
|
|
|
1373
1386
|
// `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1374
1387
|
// in either order, and the refusal below stands.
|
|
1375
1388
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1376
|
-
if (up !== undefined && exclusive)
|
|
1389
|
+
if (up !== undefined && exclusive)
|
|
1390
|
+
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
|
|
1377
1391
|
if (up !== undefined && down !== undefined) {
|
|
1378
1392
|
const downGlobs = splitNegations(
|
|
1379
1393
|
(field === 'files'
|
|
@@ -1386,6 +1400,7 @@ function collide(
|
|
|
1386
1400
|
}
|
|
1387
1401
|
throw new UserError(
|
|
1388
1402
|
`${head} and the run still reports success. Give each task its own output path.`,
|
|
1403
|
+
'VX_E_OUTPUT_OVERLAP',
|
|
1389
1404
|
)
|
|
1390
1405
|
}
|
|
1391
1406
|
}
|
|
@@ -1440,6 +1455,7 @@ function refuseSelfClean(node: TaskNode): void {
|
|
|
1440
1455
|
`its own output — vx removes a task's outputs before it runs, so the task would ` +
|
|
1441
1456
|
`delete its own sources. A task that rewrites files in place (a formatter) declares ` +
|
|
1442
1457
|
`no outputs.`,
|
|
1458
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1443
1459
|
)
|
|
1444
1460
|
}
|
|
1445
1461
|
}
|
|
@@ -1663,6 +1679,7 @@ function readsOutputs(
|
|
|
1663
1679
|
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1664
1680
|
`cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
|
|
1665
1681
|
`let it wait for its producer.`,
|
|
1682
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1666
1683
|
)
|
|
1667
1684
|
}
|
|
1668
1685
|
}
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export type {
|
|
|
48
48
|
CacheConfig,
|
|
49
49
|
CacheInputs,
|
|
50
50
|
CacheOutputs,
|
|
51
|
+
InputStability,
|
|
51
52
|
SandboxConfig,
|
|
52
53
|
SandboxGrants,
|
|
53
54
|
SandboxDenials,
|
|
@@ -65,7 +66,12 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
|
|
|
65
66
|
// (`@vzn/vx-schedule-history` does; a plugin package reaches core only
|
|
66
67
|
// through this façade, which is what put these here).
|
|
67
68
|
export { LocalHistoryProvider } from './orchestrator/index.js'
|
|
68
|
-
export type {
|
|
69
|
+
export type {
|
|
70
|
+
HistoryProvider,
|
|
71
|
+
HistoryTable,
|
|
72
|
+
TaskHistory,
|
|
73
|
+
TaskResources,
|
|
74
|
+
} from './orchestrator/index.js'
|
|
69
75
|
export type { PreparedRun } from './orchestrator/index.js'
|
|
70
76
|
// `RunOptions.affected`: what `--affected`'s diff changed, per project.
|
|
71
77
|
export type { AffectedChanges } from './workspace/index.js'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
|
-
import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
|
|
2
|
+
import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
|
|
3
3
|
import {
|
|
4
4
|
ArtifactVanishedError,
|
|
5
5
|
type CacheEntry,
|
|
@@ -78,6 +78,7 @@ import type { FingerprintWatch } from './fingerprint-watch.js'
|
|
|
78
78
|
import { restoreHit } from './hit-restore.js'
|
|
79
79
|
import type { MissExplainer } from './miss-reason.js'
|
|
80
80
|
import { shellVerdict } from './shell-verdict.js'
|
|
81
|
+
import { sandboxFix } from './sandbox-fix.js'
|
|
81
82
|
// The hit path's entry stays importable from here (tests).
|
|
82
83
|
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
83
84
|
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
@@ -174,6 +175,11 @@ export interface ExecuteArgs {
|
|
|
174
175
|
* `exec.timeout` wins. Threaded as an option only — never hashed.
|
|
175
176
|
*/
|
|
176
177
|
timeout?: number
|
|
178
|
+
/**
|
|
179
|
+
* The run's `inputStability` layers around the task's own: `run` from
|
|
180
|
+
* the env vars (above the task), `workspace` from `vx.workspace` (below).
|
|
181
|
+
*/
|
|
182
|
+
inputStability?: { run: InputStability; workspace?: InputStability }
|
|
177
183
|
/** Per-run memo for `git ls-files` (one entry per project dir). */
|
|
178
184
|
gitFilesCache?: GitFilesCache
|
|
179
185
|
/** Per-run memo for derived hashes (package.json bytes + task config). */
|
|
@@ -336,6 +342,24 @@ async function restoreOrMiss(
|
|
|
336
342
|
}
|
|
337
343
|
}
|
|
338
344
|
|
|
345
|
+
/**
|
|
346
|
+
* What a write to an input during the command means, each field highest
|
|
347
|
+
* first: the env var (`layers.run`), the task's `cache.inputStability`,
|
|
348
|
+
* the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
|
|
349
|
+
* by default any such write withholds the save, on CI too, since a
|
|
350
|
+
* concurrent task can change an input and put it back.
|
|
351
|
+
*/
|
|
352
|
+
function inputStabilityOf(
|
|
353
|
+
cache: CacheConfig | undefined,
|
|
354
|
+
layers: ExecuteArgs['inputStability'],
|
|
355
|
+
): Required<InputStability> {
|
|
356
|
+
const task = cache?.inputStability
|
|
357
|
+
return {
|
|
358
|
+
strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
|
|
359
|
+
severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
339
363
|
/**
|
|
340
364
|
* The workspace fingerprint every key here folded has moved (a task
|
|
341
365
|
* rewrote the lockfile), said once per run.
|
|
@@ -1449,6 +1473,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1449
1473
|
|
|
1450
1474
|
const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
|
|
1451
1475
|
|
|
1476
|
+
const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
|
|
1477
|
+
let movedFails = false
|
|
1452
1478
|
let own: Awaited<ReturnType<typeof ownOutputs>>
|
|
1453
1479
|
if (effectiveExitCode === 0 && willSave && deferralRequested) {
|
|
1454
1480
|
// The outputs never landed here: no artifact, no rows. A partial local
|
|
@@ -1558,7 +1584,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1558
1584
|
*/
|
|
1559
1585
|
async function movedSinceKey(): Promise<string | null | undefined> {
|
|
1560
1586
|
if (described!.hash !== hash) return null
|
|
1561
|
-
return (
|
|
1587
|
+
return (
|
|
1588
|
+
(await movedInput(
|
|
1589
|
+
described!.facts,
|
|
1590
|
+
cache,
|
|
1591
|
+
described!.describedAt,
|
|
1592
|
+
stability.strategy === 'mutable',
|
|
1593
|
+
)) ?? described!.added()
|
|
1594
|
+
)
|
|
1562
1595
|
}
|
|
1563
1596
|
|
|
1564
1597
|
/**
|
|
@@ -1577,6 +1610,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1577
1610
|
unkeyed = true
|
|
1578
1611
|
return false
|
|
1579
1612
|
}
|
|
1613
|
+
// `'unchecked'` skips the check of the command's own window; a key the
|
|
1614
|
+
// re-derive before the command already disagreed with still withholds,
|
|
1615
|
+
// since it never described the bytes the command read.
|
|
1616
|
+
if (stability.strategy === 'unchecked' && described!.hash === hash) return true
|
|
1580
1617
|
const endCheck = span('miss: recheck inputs')
|
|
1581
1618
|
const moved = await movedSinceKey()
|
|
1582
1619
|
endCheck()
|
|
@@ -1591,15 +1628,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1591
1628
|
moved === null || path.basename(moved) === 'package.json'
|
|
1592
1629
|
? ''
|
|
1593
1630
|
: '; if the task writes it, declare it in cache.outputs'
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
`
|
|
1597
|
-
|
|
1631
|
+
if (stability.severity === 'error') {
|
|
1632
|
+
log.status(
|
|
1633
|
+
`[vx] ${node.id}: ${what} changed after its key was taken — the result is not saved, ` +
|
|
1634
|
+
`and inputStability.severity is 'error', so the task fails${hint}`,
|
|
1635
|
+
)
|
|
1636
|
+
movedFails = true
|
|
1637
|
+
} else {
|
|
1638
|
+
log.status(
|
|
1639
|
+
`[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
|
|
1640
|
+
`but is not saved under a key that no longer describes it${hint}`,
|
|
1641
|
+
)
|
|
1642
|
+
}
|
|
1598
1643
|
forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
|
|
1599
1644
|
unkeyed = true
|
|
1600
1645
|
return false
|
|
1601
1646
|
}
|
|
1602
1647
|
|
|
1648
|
+
if (movedFails) effectiveExitCode = 1
|
|
1649
|
+
|
|
1603
1650
|
if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
|
|
1604
1651
|
log.status(
|
|
1605
1652
|
`[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
|
|
@@ -1608,6 +1655,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1608
1655
|
}
|
|
1609
1656
|
|
|
1610
1657
|
const finalViolations = violations
|
|
1658
|
+
const fixOf = (fix: TaskOutcome['sandboxFix']) => (fix === undefined ? {} : { sandboxFix: fix })
|
|
1611
1659
|
|
|
1612
1660
|
return {
|
|
1613
1661
|
node,
|
|
@@ -1640,6 +1688,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1640
1688
|
sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
|
|
1641
1689
|
// A denied path is the task's own spelling, a secret in it too.
|
|
1642
1690
|
sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
|
|
1691
|
+
...fixOf(
|
|
1692
|
+
sandboxFix(
|
|
1693
|
+
finalViolations,
|
|
1694
|
+
node.projectDir,
|
|
1695
|
+
(t) => secrets?.mask(t) ?? t,
|
|
1696
|
+
cacheCfg?.inputs.files,
|
|
1697
|
+
),
|
|
1698
|
+
),
|
|
1643
1699
|
}
|
|
1644
1700
|
: {}),
|
|
1645
1701
|
}
|
|
@@ -32,6 +32,7 @@ import { paint, type ColorSupport } from './colors.js'
|
|
|
32
32
|
import { neverStarted } from './summary.js'
|
|
33
33
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
34
|
import { fileMemo, linkPaths } from './path-links.js'
|
|
35
|
+
import { sandboxFixLine } from './sandbox-fix.js'
|
|
35
36
|
|
|
36
37
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
37
38
|
|
|
@@ -529,7 +530,32 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
529
530
|
// every record for telemetry.
|
|
530
531
|
const body = [...new Set(vlines)]
|
|
531
532
|
// Red, not amber: a violation fails the task, so it reads as a failure.
|
|
532
|
-
|
|
533
|
+
// vx's note, after the evidence: the grants that would have allowed it.
|
|
534
|
+
// A read the output depends on is a key input too; the sandbox never
|
|
535
|
+
// adds one (owner, 2026-10-09).
|
|
536
|
+
const fix = outcome.sandboxFix
|
|
537
|
+
const line = fix === undefined ? undefined : sandboxFixLine(fix)
|
|
538
|
+
const dim = (text: string): string => paint('', text, colors, { dim: true })
|
|
539
|
+
const note =
|
|
540
|
+
fix === undefined
|
|
541
|
+
? []
|
|
542
|
+
: [
|
|
543
|
+
...(line !== undefined ? [dim(`fix: ${line}`)] : []),
|
|
544
|
+
...(fix.read !== undefined
|
|
545
|
+
? [dim('a read the output depends on belongs in cache.inputs.files too')]
|
|
546
|
+
: []),
|
|
547
|
+
...(fix.withheld !== undefined
|
|
548
|
+
? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
|
|
549
|
+
: []),
|
|
550
|
+
'',
|
|
551
|
+
]
|
|
552
|
+
return [
|
|
553
|
+
sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
|
|
554
|
+
'',
|
|
555
|
+
...body,
|
|
556
|
+
'',
|
|
557
|
+
...note,
|
|
558
|
+
]
|
|
533
559
|
}
|
|
534
560
|
|
|
535
561
|
export function formatFrameClose(
|
|
@@ -21,7 +21,8 @@ const DEFAULT_RECENT = 50
|
|
|
21
21
|
|
|
22
22
|
// A hit is recorded as `cache-hit` / `cache-hit-remote` and replays a
|
|
23
23
|
// success; the list is derived so a new status cannot miss it.
|
|
24
|
-
|
|
24
|
+
/** The pass set as SQL, `(…)`: one definition, `isPassStatus`. */
|
|
25
|
+
export const PASS_STATUSES = `(${TASK_STATUSES.filter(isPassStatus)
|
|
25
26
|
.map((s) => `'${s}'`)
|
|
26
27
|
.join(', ')})`
|
|
27
28
|
|
|
@@ -58,6 +59,12 @@ export interface TaskHistory {
|
|
|
58
59
|
/** Map keyed by `project#task`. */
|
|
59
60
|
export type HistoryTable = ReadonlyMap<string, TaskHistory>
|
|
60
61
|
|
|
62
|
+
/** What a scheduler learns from a task's history: its p50 and its resource maxima. */
|
|
63
|
+
export type TaskResources = Pick<
|
|
64
|
+
TaskHistory,
|
|
65
|
+
'p50DurationMs' | 'maxPeakRssBytes' | 'maxCpuParallelism'
|
|
66
|
+
>
|
|
67
|
+
|
|
61
68
|
export interface HistoryProvider {
|
|
62
69
|
loadFor(taskIds: readonly string[]): Promise<HistoryTable>
|
|
63
70
|
/**
|
|
@@ -213,6 +220,73 @@ export class LocalHistoryProvider implements HistoryProvider {
|
|
|
213
220
|
return out
|
|
214
221
|
}
|
|
215
222
|
|
|
223
|
+
/**
|
|
224
|
+
* `loadFor`'s p50 and resource maxima over the same window, without its
|
|
225
|
+
* rates. The rates group every row of the window and join each hit to
|
|
226
|
+
* its entry: 407 ms at 168,000 rows (8,002 tasks, 21 runs), most of a
|
|
227
|
+
* nothing-changed run's schedule stage. Here the executed successes are
|
|
228
|
+
* grouped alone, and the hits only as distinct keys, each joined to its
|
|
229
|
+
* entry once: 148 ms on the same table, the same numbers.
|
|
230
|
+
*/
|
|
231
|
+
async resourcesFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, TaskResources>> {
|
|
232
|
+
const out = new Map<string, TaskResources>()
|
|
233
|
+
if (taskIds.length === 0) return out
|
|
234
|
+
const floor = this.floor()
|
|
235
|
+
const wanted = new Set(taskIds)
|
|
236
|
+
type Executed = {
|
|
237
|
+
project: string
|
|
238
|
+
task: string
|
|
239
|
+
ds: string
|
|
240
|
+
rss: number | null
|
|
241
|
+
cpu: number | null
|
|
242
|
+
}
|
|
243
|
+
const executed = this.db
|
|
244
|
+
.query(
|
|
245
|
+
`SELECT project, task, GROUP_CONCAT(duration_ms) AS ds, MAX(peak_rss_bytes) AS rss,
|
|
246
|
+
MAX(CASE WHEN cpu_ms IS NOT NULL AND duration_ms > 0
|
|
247
|
+
THEN cpu_ms * 1.0 / duration_ms END) AS cpu
|
|
248
|
+
FROM runs
|
|
249
|
+
WHERE id >= ? AND status = 'success' AND (cache_hit IS NULL OR cache_hit = 0)
|
|
250
|
+
GROUP BY project, task`,
|
|
251
|
+
)
|
|
252
|
+
.all(floor) as Executed[]
|
|
253
|
+
for (const row of executed) {
|
|
254
|
+
const key = `${row.project}#${row.task}`
|
|
255
|
+
if (!wanted.has(key)) continue
|
|
256
|
+
const durations = row.ds.split(',').map(Number)
|
|
257
|
+
durations.sort((a, b) => a - b)
|
|
258
|
+
out.set(key, {
|
|
259
|
+
p50DurationMs: pickPercentile(durations, 0.5),
|
|
260
|
+
...(row.rss !== null ? { maxPeakRssBytes: row.rss } : {}),
|
|
261
|
+
...(row.cpu !== null ? { maxCpuParallelism: row.cpu } : {}),
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
type Hit = { project: string; task: string; rss: number | null; cpu: number | null }
|
|
265
|
+
const hits = this.db
|
|
266
|
+
.query(
|
|
267
|
+
`SELECT h.project, h.task, MAX(e.peak_rss_bytes) AS rss,
|
|
268
|
+
MAX(CASE WHEN e.cpu_ms IS NOT NULL AND e.duration_ms > 0
|
|
269
|
+
THEN e.cpu_ms * 1.0 / e.duration_ms END) AS cpu
|
|
270
|
+
FROM (SELECT DISTINCT project, task, hash FROM runs WHERE id >= ? AND cache_hit = 1) h
|
|
271
|
+
JOIN entries e ON e.hash = h.hash
|
|
272
|
+
GROUP BY h.project, h.task`,
|
|
273
|
+
)
|
|
274
|
+
.all(floor) as Hit[]
|
|
275
|
+
for (const row of hits) {
|
|
276
|
+
const key = `${row.project}#${row.task}`
|
|
277
|
+
if (!wanted.has(key) || (row.rss === null && row.cpu === null)) continue
|
|
278
|
+
const had = out.get(key)
|
|
279
|
+
const rss = Math.max(had?.maxPeakRssBytes ?? -Infinity, row.rss ?? -Infinity)
|
|
280
|
+
const cpu = Math.max(had?.maxCpuParallelism ?? -Infinity, row.cpu ?? -Infinity)
|
|
281
|
+
out.set(key, {
|
|
282
|
+
p50DurationMs: had?.p50DurationMs,
|
|
283
|
+
...(rss !== -Infinity ? { maxPeakRssBytes: rss } : {}),
|
|
284
|
+
...(cpu !== -Infinity ? { maxCpuParallelism: cpu } : {}),
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
289
|
+
|
|
216
290
|
/** The first `runs` id of the newest `recent` invocations (see `loadFor`). */
|
|
217
291
|
private floor(): number {
|
|
218
292
|
const row = this.db
|
|
@@ -86,6 +86,8 @@ export interface RunOptions {
|
|
|
86
86
|
* saying it is not.
|
|
87
87
|
*/
|
|
88
88
|
remoteRequested?: boolean
|
|
89
|
+
/** This run's `cacheScope` (the CLI's `--cache-scope`), above `VX_CACHE_SCOPE` and `vx.workspace`. */
|
|
90
|
+
cacheScope?: string
|
|
89
91
|
/**
|
|
90
92
|
* The `cacheScope` a run takes when `vx.workspace` names none. The CLI
|
|
91
93
|
* passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
|
|
@@ -35,7 +35,9 @@ export function linkPaths(text: string, dir: string, isFile: (abs: string) => bo
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* The files `text` names that exist (resolved against `dir`), each with the
|
|
38
|
-
* line and column it gave, unique, in order of first appearance.
|
|
38
|
+
* line and column it gave, unique, in order of first appearance. A file also
|
|
39
|
+
* named with a line drops its bare mention: bun test heads a failure with
|
|
40
|
+
* `app.test.js:` and gives the line only in the stack below it.
|
|
39
41
|
*/
|
|
40
42
|
export function fileLocations(
|
|
41
43
|
text: string,
|
|
@@ -56,9 +58,9 @@ export function fileLocations(
|
|
|
56
58
|
...(line !== undefined ? { line: Number(line) } : {}),
|
|
57
59
|
...(col !== undefined ? { col: Number(col) } : {}),
|
|
58
60
|
})
|
|
59
|
-
if (out.length === LOCATIONS_CAP) break
|
|
60
61
|
}
|
|
61
|
-
|
|
62
|
+
const lined = new Set(out.filter((l) => l.line !== undefined).map((l) => l.file))
|
|
63
|
+
return out.filter((l) => l.line !== undefined || !lined.has(l.file)).slice(0, LOCATIONS_CAP)
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** A file test that asks the file system once per path. */
|
|
@@ -127,7 +127,9 @@ export interface VxPlugin {
|
|
|
127
127
|
/**
|
|
128
128
|
* Scheduling priorities: task id → weight, higher runs first among READY
|
|
129
129
|
* tasks (merged over the structural baseline, which stays the tie-break).
|
|
130
|
-
* Runs once, after the graph is final
|
|
130
|
+
* Runs once, after the graph is final and the local hits are known, and
|
|
131
|
+
* only when a task will start: a run of confirmed local hits restores
|
|
132
|
+
* in no order a weight changes. A later plugin's weight for a task
|
|
131
133
|
* overrides an earlier one's. Return undefined to leave the baseline.
|
|
132
134
|
*/
|
|
133
135
|
schedule?(
|
|
@@ -102,9 +102,11 @@ export interface PreparedRun {
|
|
|
102
102
|
/**
|
|
103
103
|
* Scheduling priorities from plugins' `schedule` stage (task id → weight,
|
|
104
104
|
* merged over the structural baseline by the scheduler). Empty when no
|
|
105
|
-
* plugin declares the stage.
|
|
105
|
+
* plugin declares the stage. Asked only by a run with a task to start:
|
|
106
|
+
* the stage reads history (148 ms at 8,002 tasks) to order work a
|
|
107
|
+
* nothing-changed run never dispatches. Runs the stage once.
|
|
106
108
|
*/
|
|
107
|
-
priorities: ReadonlyMap<string, number
|
|
109
|
+
priorities: () => Promise<ReadonlyMap<string, number>>
|
|
108
110
|
nodes: Map<string, TaskNode>
|
|
109
111
|
/**
|
|
110
112
|
* The tasks `--exclude-dependencies` took out of the schedule: keyed as a
|
|
@@ -286,13 +288,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
286
288
|
|
|
287
289
|
// The local cache opens BEFORE the configs load: it is also where their
|
|
288
290
|
// cached evaluations live.
|
|
289
|
-
// `VX_CACHE_SCOPE
|
|
291
|
+
// `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
|
|
290
292
|
// a CI job that knows its trust level says so without editing config.
|
|
291
293
|
const envScope = process.env['VX_CACHE_SCOPE'] || undefined
|
|
292
294
|
if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
|
|
293
295
|
const policy: CachePolicy = scopeCachePolicy(
|
|
294
296
|
options.cache ?? FULL_CACHE_POLICY,
|
|
295
|
-
envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
|
|
297
|
+
options.cacheScope ?? envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
|
|
296
298
|
)
|
|
297
299
|
const localCache = new Cache(
|
|
298
300
|
cacheDir,
|
|
@@ -491,7 +493,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
491
493
|
localCache,
|
|
492
494
|
hasRemoteLayer,
|
|
493
495
|
cachePolicy: policy,
|
|
494
|
-
priorities: new Map(),
|
|
496
|
+
priorities: () => Promise.resolve(new Map()),
|
|
495
497
|
nodes: new Map(),
|
|
496
498
|
keyOnly: new Map(),
|
|
497
499
|
unresolvedTasks,
|
|
@@ -687,15 +689,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
687
689
|
})
|
|
688
690
|
}
|
|
689
691
|
}
|
|
690
|
-
let
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
692
|
+
let scheduled: Promise<ReadonlyMap<string, number>> | undefined
|
|
693
|
+
const priorities = (): Promise<ReadonlyMap<string, number>> =>
|
|
694
|
+
(scheduled ??= hasHook(plugins, 'schedule')
|
|
695
|
+
? applyScheduleHooks(plugins, nodes, {
|
|
696
|
+
workspaceRoot,
|
|
697
|
+
cacheDir,
|
|
698
|
+
warn: (m) => log.status(m),
|
|
699
|
+
localCache,
|
|
700
|
+
})
|
|
701
|
+
: Promise.resolve(new Map()))
|
|
699
702
|
|
|
700
703
|
return {
|
|
701
704
|
workspaceRoot,
|