@vzn/vx 0.0.634 → 0.0.635
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 +7 -1
- package/docs/cli.md +102 -35
- package/docs/features.md +2 -1
- package/docs/patterns.md +8 -5
- package/docs/schema.md +34 -2
- package/docs/security.md +6 -0
- 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 +2 -1
- package/schemas/why.json +4 -1
- package/src/cache/layered-cache.ts +3 -1
- package/src/cli/last.ts +9 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +9 -6
- package/src/cli/select.ts +17 -11
- package/src/cli/show.ts +2 -2
- package/src/config.ts +14 -2
- package/src/graph/scheduler.ts +14 -3
- package/src/graph/task-graph.ts +22 -5
- package/src/index.ts +6 -1
- package/src/orchestrator/execute-task.ts +30 -1
- 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/path-links.ts +5 -3
- package/src/orchestrator/plugin.ts +3 -1
- package/src/orchestrator/prepare.ts +15 -12
- package/src/orchestrator/run-failures.ts +20 -3
- package/src/orchestrator/run.ts +11 -1
- 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 +14 -2
- package/src/workspace/migration.ts +51 -4
- package/src/workspace/project-loader.ts +3 -1
|
@@ -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
|
|
@@ -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
|
|
@@ -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,
|
|
@@ -9,6 +9,7 @@ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { decodeOutputLog } from './output-log.js'
|
|
12
|
+
import { PASS_STATUSES } from './history.js'
|
|
12
13
|
import { splitTaskId } from '../util/index.js'
|
|
13
14
|
import { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
|
|
14
15
|
|
|
@@ -23,6 +24,10 @@ export interface TaskFailure {
|
|
|
23
24
|
/** Plain text, bounded, secrets masked; '' when the run kept none. */
|
|
24
25
|
output: string
|
|
25
26
|
locations: OutputLocation[]
|
|
27
|
+
/** The `exec.sandbox.allow` entries that would have granted its denials. */
|
|
28
|
+
sandboxFix?: NonNullable<TaskOutcome['sandboxFix']>
|
|
29
|
+
/** The first later run where this task passed (ran or hit): the failure is history. */
|
|
30
|
+
fixedIn?: string
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
export interface RunFailures {
|
|
@@ -56,6 +61,7 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
|
|
|
56
61
|
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
57
62
|
output,
|
|
58
63
|
locations: fileLocations(output, o.node.projectDir, isFile),
|
|
64
|
+
...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
|
|
59
65
|
})
|
|
60
66
|
}
|
|
61
67
|
return out
|
|
@@ -110,10 +116,19 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
110
116
|
}
|
|
111
117
|
const rows = db
|
|
112
118
|
.query(
|
|
113
|
-
`SELECT project, task, exit_code AS exitCode, timed_out AS timedOut
|
|
114
|
-
|
|
119
|
+
`SELECT r.project, r.task, r.exit_code AS exitCode, r.timed_out AS timedOut,
|
|
120
|
+
(SELECT s.run_id FROM runs s WHERE s.project = r.project AND s.task = r.task
|
|
121
|
+
AND s.id > r.id AND s.status IN ${PASS_STATUSES}
|
|
122
|
+
ORDER BY s.id LIMIT 1) AS fixedIn
|
|
123
|
+
FROM runs r WHERE r.run_id = ? AND r.status = 'failed' ORDER BY r.id`,
|
|
115
124
|
)
|
|
116
|
-
.all(id) as {
|
|
125
|
+
.all(id) as {
|
|
126
|
+
project: string
|
|
127
|
+
task: string
|
|
128
|
+
exitCode: number
|
|
129
|
+
timedOut: number | null
|
|
130
|
+
fixedIn: string | null
|
|
131
|
+
}[]
|
|
117
132
|
return {
|
|
118
133
|
runId: id,
|
|
119
134
|
tasks: rows.map((r) => {
|
|
@@ -125,6 +140,8 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
125
140
|
...(r.timedOut === 1 ? { timedOut: true as const } : {}),
|
|
126
141
|
output: k?.output ?? '',
|
|
127
142
|
locations: k?.locations ?? [],
|
|
143
|
+
...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
|
|
144
|
+
...(r.fixedIn !== null ? { fixedIn: r.fixedIn } : {}),
|
|
128
145
|
}
|
|
129
146
|
}),
|
|
130
147
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -1089,6 +1089,14 @@ async function runOnBus(
|
|
|
1089
1089
|
})
|
|
1090
1090
|
|
|
1091
1091
|
mark('classify + probe')
|
|
1092
|
+
// A run whose every task is a confirmed local hit dispatches only
|
|
1093
|
+
// restores, which no weight orders: the `schedule` stage is not asked.
|
|
1094
|
+
const priorities = [...nodes.values()].every(
|
|
1095
|
+
(n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
|
|
1096
|
+
)
|
|
1097
|
+
? new Map<string, number>()
|
|
1098
|
+
: await prepared.priorities()
|
|
1099
|
+
mark('schedule')
|
|
1092
1100
|
// Judged as each task finishes, against the history BEFORE this run's
|
|
1093
1101
|
// rows land, so the row that prints next carries the verdict: nothing
|
|
1094
1102
|
// prints below the footer. A hit or a skip asks nothing; a green miss
|
|
@@ -1172,7 +1180,7 @@ async function runOnBus(
|
|
|
1172
1180
|
: {}),
|
|
1173
1181
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1174
1182
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1175
|
-
...(
|
|
1183
|
+
...(priorities.size > 0 ? { priorities } : {}),
|
|
1176
1184
|
// The baseline under them; `most-work` when the workspace names none.
|
|
1177
1185
|
...(prepared.workspaceConfig?.schedule !== undefined
|
|
1178
1186
|
? { strategy: prepared.workspaceConfig.schedule }
|
|
@@ -1718,6 +1726,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1718
1726
|
if (prepared.empty === 'none-affected')
|
|
1719
1727
|
return { tasks: [], noneAffected: noneAffected(prepared, options) }
|
|
1720
1728
|
if (prepared.empty !== null) return { tasks: [] }
|
|
1729
|
+
// The plan orders nothing, but it refuses what the run would refuse.
|
|
1730
|
+
await prepared.priorities()
|
|
1721
1731
|
// Its own mark: a dry run's plan (every task's hash, the cache lookups,
|
|
1722
1732
|
// the history p50s) was booked under `close`, the next mark, and read
|
|
1723
1733
|
// as 105 ms of closing a cache at 1,000 projects (item 601).
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// The `exec.sandbox.allow` entries that would have granted what a failed
|
|
2
|
+
// task was denied, so the report names the line to add (CLI, JSON, MCP)
|
|
3
|
+
// rather than leaving the user to turn absolute paths into grants. Built on
|
|
4
|
+
// the failure path only: a passing run never calls it.
|
|
5
|
+
|
|
6
|
+
import { lstatSync } from 'node:fs'
|
|
7
|
+
import os from 'node:os'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import type { SandboxViolation } from '../exec/index.js'
|
|
10
|
+
import type { TaskOutcome } from '../graph/index.js'
|
|
11
|
+
import { isLiteralPattern } from '../util/index.js'
|
|
12
|
+
|
|
13
|
+
/** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
|
|
14
|
+
type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
|
|
15
|
+
type Kind = Exclude<keyof SandboxFix, 'withheld'>
|
|
16
|
+
|
|
17
|
+
// Where credentials live. A denial here is never turned into a grant: an
|
|
18
|
+
// agent applies `sandboxFix` as given, so a dependency probing ~/.ssh would
|
|
19
|
+
// get its grant suggested and applied (Security, 2026-10-12).
|
|
20
|
+
const CREDENTIAL_DIRS = ['.ssh', '.aws', '.gnupg', '.kube', '.config/gh', '.config/gcloud']
|
|
21
|
+
const CREDENTIAL_FILES = ['.netrc', '.npmrc', '.docker/config.json', '.git-credentials', '.pypirc']
|
|
22
|
+
|
|
23
|
+
function isCredential(abs: string): boolean {
|
|
24
|
+
const base = path.basename(abs)
|
|
25
|
+
if (base === '.env' || base.startsWith('.env.')) return true
|
|
26
|
+
const rel = path.relative(os.homedir(), abs)
|
|
27
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
28
|
+
return (
|
|
29
|
+
CREDENTIAL_FILES.includes(rel) ||
|
|
30
|
+
CREDENTIAL_DIRS.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A path as a grant spells it: project-relative inside the project, `~/…`
|
|
36
|
+
* under home, else absolute.
|
|
37
|
+
*/
|
|
38
|
+
function grantPath(abs: string, projectDir: string): string {
|
|
39
|
+
const rel = path.relative(projectDir, abs)
|
|
40
|
+
if (rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)) return rel
|
|
41
|
+
const home = os.homedir()
|
|
42
|
+
return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A write grant covers the top directory the write landed in: granting
|
|
47
|
+
* `dist/a.js` alone fails the run again on `dist/b.js`. Not when the task
|
|
48
|
+
* reads that directory as an input: the grant would let it write its own
|
|
49
|
+
* inputs, so it names the path alone.
|
|
50
|
+
*/
|
|
51
|
+
function writeGrant(grant: string, isDir: boolean, inputs: readonly string[]): string {
|
|
52
|
+
const slash = grant.indexOf('/')
|
|
53
|
+
const exact = isDir ? `${grant}/` : grant
|
|
54
|
+
if (grant.startsWith('~/') || path.isAbsolute(grant) || slash === -1) return exact
|
|
55
|
+
const top = grant.slice(0, slash)
|
|
56
|
+
return inputs.some((g) => readsDir(g, top)) ? exact : `${top}/`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Whether input glob `g` can match under top directory `top`. */
|
|
60
|
+
function readsDir(g: string, top: string): boolean {
|
|
61
|
+
if (g.startsWith('!')) return false
|
|
62
|
+
const first = g.replace(/^\.\//, '').split('/')[0] ?? ''
|
|
63
|
+
return first === top || !isLiteralPattern(first)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function kindOf(v: SandboxViolation): Kind | undefined {
|
|
67
|
+
const can = v.ignorable ?? []
|
|
68
|
+
if (can.length === 1) return can[0]
|
|
69
|
+
// Linux reports a refused `openat` as either: the flags the parse drops
|
|
70
|
+
// decide. A file the host has was hidden from a read; one it lacks was
|
|
71
|
+
// being created.
|
|
72
|
+
if (can.includes('read') && can.includes('write') && v.path !== undefined)
|
|
73
|
+
return lstatSync(v.path, { throwIfNoEntry: false }) !== undefined ? 'read' : 'write'
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function sandboxFix(
|
|
78
|
+
violations: readonly SandboxViolation[],
|
|
79
|
+
projectDir: string,
|
|
80
|
+
mask: (text: string) => string = (t) => t,
|
|
81
|
+
/** The task's `cache.inputs.files`: a write under one is granted exactly. */
|
|
82
|
+
inputs: readonly string[] = [],
|
|
83
|
+
): SandboxFix | undefined {
|
|
84
|
+
const fix: Record<Kind | 'withheld', Set<string>> = {
|
|
85
|
+
read: new Set(),
|
|
86
|
+
write: new Set(),
|
|
87
|
+
network: new Set(),
|
|
88
|
+
systemInfo: new Set(),
|
|
89
|
+
withheld: new Set(),
|
|
90
|
+
}
|
|
91
|
+
for (const v of violations) {
|
|
92
|
+
if (v.hint === true || v.target === undefined) continue
|
|
93
|
+
const kind = kindOf(v)
|
|
94
|
+
if (kind === undefined) continue
|
|
95
|
+
if (kind === 'network') fix.network.add(v.target.replace(/:\d+$/, ''))
|
|
96
|
+
else if (kind === 'systemInfo') fix.systemInfo.add(v.target)
|
|
97
|
+
else if (v.path === undefined) continue
|
|
98
|
+
else if (isCredential(v.path)) fix.withheld.add(grantPath(v.path, projectDir))
|
|
99
|
+
else if (kind === 'read') fix.read.add(grantPath(v.path, projectDir))
|
|
100
|
+
else {
|
|
101
|
+
const isDir =
|
|
102
|
+
v.line.startsWith('mkdir') ||
|
|
103
|
+
lstatSync(v.path, { throwIfNoEntry: false })?.isDirectory() === true
|
|
104
|
+
fix.write.add(writeGrant(grantPath(v.path, projectDir), isDir, inputs))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const out: SandboxFix = {}
|
|
108
|
+
for (const k of ['read', 'write', 'network', 'systemInfo', 'withheld'] as const)
|
|
109
|
+
if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
|
|
110
|
+
return Object.keys(out).length > 0 ? out : undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
115
|
+
* or undefined when every denial was a withheld credential path.
|
|
116
|
+
*/
|
|
117
|
+
export function sandboxFixLine({ withheld: _, ...fix }: SandboxFix): string | undefined {
|
|
118
|
+
if (Object.keys(fix).length === 0) return undefined
|
|
119
|
+
const parts = Object.entries(fix).map(
|
|
120
|
+
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|
|
121
|
+
)
|
|
122
|
+
return `exec.sandbox.allow: { ${parts.join(', ')} }`
|
|
123
|
+
}
|
|
@@ -238,11 +238,15 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
238
238
|
* input changed and changed BACK while the command ran matched its digest
|
|
239
239
|
* again, and the entry saved the other content's output under the key (item
|
|
240
240
|
* 1015). A write before it — an upstream's — is judged by content, as before.
|
|
241
|
+
* `writesByBytes` (`cache.rewrittenInputs: 'compare'`) judges a write
|
|
242
|
+
* during the command by content too: the task says the same bytes are its
|
|
243
|
+
* own identical rewrite (tsdown's package.json), not an edit undone.
|
|
241
244
|
*/
|
|
242
245
|
export async function movedInput(
|
|
243
246
|
facts: readonly InputFact[],
|
|
244
247
|
cache: CacheLayer,
|
|
245
248
|
commandFrom?: number,
|
|
249
|
+
writesByBytes = false,
|
|
246
250
|
): Promise<string | undefined> {
|
|
247
251
|
const suspects: InputFact[] = []
|
|
248
252
|
for (const f of facts) {
|
|
@@ -260,7 +264,9 @@ export async function movedInput(
|
|
|
260
264
|
// an even one, FAT's tick), one during the command included
|
|
261
265
|
// (`racyWindowMs`).
|
|
262
266
|
if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
|
|
263
|
-
return f.path
|
|
267
|
+
if (!writesByBytes) return f.path
|
|
268
|
+
suspects.push(f)
|
|
269
|
+
continue
|
|
264
270
|
}
|
|
265
271
|
if (ctimeMs >= f.since - racyWindowMs(ctimeMs, FILE_HASH_RACY_MS)) suspects.push(f)
|
|
266
272
|
}
|
|
@@ -173,6 +173,7 @@ export async function affectedChanges(
|
|
|
173
173
|
if (since.length === 0 || since.startsWith('-')) {
|
|
174
174
|
throw new UserError(
|
|
175
175
|
`git ref "${since}" is not a ref: a base cannot be empty or start with "-".`,
|
|
176
|
+
'VX_E_AFFECTED_BASE',
|
|
176
177
|
)
|
|
177
178
|
}
|
|
178
179
|
// `A..B` / `A...B` reached `rev-parse --verify`, which refuses a range, and
|
|
@@ -183,6 +184,7 @@ export async function affectedChanges(
|
|
|
183
184
|
throw new UserError(
|
|
184
185
|
`git ref "${since}" is a range: ranges are not supported — pass the base alone ` +
|
|
185
186
|
`("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
|
|
187
|
+
'VX_E_AFFECTED_BASE',
|
|
186
188
|
)
|
|
187
189
|
}
|
|
188
190
|
// `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
|
|
@@ -191,6 +193,7 @@ export async function affectedChanges(
|
|
|
191
193
|
if (since.startsWith('^')) {
|
|
192
194
|
throw new UserError(
|
|
193
195
|
`git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
|
|
196
|
+
'VX_E_AFFECTED_BASE',
|
|
194
197
|
)
|
|
195
198
|
}
|
|
196
199
|
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
@@ -556,12 +559,13 @@ async function gitBlobsAt(
|
|
|
556
559
|
proc.exited,
|
|
557
560
|
])
|
|
558
561
|
if (exit !== 0) {
|
|
559
|
-
throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}
|
|
562
|
+
throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
|
|
560
563
|
}
|
|
561
564
|
let at = 0
|
|
562
565
|
for (const file of batched) {
|
|
563
566
|
const eol = out.indexOf(0x0a, at)
|
|
564
|
-
if (eol === -1)
|
|
567
|
+
if (eol === -1)
|
|
568
|
+
throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`, 'VX_E_GIT')
|
|
565
569
|
const header = new TextDecoder().decode(out.subarray(at, eol))
|
|
566
570
|
at = eol + 1
|
|
567
571
|
// `<oid> <type> <size>`, or `<spec> missing` / `<spec> ambiguous`.
|
|
@@ -636,7 +640,10 @@ async function gitBytesAt(
|
|
|
636
640
|
// "exists on disk but not in <ref>" and "path does not exist in <ref>"
|
|
637
641
|
// are the file being absent at the ref; anything else is git failing.
|
|
638
642
|
if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
|
|
639
|
-
throw new UserError(
|
|
643
|
+
throw new UserError(
|
|
644
|
+
`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`,
|
|
645
|
+
'VX_E_GIT',
|
|
646
|
+
)
|
|
640
647
|
}
|
|
641
648
|
|
|
642
649
|
async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
@@ -654,7 +661,7 @@ async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]>
|
|
|
654
661
|
const stderr = await new Response(proc.stderr).text()
|
|
655
662
|
const exit = await proc.exited
|
|
656
663
|
if (exit !== 0) {
|
|
657
|
-
throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}
|
|
664
|
+
throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}`, 'VX_E_GIT')
|
|
658
665
|
}
|
|
659
666
|
return stdout.split('\0').filter((s) => s.length > 0)
|
|
660
667
|
}
|
|
@@ -724,6 +731,7 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
|
|
|
724
731
|
'--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
|
|
725
732
|
'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
|
|
726
733
|
'base: --affected=origin/main',
|
|
734
|
+
'VX_E_AFFECTED_BASE',
|
|
727
735
|
)
|
|
728
736
|
}
|
|
729
737
|
return 'HEAD~1'
|
|
@@ -742,6 +750,7 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
|
|
|
742
750
|
if (revParse(workspaceRoot, 'HEAD') === undefined) {
|
|
743
751
|
return new UserError(
|
|
744
752
|
'--affected has no base here: this repository has no commit yet. Commit first, or run without --affected.',
|
|
753
|
+
'VX_E_AFFECTED_BASE',
|
|
745
754
|
)
|
|
746
755
|
}
|
|
747
756
|
return undefined
|
|
@@ -832,6 +841,7 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
|
832
841
|
throw new UserError(
|
|
833
842
|
`git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
|
|
834
843
|
`workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
|
|
844
|
+
'VX_E_AFFECTED_BASE',
|
|
835
845
|
)
|
|
836
846
|
}
|
|
837
847
|
return ref
|
|
@@ -857,6 +867,7 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
857
867
|
throw new UserError(
|
|
858
868
|
`git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
|
|
859
869
|
(stderr.length > 0 ? `: ${stderr}` : ''),
|
|
870
|
+
'VX_E_GIT',
|
|
860
871
|
)
|
|
861
872
|
}
|
|
862
873
|
// A shallow clone (CI's checkout fetches one commit by default) has no
|
|
@@ -868,6 +879,7 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
|
868
879
|
(isShallow
|
|
869
880
|
? ' This clone is shallow: fetch the history the base needs (`git fetch --unshallow`, or `fetch-depth: 0` on actions/checkout).'
|
|
870
881
|
: ''),
|
|
882
|
+
'VX_E_AFFECTED_BASE',
|
|
871
883
|
)
|
|
872
884
|
}
|
|
873
885
|
|
|
@@ -136,6 +136,11 @@ function validateRetention(retention: unknown, configPath: string): void {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
|
|
140
|
+
export function isSkippedPlugin(p: unknown): p is undefined | null | false {
|
|
141
|
+
return p === undefined || p === null || p === false
|
|
142
|
+
}
|
|
143
|
+
|
|
139
144
|
export function validateWorkspace(config: WorkspaceConfig, configPath: string): void {
|
|
140
145
|
// No JSON-data rule here, unlike `validateProjectConfig`: `plugins` holds
|
|
141
146
|
// objects of functions by design, the file is evaluated in-process on
|
|
@@ -206,7 +211,10 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
206
211
|
}
|
|
207
212
|
const verbOwners = new Map<string, string>()
|
|
208
213
|
const fileClaimants = new Map<string, { name: string; at: number }>()
|
|
209
|
-
for (const [i, p] of config.plugins.entries()) {
|
|
214
|
+
for (const [i, p] of (config.plugins as readonly unknown[]).entries()) {
|
|
215
|
+
// A factory that declines returns nothing, and `cond && plugin()` is
|
|
216
|
+
// false: both are skipped, as Vite skips them.
|
|
217
|
+
if (isSkippedPlugin(p)) continue
|
|
210
218
|
if (p === null || typeof p !== 'object') {
|
|
211
219
|
// Nx's nx.json lists plugins by module name (`'@nx/vite/plugin'`);
|
|
212
220
|
// a vx plugin is the value its package's function returns.
|
|
@@ -660,6 +668,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
660
668
|
)
|
|
661
669
|
}
|
|
662
670
|
assertKnownFields(cache, CACHE_FIELDS, `${where}.cache`)
|
|
671
|
+
const rewritten = (cache as { rewrittenInputs?: unknown }).rewrittenInputs
|
|
672
|
+
if (rewritten !== undefined && rewritten !== 'withhold' && rewritten !== 'compare') {
|
|
673
|
+
throw new UserError(`${where}.cache.rewrittenInputs must be 'withhold' or 'compare'`)
|
|
674
|
+
}
|
|
663
675
|
const inputs = (cache as { inputs?: unknown }).inputs
|
|
664
676
|
const outputs = (cache as { outputs?: unknown }).outputs
|
|
665
677
|
// A present field of the wrong shape (`outputs: 'dist'`) was told it
|
|
@@ -896,7 +908,7 @@ const EXEC_FIELDS = new Set([
|
|
|
896
908
|
])
|
|
897
909
|
const PERSISTENT_FIELDS = new Set(['readyWhen'])
|
|
898
910
|
const ENV_FIELDS = new Set(['passThrough', 'define', 'secret'])
|
|
899
|
-
const CACHE_FIELDS = new Set(['inputs', 'outputs'])
|
|
911
|
+
const CACHE_FIELDS = new Set(['inputs', 'outputs', 'rewrittenInputs'])
|
|
900
912
|
const CACHE_INPUT_FIELDS = new Set([
|
|
901
913
|
'files',
|
|
902
914
|
'workspaceFiles',
|