@vzn/vx 0.0.349 → 0.0.352
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/package.json +5 -5
- package/src/cache/cache.ts +31 -1
- package/src/cache/file-hashes.ts +30 -0
- package/src/cache/git-inputs.ts +164 -10
- package/src/cache/index.ts +2 -0
- package/src/cache/inputs.ts +84 -22
- package/src/cache/key-fold.ts +5 -1
- package/src/cache/schema.ts +9 -0
- package/src/cli/show.ts +7 -1
- package/src/cli/workspace-config.ts +1 -1
- package/src/exec/index.ts +7 -1
- package/src/exec/sandbox-paths.ts +22 -0
- package/src/exec/sandbox-runtime.ts +71 -15
- package/src/exec/sandbox-violations.ts +41 -6
- package/src/graph/scheduler.ts +9 -1
- package/src/orchestrator/execute-task.ts +2 -0
- package/src/orchestrator/framed-output.ts +13 -0
- package/src/orchestrator/logger.ts +41 -2
- package/src/orchestrator/options.ts +6 -4
- package/src/orchestrator/plugin-host.ts +7 -6
- package/src/orchestrator/prepare.ts +36 -9
- package/src/orchestrator/projects.ts +9 -3
- package/src/orchestrator/run.ts +10 -2
- package/src/orchestrator/sandbox-request.ts +6 -5
- package/src/orchestrator/shell-verdict.ts +10 -1
- package/src/workspace/affected.ts +34 -1
- package/src/workspace/config-schema.ts +28 -0
- package/src/workspace/migrate-scripts.ts +49 -6
|
@@ -742,20 +742,19 @@ function canonicalBaselines(
|
|
|
742
742
|
* only if it exists in the new root, and otherwise `$HOME`, with no word:
|
|
743
743
|
* a project granted no read ran in the home directory, where `cat x.txt`
|
|
744
744
|
* read `~/x.txt`. A grant at or above the cwd holds it, and so does an
|
|
745
|
-
* existing one below (bwrap builds the path to a bind)
|
|
746
|
-
* deny that IS the cwd (a single-package workspace's anchor). When none does,
|
|
745
|
+
* existing one below (bwrap builds the path to a bind). When none does,
|
|
747
746
|
* the cwd is denied instead: an empty directory the task enters, whose
|
|
748
|
-
* reads are refused and reported as the anchor's are.
|
|
747
|
+
* reads are refused and reported as the anchor's are. A single-package
|
|
748
|
+
* workspace's cwd is its anchor, denied already; the runtime takes the
|
|
749
|
+
* second entry as the same mount.
|
|
749
750
|
*/
|
|
750
751
|
function cwdMounted(
|
|
751
752
|
cwd: string,
|
|
752
753
|
fs: {
|
|
753
754
|
allowRead?: readonly string[] | undefined
|
|
754
755
|
allowWrite?: readonly string[] | undefined
|
|
755
|
-
denyRead?: readonly string[] | undefined
|
|
756
756
|
},
|
|
757
757
|
): boolean {
|
|
758
|
-
if (fs.denyRead?.includes(cwd) === true) return true
|
|
759
758
|
return [...(fs.allowRead ?? []), ...(fs.allowWrite ?? [])].some(
|
|
760
759
|
(p) => atOrUnder(cwd, p) || (atOrUnder(p, cwd) && existsSync(p)),
|
|
761
760
|
)
|
|
@@ -1007,12 +1006,21 @@ export async function wrapSandboxedCommand(
|
|
|
1007
1006
|
process.platform === 'linux'
|
|
1008
1007
|
? ownGroupCommand(tag, inTmp, args.trace)
|
|
1009
1008
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1010
|
-
const inner =
|
|
1011
|
-
ports.length > 0 ?
|
|
1009
|
+
const inner = [
|
|
1010
|
+
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
1011
|
+
process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
|
|
1012
|
+
grouped.command,
|
|
1013
|
+
]
|
|
1014
|
+
.filter((part) => part !== '')
|
|
1015
|
+
.join(' ')
|
|
1012
1016
|
let wrapped = await wrapForTask(
|
|
1013
1017
|
SandboxManager,
|
|
1014
1018
|
inner,
|
|
1015
|
-
process.platform === 'linux'
|
|
1019
|
+
process.platform === 'linux'
|
|
1020
|
+
? literalReadPaths(customConfig)
|
|
1021
|
+
: process.platform === 'darwin'
|
|
1022
|
+
? seatbeltBrackets(customConfig)
|
|
1023
|
+
: customConfig,
|
|
1016
1024
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1017
1025
|
args.config.gitConfig === true,
|
|
1018
1026
|
)
|
|
@@ -1177,6 +1185,30 @@ function literalReadPaths(
|
|
|
1177
1185
|
}
|
|
1178
1186
|
}
|
|
1179
1187
|
|
|
1188
|
+
/**
|
|
1189
|
+
* macOS: a grant's escaped bracket as seatbelt's SRT can read it. vx hands
|
|
1190
|
+
* it the pattern, and SRT compiles any spelling holding `[` as a regex in
|
|
1191
|
+
* which a backslash is a literal one, so `pages/\[id\].tsx` matched no
|
|
1192
|
+
* file and the route could not be granted. `[[]` is a class of one `[`; a
|
|
1193
|
+
* lone `]` is plain text (B-65).
|
|
1194
|
+
*/
|
|
1195
|
+
function seatbeltBrackets(
|
|
1196
|
+
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
1197
|
+
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
1198
|
+
const fs = config?.filesystem
|
|
1199
|
+
if (fs === undefined) return config
|
|
1200
|
+
const literal = (paths: readonly string[]): string[] =>
|
|
1201
|
+
paths.map((p) => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']'))
|
|
1202
|
+
return {
|
|
1203
|
+
...config,
|
|
1204
|
+
filesystem: {
|
|
1205
|
+
...fs,
|
|
1206
|
+
allowWrite: literal(fs.allowWrite),
|
|
1207
|
+
...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
|
|
1208
|
+
},
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1180
1212
|
/** SRT's wrap, with the socket lift and the git-config grant this task asked for, or none (L-6, B-41). */
|
|
1181
1213
|
function wrapForTask(
|
|
1182
1214
|
SandboxManager: SrtModule['SandboxManager'],
|
|
@@ -1204,6 +1236,18 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
|
|
|
1204
1236
|
return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
|
|
1205
1237
|
}
|
|
1206
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* Linux: SRT starts its in-sandbox proxy bridges (`socat TCP-LISTEN:3128`
|
|
1241
|
+
* and `:1080`) in the background and runs the command at once, so a
|
|
1242
|
+
* networked task that dialled the proxy first met "connection refused"
|
|
1243
|
+
* (curl's `000`) on a loaded box (M-20). This waits, in front of the
|
|
1244
|
+
* command, until both listen in the task's network namespace, read off
|
|
1245
|
+
* /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
|
|
1246
|
+
* listens leaves the command to meet the refusal it met before.
|
|
1247
|
+
*/
|
|
1248
|
+
const PROXY_BRIDGE_WAIT =
|
|
1249
|
+
"( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
|
|
1250
|
+
|
|
1207
1251
|
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
1208
1252
|
export function portBridgeSocket(tag: string, port: number): string {
|
|
1209
1253
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
@@ -2076,12 +2120,20 @@ async function straceState(): Promise<{ form: false | 'plain' | 'seccomp'; why:
|
|
|
2076
2120
|
} else {
|
|
2077
2121
|
const m = /version (\d+)\.(\d+)/.exec(out)
|
|
2078
2122
|
const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]
|
|
2079
|
-
|
|
2080
|
-
|
|
2123
|
+
let form: 'plain' | 'seccomp' = major > 5 || (major === 5 && minor >= 3) ? 'seccomp' : 'plain'
|
|
2124
|
+
let refused = await traceRefusal(form)
|
|
2125
|
+
// A strace that cannot check the seccomp filter says so and traces on
|
|
2126
|
+
// without it, exit 0. Inside the sandbox that line read as the trace
|
|
2127
|
+
// cut short, and every sandboxed task ran twice: the plain form, if
|
|
2128
|
+
// it is quiet, is the one that works here.
|
|
2129
|
+
if (refused?.warned === true && form === 'seccomp') {
|
|
2130
|
+
refused = await traceRefusal('plain')
|
|
2131
|
+
if (refused === null) form = 'plain'
|
|
2132
|
+
}
|
|
2081
2133
|
straceAvailableCache =
|
|
2082
2134
|
refused === null
|
|
2083
2135
|
? { form, why: '' }
|
|
2084
|
-
: { form: false, why: `strace cannot trace here (${refused})` }
|
|
2136
|
+
: { form: false, why: `strace cannot trace here (${refused.line})` }
|
|
2085
2137
|
}
|
|
2086
2138
|
} catch {
|
|
2087
2139
|
// Not on PATH. Said as the refused attach is: without it an undeclared
|
|
@@ -2111,14 +2163,17 @@ export async function untracedReason(): Promise<string | null> {
|
|
|
2111
2163
|
|
|
2112
2164
|
/**
|
|
2113
2165
|
* Why strace may not attach here (null when it may), asked once with the flags a task's trace
|
|
2114
|
-
* uses (`ownGroupCommand`).
|
|
2166
|
+
* uses (`ownGroupCommand`). A strace that exits 0 having said something of
|
|
2167
|
+
* its own is refused too (`warned`): in a task, its line is the retry key. `--version` answers on a host that refuses
|
|
2115
2168
|
* ptrace (Yama's `ptrace_scope` 2 or 3, a container's seccomp profile),
|
|
2116
2169
|
* and there every sandboxed task failed twice, the retry included, on
|
|
2117
2170
|
* `attach: ptrace(PTRACE_SEIZE…): Operation not permitted`. Refused, the
|
|
2118
2171
|
* run goes untraced, as without strace: bwrap still enforces, and only the
|
|
2119
2172
|
* read-violation report is lost, which is said once.
|
|
2120
2173
|
*/
|
|
2121
|
-
async function traceRefusal(
|
|
2174
|
+
async function traceRefusal(
|
|
2175
|
+
form: 'plain' | 'seccomp',
|
|
2176
|
+
): Promise<{ line: string; warned: boolean } | null> {
|
|
2122
2177
|
const p = Bun.spawn(
|
|
2123
2178
|
[
|
|
2124
2179
|
executablePath('strace'),
|
|
@@ -2136,8 +2191,9 @@ async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
|
|
|
2136
2191
|
{ stdin: 'ignore', stdout: 'ignore', stderr: 'pipe' },
|
|
2137
2192
|
)
|
|
2138
2193
|
const err = await new Response(p.stderr).text()
|
|
2139
|
-
|
|
2140
|
-
|
|
2194
|
+
const line = err.split('\n').find((l) => STRACE_OWN_ERROR.test(l))
|
|
2195
|
+
if ((await p.exited) === 0) return line === undefined ? null : { line, warned: true }
|
|
2196
|
+
return { line: err.trim().split('\n')[0] || `exit ${p.exitCode}`, warned: false }
|
|
2141
2197
|
}
|
|
2142
2198
|
let warnedNoTrace = false
|
|
2143
2199
|
|
|
@@ -100,7 +100,14 @@ export interface DeniedCall {
|
|
|
100
100
|
// a child starts in its parent's cwd as it was at the fork.
|
|
101
101
|
const CHDIR_DONE_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*0`)
|
|
102
102
|
const CHDIR_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED} <unfinished`)
|
|
103
|
-
const CHDIR_RESUMED_RE =
|
|
103
|
+
const CHDIR_RESUMED_RE =
|
|
104
|
+
/^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)(?:\s+(ENOENT|EACCES|EPERM))?/
|
|
105
|
+
// A denied `chdir` is a denied read of the directory: `cd src` into a
|
|
106
|
+
// directory no grant holds failed with no line of the trace's own, and
|
|
107
|
+
// `cd src || …` passed and cached.
|
|
108
|
+
const CHDIR_DENIED_RE = new RegExp(
|
|
109
|
+
`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
110
|
+
)
|
|
104
111
|
const FCHDIR_RE = /^(\d+)\s+(?:fchdir\(\d+\)|<\.\.\. fchdir resumed>.*?)\s*=\s*0/
|
|
105
112
|
const FORK = '(?:clone3?|v?fork)'
|
|
106
113
|
const FORK_DONE_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*\\)\\s*=\\s*(\\d+)$`)
|
|
@@ -150,6 +157,17 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
150
157
|
out.push(call)
|
|
151
158
|
}
|
|
152
159
|
for (const line of text.split('\n')) {
|
|
160
|
+
// Most lines are opens that succeeded: two substring tests skip them.
|
|
161
|
+
const opener =
|
|
162
|
+
line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
|
|
163
|
+
if (!opener) {
|
|
164
|
+
if (
|
|
165
|
+
cwd !== undefined &&
|
|
166
|
+
(line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
|
|
167
|
+
)
|
|
168
|
+
follow(line)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
153
171
|
const done = STRACE_DONE_RE.exec(line)
|
|
154
172
|
if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
|
|
155
173
|
denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
|
|
@@ -176,15 +194,32 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
176
194
|
}
|
|
177
195
|
continue
|
|
178
196
|
}
|
|
179
|
-
|
|
197
|
+
// A refused or split `chdir` arrives here: its denial counts with or
|
|
198
|
+
// without the cwd tracking.
|
|
199
|
+
follow(line)
|
|
200
|
+
}
|
|
201
|
+
function follow(line: string): void {
|
|
180
202
|
let m: RegExpExecArray | null
|
|
181
|
-
if ((m =
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
if ((m = CHDIR_DENIED_RE.exec(line)) !== null) {
|
|
204
|
+
denied(m[1]!, { syscall: 'chdir', rawPath: cStringPath(m[2]!), errno: m[3]! })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) {
|
|
208
|
+
chdirring.set(m[1]!, cStringPath(m[2]!))
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
|
|
184
212
|
const to = chdirring.get(m[1]!)
|
|
185
213
|
chdirring.delete(m[1]!)
|
|
186
214
|
if (to !== undefined && m[2] === '0') opsOf(m[1]!).push({ chdir: to })
|
|
187
|
-
|
|
215
|
+
else if (to !== undefined && m[3] !== undefined) {
|
|
216
|
+
denied(m[1]!, { syscall: 'chdir', rawPath: to, errno: m[3] })
|
|
217
|
+
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (cwd === undefined) return
|
|
221
|
+
if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
|
|
222
|
+
else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
|
|
188
223
|
else if ((m = FORK_DONE_RE.exec(line)) !== null) {
|
|
189
224
|
forked(m[1]!, m[2]!, opsOf(m[1]!).length, line.includes('CLONE_FS'))
|
|
190
225
|
} else if ((m = FORK_UNFINISHED_RE.exec(line)) !== null) {
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -726,14 +726,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
726
726
|
return
|
|
727
727
|
}
|
|
728
728
|
const message = err instanceof Error ? err.message : String(err)
|
|
729
|
+
// After the run's stop a rejection is the stop's doing (a probe
|
|
730
|
+
// it killed, C-65): aborted, as any task it kills, and unsaid.
|
|
731
|
+
const stopped = aborted()
|
|
729
732
|
const outcome: TaskOutcome = withHold({
|
|
730
733
|
node,
|
|
731
|
-
status: 'failed',
|
|
734
|
+
status: stopped ? 'aborted' : 'failed',
|
|
732
735
|
exitCode: 1,
|
|
733
736
|
durationMs: 0,
|
|
734
737
|
})
|
|
735
738
|
leave()
|
|
736
739
|
untrack()
|
|
740
|
+
if (stopped) {
|
|
741
|
+
finishOne(id, outcome)
|
|
742
|
+
tick()
|
|
743
|
+
return
|
|
744
|
+
}
|
|
737
745
|
// A UserError is a config/input failure (e.g. a failed
|
|
738
746
|
// `cache.inputs.runtime` command), not a vx bug — report it
|
|
739
747
|
// plainly, never as an "internal error".
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type TaskExecutor,
|
|
34
34
|
type TaskInputs,
|
|
35
35
|
PersistentReadyError,
|
|
36
|
+
sandboxReads,
|
|
36
37
|
} from '../exec/index.js'
|
|
37
38
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
38
39
|
import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
|
|
@@ -875,6 +876,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
875
876
|
cwd: node.projectDir,
|
|
876
877
|
bins: taskBinDirs(node, args.workspaceRoot),
|
|
877
878
|
signal: res.signal,
|
|
879
|
+
hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
|
|
878
880
|
})
|
|
879
881
|
if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
|
|
880
882
|
// A committed file another task's clean removed, still gone: vx
|
|
@@ -468,6 +468,19 @@ export function formatPersistentTailBlock(
|
|
|
468
468
|
return lines.join('\n') + '\n'
|
|
469
469
|
}
|
|
470
470
|
|
|
471
|
+
/**
|
|
472
|
+
* What a kept server writes after the summary, one line at a time under its
|
|
473
|
+
* id (`app#dev │ Local: http://localhost:5173`), so several servers'
|
|
474
|
+
* logs read apart. `text` holds whole lines, its last newline cut off.
|
|
475
|
+
*/
|
|
476
|
+
export function formatKeptLines(node: TaskNode, text: string, colors: ColorSupport): string {
|
|
477
|
+
const prefix = `${paintTaskId(node, colors)} ${paint('', '│', colors, { dim: true })} `
|
|
478
|
+
return text
|
|
479
|
+
.split('\n')
|
|
480
|
+
.map((line) => `${prefix}${line}\n`)
|
|
481
|
+
.join('')
|
|
482
|
+
}
|
|
483
|
+
|
|
471
484
|
/**
|
|
472
485
|
* Between the requested task's frame and the summary footer, list the
|
|
473
486
|
* persistent tasks (dev servers / watchers) the run is keeping alive in
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
formatFailureRecap,
|
|
5
5
|
formatFrameClose,
|
|
6
6
|
formatFrameOpen,
|
|
7
|
+
formatKeptLines,
|
|
7
8
|
formatPersistentTailBlock,
|
|
8
9
|
formatTaskBlock,
|
|
9
10
|
formatTaskExecutedLine,
|
|
@@ -318,6 +319,39 @@ export function defaultLogger(
|
|
|
318
319
|
{ node: TaskNode; outcome?: TaskOutcome; out: Tail; err: Tail }
|
|
319
320
|
>()
|
|
320
321
|
let flushedPersistent = false
|
|
322
|
+
// After that flush nothing prints a tail again, yet a kept server writes
|
|
323
|
+
// for as long as vx holds it: `vx run dev --all` showed none of its
|
|
324
|
+
// servers' logs while it ran (C-56). So a ready server's output streams
|
|
325
|
+
// from then on, a line at a time under its id; the partial line each
|
|
326
|
+
// holds waits for its newline, a second runEnd, or `settle`.
|
|
327
|
+
const KEPT_LINE_CAP = 64 * 1024
|
|
328
|
+
const keptLines = new Map<string, { node: TaskNode; rest: string }>()
|
|
329
|
+
const streamKept = (node: TaskNode, chunk: string): void => {
|
|
330
|
+
const held = keptLines.get(node.id) ?? { node, rest: '' }
|
|
331
|
+
keptLines.set(node.id, held)
|
|
332
|
+
const text = held.rest + chunk
|
|
333
|
+
const cut = text.lastIndexOf('\n')
|
|
334
|
+
if (cut < 0) {
|
|
335
|
+
// A line that never ends (a `\r` progress bar) is printed at the cap
|
|
336
|
+
// rather than held without bound.
|
|
337
|
+
if (text.length < KEPT_LINE_CAP) held.rest = text
|
|
338
|
+
else {
|
|
339
|
+
held.rest = ''
|
|
340
|
+
writer.write(fenced(formatKeptLines(node, text, colors)))
|
|
341
|
+
}
|
|
342
|
+
return
|
|
343
|
+
}
|
|
344
|
+
held.rest = text.slice(cut + 1)
|
|
345
|
+
writer.write(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
|
|
346
|
+
}
|
|
347
|
+
const flushKept = (): void => {
|
|
348
|
+
for (const held of keptLines.values()) {
|
|
349
|
+
if (held.rest.length > 0) writer.write(fenced(formatKeptLines(held.node, held.rest, colors)))
|
|
350
|
+
held.rest = ''
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const printsKept = (tail: { outcome?: TaskOutcome }): boolean =>
|
|
354
|
+
flushedPersistent && tail.outcome !== undefined && view.mode !== 'errors-only'
|
|
321
355
|
// The recap's material, decided at each failure so nothing is held for a
|
|
322
356
|
// task that passed. A buffered task's tail is read off the buffers the
|
|
323
357
|
// frame drains; a live-streamed one keeps no buffer, so a bounded ring
|
|
@@ -411,6 +445,8 @@ export function defaultLogger(
|
|
|
411
445
|
|
|
412
446
|
return {
|
|
413
447
|
settle() {
|
|
448
|
+
// The bus delivers one `run:end`, so the CLI's last word reaches here.
|
|
449
|
+
flushKept()
|
|
414
450
|
writer.settle()
|
|
415
451
|
},
|
|
416
452
|
failureRecap() {
|
|
@@ -483,6 +519,7 @@ export function defaultLogger(
|
|
|
483
519
|
// like the failures below: run() calls runEnd twice on the success
|
|
484
520
|
// path (once before the summary, once in its finally), and a
|
|
485
521
|
// kept-alive child keeps writing between the two.
|
|
522
|
+
if (flushedPersistent) flushKept()
|
|
486
523
|
if (!flushedPersistent) {
|
|
487
524
|
flushedPersistent = true
|
|
488
525
|
if (view.mode !== 'none' && view.mode !== 'errors-only') {
|
|
@@ -533,7 +570,8 @@ export function defaultLogger(
|
|
|
533
570
|
}
|
|
534
571
|
const tail = persistentTails.get(node.id)
|
|
535
572
|
if (tail !== undefined) {
|
|
536
|
-
|
|
573
|
+
if (printsKept(tail)) streamKept(node, chunk)
|
|
574
|
+
else appendTail(tail.out, chunk)
|
|
537
575
|
return
|
|
538
576
|
}
|
|
539
577
|
pushChunk(stdoutBuffers, node.id, chunk)
|
|
@@ -550,7 +588,8 @@ export function defaultLogger(
|
|
|
550
588
|
}
|
|
551
589
|
const tail = persistentTails.get(node.id)
|
|
552
590
|
if (tail !== undefined) {
|
|
553
|
-
|
|
591
|
+
if (printsKept(tail)) streamKept(node, chunk)
|
|
592
|
+
else appendTail(tail.err, chunk)
|
|
554
593
|
return
|
|
555
594
|
}
|
|
556
595
|
pushChunk(stderrBuffers, node.id, chunk)
|
|
@@ -180,9 +180,10 @@ export interface RunOptions {
|
|
|
180
180
|
*/
|
|
181
181
|
signal?: AbortSignal
|
|
182
182
|
/**
|
|
183
|
-
* Hand the requested persistent tasks still running,
|
|
184
|
-
*
|
|
185
|
-
* of stopping them when
|
|
183
|
+
* Hand the requested persistent tasks still running, those a requested
|
|
184
|
+
* group stands for (C-52), and the persistent tasks they depend on
|
|
185
|
+
* (C-46), back on `RunSummary.persistent` instead of stopping them when
|
|
186
|
+
* the graph ends.
|
|
186
187
|
* The caller owns them from then on and stops them with its `stop()`.
|
|
187
188
|
* The watch loop sets this: a dev server stays up while watch idles and
|
|
188
189
|
* is replaced only when the next cycle starts.
|
|
@@ -229,7 +230,8 @@ export interface RunOptions {
|
|
|
229
230
|
* The raw command string for the `invocations` row (e.g.
|
|
230
231
|
* `'vx run build test --all'`). When absent, run() falls back to
|
|
231
232
|
* `process.argv.slice(1).join(' ')` so programmatic callers still
|
|
232
|
-
* record something useful.
|
|
233
|
+
* record something useful. Telemetry sinks get it with what follows
|
|
234
|
+
* `--` counted, not quoted, as they get the argv (C-67).
|
|
233
235
|
*/
|
|
234
236
|
command?: string
|
|
235
237
|
/**
|
|
@@ -386,12 +386,13 @@ export function buildAdmission(
|
|
|
386
386
|
}
|
|
387
387
|
|
|
388
388
|
/**
|
|
389
|
-
* Collect every plugin's `cache` layer in declaration order
|
|
390
|
-
*
|
|
391
|
-
* see ChainedCache).
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
*
|
|
389
|
+
* Collect every plugin's `cache` layer in declaration order, with the local
|
|
390
|
+
* store at the tail, and chain them (lookup walks them, save reaches all;
|
|
391
|
+
* see ChainedCache). The local store is dropped when a declared layer wraps
|
|
392
|
+
* it (`layer.local === ctx.localCache`), so a remote plugin that layers over
|
|
393
|
+
* the local handle does not also write it directly. Only a single layer
|
|
394
|
+
* left is used as is: the local store when no plugin declares one, or the
|
|
395
|
+
* one layer that wraps it.
|
|
395
396
|
*/
|
|
396
397
|
export async function resolveCache(
|
|
397
398
|
plugins: readonly VxPlugin[],
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
LayeredCache,
|
|
21
21
|
applyGitEnumeration,
|
|
22
22
|
gitPathspecs,
|
|
23
|
+
MAX_SCOPED_PATHSPECS,
|
|
23
24
|
startGitEnumeration,
|
|
24
25
|
lazyGitEnumeration,
|
|
25
26
|
} from '../cache/index.js'
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
type LoadReads,
|
|
32
33
|
loadWorkspace,
|
|
33
34
|
FROZEN_WITHOUT_LOCK,
|
|
35
|
+
type Lockfile,
|
|
34
36
|
readLockfile,
|
|
35
37
|
resolveCacheDir,
|
|
36
38
|
type ProjectEntry,
|
|
@@ -181,9 +183,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
181
183
|
// config, discovery, the cache open and the config evaluation. Its
|
|
182
184
|
// ~60 ms is the warm run's wall floor on a 1000-project tree; it used to
|
|
183
185
|
// start after discovery and the cache open, ~25 ms later. A scoped run
|
|
184
|
-
// waits: its pathspecs depend on which projects the configs pull in
|
|
185
|
-
|
|
186
|
-
|
|
186
|
+
// waits: its pathspecs depend on which projects the configs pull in —
|
|
187
|
+
// unless it already names more projects than pathspecs scope, where the
|
|
188
|
+
// walk is the whole tree either way (an `--affected` run on 1,000
|
|
189
|
+
// projects waited ~60 ms for it after the configs loaded).
|
|
190
|
+
const wholeTree =
|
|
191
|
+
(options.projects === undefined || options.projects.length > MAX_SCOPED_PATHSPECS) &&
|
|
192
|
+
options.tasks.some((spec) => spec.indexOf('#') <= 0)
|
|
187
193
|
// A `discover` hook that reads the worktree (nx()'s graph key) starts the
|
|
188
194
|
// whole-tree enumeration a scoped run would otherwise scope later, here or
|
|
189
195
|
// in the CLI's selection pass; the run reuses it instead of walking the
|
|
@@ -192,7 +198,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
192
198
|
const git =
|
|
193
199
|
(reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
|
|
194
200
|
lazyGitEnumeration(workspaceRoot)
|
|
195
|
-
if (
|
|
201
|
+
if (wholeTree) void git.start()
|
|
196
202
|
const workspace = await loadWorkspace(workspaceRoot, reads)
|
|
197
203
|
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
|
|
198
204
|
log.status(m),
|
|
@@ -277,10 +283,24 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
277
283
|
// so consuming the lock by default would silently serve stale
|
|
278
284
|
// freezes. `vx lock --check` is the full re-evaluation audit.
|
|
279
285
|
// See docs/design/config-lock-2026-06.md.
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
286
|
+
// Read once, and only when a config is to be read from it: the CLI's
|
|
287
|
+
// selection pass may have staged every config (`options.staged`), and
|
|
288
|
+
// its load refused a frozen run without a lock. A 1,000-project lock is
|
|
289
|
+
// 1.1 MB of JSON (I-27).
|
|
290
|
+
let lockRead: Promise<Lockfile> | undefined
|
|
291
|
+
const readLock = (): Promise<Lockfile> =>
|
|
292
|
+
(lockRead ??= readLockfile(workspaceRoot).then((read) => {
|
|
293
|
+
if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
294
|
+
return read
|
|
295
|
+
}))
|
|
296
|
+
const staged = options.staged
|
|
297
|
+
const allStaged =
|
|
298
|
+
staged !== undefined &&
|
|
299
|
+
projectMetas.every(
|
|
300
|
+
(m) => typeof m.configPath !== 'string' || m.configPath === '' || staged.has(m.name),
|
|
301
|
+
)
|
|
302
|
+
if (options.frozen === true && !allStaged) await readLock()
|
|
303
|
+
const lock = options.frozen === true ? readLock : null
|
|
284
304
|
|
|
285
305
|
const loadArgs = {
|
|
286
306
|
workspaceRoot,
|
|
@@ -436,7 +456,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
436
456
|
workspaceRoot,
|
|
437
457
|
gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
|
|
438
458
|
))
|
|
439
|
-
applyGitEnumeration(
|
|
459
|
+
await applyGitEnumeration(
|
|
460
|
+
enumeration,
|
|
461
|
+
workspaceRoot,
|
|
462
|
+
projectDirs,
|
|
463
|
+
gitFilesCache,
|
|
464
|
+
usesWorkspaceInputs,
|
|
465
|
+
localCache,
|
|
466
|
+
)
|
|
440
467
|
mark('git enumeration')
|
|
441
468
|
const hashCache = createHashCache()
|
|
442
469
|
const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
|
|
@@ -116,8 +116,12 @@ interface LoadProjectsBase {
|
|
|
116
116
|
* way, since the package graph cannot see the cross form.
|
|
117
117
|
*/
|
|
118
118
|
seeds: 'all' | Iterable<string>
|
|
119
|
-
/**
|
|
120
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Read configs from the lock instead of evaluating them (`--frozen`),
|
|
121
|
+
* asked only when a config is to be read: a run whose every config the
|
|
122
|
+
* CLI's selection pass staged never parses it again.
|
|
123
|
+
*/
|
|
124
|
+
lock: (() => Promise<Lockfile>) | null
|
|
121
125
|
evalCache: LoadProjectConfigOptions['evalCache']
|
|
122
126
|
warn: (message: string) => void
|
|
123
127
|
/**
|
|
@@ -223,7 +227,9 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
223
227
|
typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
|
|
224
228
|
)
|
|
225
229
|
const loaded = lock
|
|
226
|
-
?
|
|
230
|
+
? withFile.length === 0
|
|
231
|
+
? []
|
|
232
|
+
: await frozenProjectConfigs(await lock(), withFile, workspaceRoot, args.evalCache?.store)
|
|
227
233
|
: await loadProjectConfigs(
|
|
228
234
|
withFile.map((m) => m.configPath),
|
|
229
235
|
args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
6
|
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type CacheLayer,
|
|
10
|
+
type CachePolicy,
|
|
11
|
+
FULL_CACHE_POLICY,
|
|
12
|
+
stopRuntimeProbes,
|
|
13
|
+
} from '../cache/index.js'
|
|
9
14
|
import { VERSION } from '../version.js'
|
|
10
15
|
import {
|
|
11
16
|
resetSandbox,
|
|
@@ -533,6 +538,7 @@ async function runOnBus(
|
|
|
533
538
|
// ends after it is the stop's own kill, and was named a crash (item 1061).
|
|
534
539
|
let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
|
|
535
540
|
const onAbort = (): void => {
|
|
541
|
+
stopRuntimeProbes()
|
|
536
542
|
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
537
543
|
aborting = terminateChildren(
|
|
538
544
|
() => [...liveChildren, ...persistentRegistry.values()],
|
|
@@ -634,7 +640,9 @@ async function runOnBus(
|
|
|
634
640
|
runContextRecord = {
|
|
635
641
|
runId,
|
|
636
642
|
vxVersion: VERSION,
|
|
637
|
-
command:
|
|
643
|
+
// An embedder's `command` is redacted as the argv is: it passed a
|
|
644
|
+
// token after `--` to every sink verbatim (C-67).
|
|
645
|
+
command: invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
|
|
638
646
|
requestedTasks: [...options.tasks],
|
|
639
647
|
cachePolicy: compactCachePolicy(policy),
|
|
640
648
|
concurrency,
|
|
@@ -187,13 +187,14 @@ export async function sandboxRequestFor(
|
|
|
187
187
|
*/
|
|
188
188
|
cacheDir?: string,
|
|
189
189
|
): Promise<SandboxRequest> {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
190
|
+
// The runtime reads a path holding a bracket as a pattern: on Linux it
|
|
191
|
+
// mounts no such write path (B-60); seatbelt's rules compile it as a
|
|
192
|
+
// character class, so vx's own workspace wall matched nothing (B-65).
|
|
192
193
|
const home = toRealPath(node.projectDir)
|
|
193
|
-
if (process.platform
|
|
194
|
+
if (process.platform !== 'win32' && /[[\]]/.test(home)) {
|
|
194
195
|
throw new UserError(
|
|
195
|
-
`exec.sandbox: ${home} holds a bracket ([ or ]),
|
|
196
|
-
`
|
|
196
|
+
`exec.sandbox: ${home} holds a bracket ([ or ]), which the sandbox runtime reads as a ` +
|
|
197
|
+
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
|
197
198
|
)
|
|
198
199
|
}
|
|
199
200
|
const depDirs = [
|
|
@@ -23,13 +23,22 @@ export function shellVerdict(args: {
|
|
|
23
23
|
bins: string[]
|
|
24
24
|
/** The signal that killed the child when the runner saw one (`RunResult.signal`). */
|
|
25
25
|
signal?: string | undefined
|
|
26
|
+
/** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
|
|
27
|
+
hidden?: ((file: string) => boolean) | undefined
|
|
26
28
|
}): string | undefined {
|
|
27
29
|
if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
|
|
28
30
|
const word = execWord(args.command)
|
|
29
31
|
const what = word ?? 'a command in this task'
|
|
30
32
|
if (word !== undefined && word.includes('/')) {
|
|
31
33
|
const file = path.resolve(args.cwd, word)
|
|
32
|
-
|
|
34
|
+
// The file the host has may be one the sandbox does not: then the
|
|
35
|
+
// shell's "not found" is the sandbox's, and the file's own state (its
|
|
36
|
+
// `#!` line) is not the reason.
|
|
37
|
+
const verdict =
|
|
38
|
+
existsSync(file) && args.hidden?.(file) === true
|
|
39
|
+
? `exists, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
|
|
40
|
+
: fileVerdict(file)
|
|
41
|
+
return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${verdict}`
|
|
33
42
|
}
|
|
34
43
|
if (args.code === 126) {
|
|
35
44
|
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|