@vzn/vx 0.0.345 → 0.0.347
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/git-inputs.ts +7 -0
- package/src/cli/select.ts +7 -1
- package/src/cli/workspace-config.ts +0 -2
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +7 -2
- package/src/exec/sandbox-violations.ts +107 -12
- package/src/orchestrator/doctor.ts +0 -2
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/options.ts +4 -0
- package/src/orchestrator/projects.ts +18 -12
- package/src/orchestrator/run.ts +42 -1
- package/src/workspace/affected.ts +10 -1
- package/src/workspace/lockfile.ts +9 -1
- package/src/workspace/migrate-scripts.ts +22 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.347",
|
|
4
4
|
"description": "A fast, correct task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.347",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.347",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.347",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.347"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -841,6 +841,12 @@ export interface GitEnumeration {
|
|
|
841
841
|
* paths (both sides of a rename) and untracked files. Null when it failed.
|
|
842
842
|
*/
|
|
843
843
|
changed: readonly string[] | null
|
|
844
|
+
/**
|
|
845
|
+
* What `git status` listed as untracked, workspace-relative and before
|
|
846
|
+
* nested repositories are expanded — `git ls-files --others
|
|
847
|
+
* --exclude-standard`'s set. Null when it failed.
|
|
848
|
+
*/
|
|
849
|
+
untracked: readonly string[] | null
|
|
844
850
|
/** Workspace-relative paths whose names are not UTF-8 (`decodeGitZ`). */
|
|
845
851
|
undecodable: readonly string[]
|
|
846
852
|
/** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
|
|
@@ -1048,6 +1054,7 @@ export async function startGitEnumeration(
|
|
|
1048
1054
|
trusted,
|
|
1049
1055
|
dirty: worktreeDirty,
|
|
1050
1056
|
changed: dirty === null ? null : [...dirty, ...untracked],
|
|
1057
|
+
untracked: dirty === null ? null : untracked,
|
|
1051
1058
|
undecodable,
|
|
1052
1059
|
startedAtMs,
|
|
1053
1060
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -26,7 +26,7 @@ import type { ProjectConfig } from '../config.js'
|
|
|
26
26
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
27
27
|
import { parseDependencySpec } from '../graph/index.js'
|
|
28
28
|
import { nearest, UserError } from '../util/index.js'
|
|
29
|
-
import { claimedAffected, fingerprintClaims } from '../orchestrator/index.js'
|
|
29
|
+
import { claimedAffected, fingerprintClaims, gitOfDiscovery } from '../orchestrator/index.js'
|
|
30
30
|
import {
|
|
31
31
|
type CliLoadOptions,
|
|
32
32
|
discoverCliProjects,
|
|
@@ -230,6 +230,11 @@ export async function resolveFilters(
|
|
|
230
230
|
return { error: err instanceof Error ? err.message : String(err) }
|
|
231
231
|
}
|
|
232
232
|
const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
|
|
233
|
+
// A filter that diffs against git starts the run's whole-tree walk now:
|
|
234
|
+
// the diff reads its untracked files from it (one walk where it spawned
|
|
235
|
+
// its own), and the run reuses it with the discovery (I-26).
|
|
236
|
+
const git = parsed.some((f) => f.gitSince !== undefined) ? gitOfDiscovery(projects) : undefined
|
|
237
|
+
void git?.start()
|
|
233
238
|
// Every reader of the staged configs in this pass — the `pkg#task`
|
|
234
239
|
// edge walk, the `workspaceFiles` owners of a changed path — shares
|
|
235
240
|
// ONE load, and the run reuses it (`RunOptions.staged`): the `project`
|
|
@@ -262,6 +267,7 @@ export async function resolveFilters(
|
|
|
262
267
|
workspaceGlobOwners(root, projects, changed, load, stagedOnce),
|
|
263
268
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
264
269
|
taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
|
|
270
|
+
...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
|
|
265
271
|
})
|
|
266
272
|
affectedByFilter.set(f, names)
|
|
267
273
|
} catch (err) {
|
|
@@ -12,7 +12,6 @@ import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
|
12
12
|
import { discoverProjects, loadProjects, loadWorkspacePlugins } from '../orchestrator/index.js'
|
|
13
13
|
import type { VxPlugin } from '../orchestrator/index.js'
|
|
14
14
|
import {
|
|
15
|
-
buildPackageGraph,
|
|
16
15
|
computeWorkspaceFingerprint,
|
|
17
16
|
FROZEN_WITHOUT_LOCK,
|
|
18
17
|
type Workspace,
|
|
@@ -140,7 +139,6 @@ export async function loadCliProjects(
|
|
|
140
139
|
cacheDir,
|
|
141
140
|
plugins,
|
|
142
141
|
projectMetas: metas,
|
|
143
|
-
packageGraph: buildPackageGraph([...metas]),
|
|
144
142
|
seeds: scope,
|
|
145
143
|
closure: false,
|
|
146
144
|
lock,
|
package/src/exec/runner.ts
CHANGED
|
@@ -206,6 +206,10 @@ const SHELL_BUILTINS = new Set([
|
|
|
206
206
|
'let',
|
|
207
207
|
'declare',
|
|
208
208
|
'typeset',
|
|
209
|
+
// bash's reserved words that may open a one-line command; macOS's `sh`
|
|
210
|
+
// is bash, and `exec [[ … ]]` there is "exec: [[: not found".
|
|
211
|
+
'[[',
|
|
212
|
+
'time',
|
|
209
213
|
])
|
|
210
214
|
|
|
211
215
|
/**
|
|
@@ -1125,7 +1125,9 @@ function ownGroupCommand(
|
|
|
1125
1125
|
...(trace === 'seccomp' ? ['--seccomp-bpf'] : []),
|
|
1126
1126
|
'-qq',
|
|
1127
1127
|
'-e',
|
|
1128
|
-
'
|
|
1128
|
+
// A process's cwd moves on `chdir` and starts as its parent's at the
|
|
1129
|
+
// fork (`deniedCalls`); `?` lets an arch without `fork` skip it.
|
|
1130
|
+
TRACED_CALLS,
|
|
1129
1131
|
'-o',
|
|
1130
1132
|
`/dev/fd/${TRACE_FD}`,
|
|
1131
1133
|
'--',
|
|
@@ -1141,6 +1143,9 @@ function ownGroupCommand(
|
|
|
1141
1143
|
return { command: `${tag0} ${run} ${watch} wait "$c"`, forwards: true, traced: true }
|
|
1142
1144
|
}
|
|
1143
1145
|
|
|
1146
|
+
/** What strace stops on: the reads, and what moves or makes a process's cwd. */
|
|
1147
|
+
const TRACED_CALLS = 'trace=openat,chdir,fchdir,clone,?clone3,?fork,?vfork'
|
|
1148
|
+
|
|
1144
1149
|
/** The descriptor an in-sandbox strace writes its trace to (`ownGroupCommand`). */
|
|
1145
1150
|
const TRACE_FD = 5
|
|
1146
1151
|
|
|
@@ -2122,7 +2127,7 @@ async function traceRefusal(form: 'plain' | 'seccomp'): Promise<string | null> {
|
|
|
2122
2127
|
...(form === 'seccomp' ? ['--seccomp-bpf'] : []),
|
|
2123
2128
|
'-qq',
|
|
2124
2129
|
'-e',
|
|
2125
|
-
|
|
2130
|
+
TRACED_CALLS,
|
|
2126
2131
|
'-o',
|
|
2127
2132
|
'/dev/null',
|
|
2128
2133
|
'--',
|
|
@@ -88,22 +88,71 @@ export interface DeniedCall {
|
|
|
88
88
|
syscall: string
|
|
89
89
|
rawPath: string
|
|
90
90
|
errno: string
|
|
91
|
+
/**
|
|
92
|
+
* The directory a relative `rawPath` was opened from, when the trace
|
|
93
|
+
* shows the process (or one it was forked from) changed into one; absent
|
|
94
|
+
* where it never did, or where it lost track (`fchdir`).
|
|
95
|
+
*/
|
|
96
|
+
dir?: string
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
// The calls that move a process's cwd, and the ones that make a process:
|
|
100
|
+
// a child starts in its parent's cwd as it was at the fork.
|
|
101
|
+
const CHDIR_DONE_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*0`)
|
|
102
|
+
const CHDIR_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED} <unfinished`)
|
|
103
|
+
const CHDIR_RESUMED_RE = /^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)/
|
|
104
|
+
const FCHDIR_RE = /^(\d+)\s+(?:fchdir\(\d+\)|<\.\.\. fchdir resumed>.*?)\s*=\s*0/
|
|
105
|
+
const FORK = '(?:clone3?|v?fork)'
|
|
106
|
+
const FORK_DONE_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*\\)\\s*=\\s*(\\d+)$`)
|
|
107
|
+
const FORK_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*<unfinished`)
|
|
108
|
+
const FORK_RESUMED_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. ${FORK} resumed>.*?=\\s*(\\d+)$`)
|
|
109
|
+
|
|
110
|
+
/** What a process did, in order, as far as its cwd and its denials go. */
|
|
111
|
+
type Op = { chdir: string } | { lost: true } | { call: number }
|
|
112
|
+
|
|
93
113
|
/**
|
|
94
114
|
* Walk the trace, pairing `<unfinished ...>` with its `<... resumed>` line.
|
|
115
|
+
* Given the task's starting `cwd`, also follow each process's `chdir` and
|
|
116
|
+
* forks, so a denial of `secret.txt` after `cd src` is `src/secret.txt`:
|
|
117
|
+
* resolved against the starting cwd, it named a file that does not exist
|
|
118
|
+
* and no `ignore` pattern for the real one matched. Resolved after the
|
|
119
|
+
* whole pass: a `vfork` child's lines come before its parent's
|
|
120
|
+
* `resumed` line names it. (`-y` names the directory on every line, but
|
|
121
|
+
* cost 40% on 2,000 opens; this costs a stop per process.)
|
|
95
122
|
*
|
|
96
123
|
* Exported for testing: this is the security-relevant half of the Linux
|
|
97
124
|
* detector, and a synthetic trace pins the split-line shapes deterministically
|
|
98
125
|
* where an end-to-end run only produces them when strace happens to interleave.
|
|
99
126
|
*/
|
|
100
|
-
export function deniedCalls(text: string): DeniedCall[] {
|
|
127
|
+
export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
101
128
|
const pending = new Map<string, { syscall: string; rawPath: string }>()
|
|
102
129
|
const out: DeniedCall[] = []
|
|
130
|
+
const ops = new Map<string, Op[]>()
|
|
131
|
+
// A clone with CLONE_FS (every thread) shares its creator's cwd, so a
|
|
132
|
+
// `chdir` by either moves both: such a pid keeps the creator's list.
|
|
133
|
+
const sharing = new Map<string, string>()
|
|
134
|
+
const owner = (pid: string): string => sharing.get(pid) ?? pid
|
|
135
|
+
const opsOf = (pid: string): Op[] => {
|
|
136
|
+
let list = ops.get(owner(pid))
|
|
137
|
+
if (list === undefined) ops.set(owner(pid), (list = []))
|
|
138
|
+
return list
|
|
139
|
+
}
|
|
140
|
+
const parent = new Map<string, { pid: string; at: number }>()
|
|
141
|
+
const forking = new Map<string, { at: number; shared: boolean }>()
|
|
142
|
+
const forked = (from: string, child: string, at: number, shared: boolean): void => {
|
|
143
|
+
// Shared only while the child has done nothing of its own yet.
|
|
144
|
+
if (shared && !ops.has(child)) sharing.set(child, owner(from))
|
|
145
|
+
else parent.set(child, { pid: owner(from), at })
|
|
146
|
+
}
|
|
147
|
+
const chdirring = new Map<string, string>()
|
|
148
|
+
const denied = (pid: string, call: DeniedCall): void => {
|
|
149
|
+
opsOf(pid).push({ call: out.length })
|
|
150
|
+
out.push(call)
|
|
151
|
+
}
|
|
103
152
|
for (const line of text.split('\n')) {
|
|
104
153
|
const done = STRACE_DONE_RE.exec(line)
|
|
105
154
|
if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
|
|
106
|
-
|
|
155
|
+
denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
|
|
107
156
|
continue
|
|
108
157
|
}
|
|
109
158
|
const unfinished = STRACE_UNFINISHED_RE.exec(line)
|
|
@@ -116,15 +165,61 @@ export function deniedCalls(text: string): DeniedCall[] {
|
|
|
116
165
|
continue
|
|
117
166
|
}
|
|
118
167
|
const resumedOk = STRACE_RESUMED_OK_RE.exec(line)
|
|
119
|
-
if (resumedOk?.[1]
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
168
|
+
if (resumedOk?.[1] !== undefined) {
|
|
169
|
+
const held = pending.get(resumedOk[1])
|
|
170
|
+
pending.delete(resumedOk[1])
|
|
171
|
+
const resumed = STRACE_RESUMED_RE.exec(line)
|
|
172
|
+
// Only a resume that carries a DENIAL is a violation; a successful
|
|
173
|
+
// resume just retires the pending entry.
|
|
174
|
+
if (held !== undefined && resumed?.[3] !== undefined) {
|
|
175
|
+
denied(resumedOk[1], { ...held, errno: resumed[3] })
|
|
176
|
+
}
|
|
177
|
+
continue
|
|
127
178
|
}
|
|
179
|
+
if (cwd === undefined) continue
|
|
180
|
+
let m: RegExpExecArray | null
|
|
181
|
+
if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
|
|
182
|
+
else if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) chdirring.set(m[1]!, cStringPath(m[2]!))
|
|
183
|
+
else if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
|
|
184
|
+
const to = chdirring.get(m[1]!)
|
|
185
|
+
chdirring.delete(m[1]!)
|
|
186
|
+
if (to !== undefined && m[2] === '0') opsOf(m[1]!).push({ chdir: to })
|
|
187
|
+
} else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
|
|
188
|
+
else if ((m = FORK_DONE_RE.exec(line)) !== null) {
|
|
189
|
+
forked(m[1]!, m[2]!, opsOf(m[1]!).length, line.includes('CLONE_FS'))
|
|
190
|
+
} else if ((m = FORK_UNFINISHED_RE.exec(line)) !== null) {
|
|
191
|
+
forking.set(m[1]!, { at: opsOf(m[1]!).length, shared: line.includes('CLONE_FS') })
|
|
192
|
+
} else if ((m = FORK_RESUMED_RE.exec(line)) !== null) {
|
|
193
|
+
const start = forking.get(m[1]!)
|
|
194
|
+
forking.delete(m[1]!)
|
|
195
|
+
forked(m[1]!, m[2]!, start?.at ?? opsOf(m[1]!).length, start?.shared === true)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (cwd === undefined) return out
|
|
199
|
+
// dirsOf(pid)[i]: the process's cwd before its op i (undefined: lost).
|
|
200
|
+
const dirs = new Map<string, (string | undefined)[]>()
|
|
201
|
+
const dirsOf = (pid: string): (string | undefined)[] => {
|
|
202
|
+
const known = dirs.get(pid)
|
|
203
|
+
if (known !== undefined) return known
|
|
204
|
+
dirs.set(pid, []) // a cycle reads as lost
|
|
205
|
+
const from = parent.get(pid)
|
|
206
|
+
let dir: string | undefined = from === undefined ? cwd : dirsOf(from.pid)[from.at]
|
|
207
|
+
const list: (string | undefined)[] = [dir]
|
|
208
|
+
for (const op of ops.get(pid) ?? []) {
|
|
209
|
+
if ('chdir' in op) {
|
|
210
|
+
dir = path.isAbsolute(op.chdir) ? op.chdir : dir && path.resolve(dir, op.chdir)
|
|
211
|
+
} else if ('lost' in op) dir = undefined
|
|
212
|
+
list.push(dir)
|
|
213
|
+
}
|
|
214
|
+
dirs.set(pid, list)
|
|
215
|
+
return list
|
|
216
|
+
}
|
|
217
|
+
for (const [pid, list] of ops) {
|
|
218
|
+
list.forEach((op, i) => {
|
|
219
|
+
if (!('call' in op)) return
|
|
220
|
+
const dir = dirsOf(pid)[i]
|
|
221
|
+
if (dir !== undefined && dir !== cwd) out[op.call]!.dir = dir
|
|
222
|
+
})
|
|
128
223
|
}
|
|
129
224
|
return out
|
|
130
225
|
}
|
|
@@ -150,8 +245,8 @@ export async function parseStraceViolations(
|
|
|
150
245
|
|
|
151
246
|
const seen = new Set<string>()
|
|
152
247
|
const out: SandboxViolation[] = []
|
|
153
|
-
for (const { syscall, rawPath, errno } of deniedCalls(text)) {
|
|
154
|
-
const abs = toRealPath(absolutize(rawPath, baselines.cwd))
|
|
248
|
+
for (const { syscall, rawPath, errno, dir } of deniedCalls(text, baselines.cwd)) {
|
|
249
|
+
const abs = toRealPath(absolutize(rawPath, dir ?? baselines.cwd))
|
|
155
250
|
// Only report paths under the workspace-root deny anchor — system
|
|
156
251
|
// libs / /proc / /sys / etc. probes are not interesting violations.
|
|
157
252
|
if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
import { VERSION } from '../version.js'
|
|
21
21
|
import { probeSandbox, resetSandbox, untracedReason } from '../exec/index.js'
|
|
22
22
|
import {
|
|
23
|
-
buildPackageGraph,
|
|
24
23
|
computeWorkspaceFingerprint,
|
|
25
24
|
findWorkspaceRoot,
|
|
26
25
|
type LoadReads,
|
|
@@ -144,7 +143,6 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
|
|
|
144
143
|
cacheDir,
|
|
145
144
|
plugins,
|
|
146
145
|
projectMetas: metas,
|
|
147
|
-
packageGraph: buildPackageGraph([...metas]),
|
|
148
146
|
seeds: 'all',
|
|
149
147
|
closure: false,
|
|
150
148
|
lock: null,
|
|
@@ -47,6 +47,7 @@ export interface RunOptions {
|
|
|
47
47
|
* as `staged` is.
|
|
48
48
|
*/
|
|
49
49
|
discovered?: { root: string; projects: ProjectMeta[] }
|
|
50
|
+
/** Worker slots: a positive integer; run() refuses anything else (C-61). */
|
|
50
51
|
concurrency?: number
|
|
51
52
|
/**
|
|
52
53
|
* Cache directory override (`--cache-dir <path>`). Absolute, or relative
|
|
@@ -115,6 +116,7 @@ export interface RunOptions {
|
|
|
115
116
|
* `exec.retries` (explicit config wins, including `retries: 0`).
|
|
116
117
|
* Threaded as an option only — never folded into any cache key, so
|
|
117
118
|
* the same run with and without `--retry` derives identical keys.
|
|
119
|
+
* A non-negative integer; run() refuses anything else (C-61).
|
|
118
120
|
*/
|
|
119
121
|
retries?: number
|
|
120
122
|
/**
|
|
@@ -124,6 +126,8 @@ export interface RunOptions {
|
|
|
124
126
|
* `exec.timeout` always wins. Threaded as an option only — never
|
|
125
127
|
* folded into any cache key (a timed-out task fails and is never
|
|
126
128
|
* cached), so a `--timeout` run cache-hits a plain run's entry.
|
|
129
|
+
* A positive integer of ms up to `2 ** 31 - 1`; run() refuses anything
|
|
130
|
+
* else (C-61).
|
|
127
131
|
*/
|
|
128
132
|
timeout?: number
|
|
129
133
|
/**
|
|
@@ -22,7 +22,6 @@ import type { WorkspaceConfig } from '../config.js'
|
|
|
22
22
|
import { Cache, lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
|
|
23
23
|
import { UserError } from '../util/index.js'
|
|
24
24
|
import {
|
|
25
|
-
buildPackageGraph,
|
|
26
25
|
computeWorkspaceFingerprint,
|
|
27
26
|
listProjects,
|
|
28
27
|
loadWorkspace,
|
|
@@ -81,8 +80,10 @@ export async function discoverProjects(
|
|
|
81
80
|
git: LazyGitEnumeration = lazyGitEnumeration(workspace.root),
|
|
82
81
|
): Promise<ProjectMeta[]> {
|
|
83
82
|
const projects = await listProjects(workspace)
|
|
84
|
-
|
|
83
|
+
// Registered whatever the hooks: an `--affected` selection reads its
|
|
84
|
+
// untracked files from this walk, and the run reuses it (I-26).
|
|
85
85
|
discoveredGit.set(projects, git)
|
|
86
|
+
if (!hasHook(plugins, 'discover')) return projects
|
|
86
87
|
const worktreeChanges = (): Promise<readonly string[] | null> =>
|
|
87
88
|
git.start().then(
|
|
88
89
|
(e) => e.changed,
|
|
@@ -97,12 +98,16 @@ export async function discoverProjects(
|
|
|
97
98
|
return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
export
|
|
101
|
+
export type LoadProjectsArgs = LoadProjectsBase &
|
|
102
|
+
// The graph is read only for the closure: a load without one built it
|
|
103
|
+
// for nothing (1,000 projects: ~10 ms, once per CLI selection pass).
|
|
104
|
+
({ closure: true; packageGraph: PackageGraph } | { closure: false; packageGraph?: PackageGraph })
|
|
105
|
+
|
|
106
|
+
interface LoadProjectsBase {
|
|
101
107
|
workspaceRoot: string
|
|
102
108
|
cacheDir: string
|
|
103
109
|
plugins: readonly VxPlugin[]
|
|
104
110
|
projectMetas: readonly ProjectMeta[]
|
|
105
|
-
packageGraph: PackageGraph
|
|
106
111
|
/**
|
|
107
112
|
* Projects whose configs must load — `'all'`, or names (unknown and
|
|
108
113
|
* config-less ones are ignored). With `closure`, each seed's transitive
|
|
@@ -111,7 +116,6 @@ export interface LoadProjectsArgs {
|
|
|
111
116
|
* way, since the package graph cannot see the cross form.
|
|
112
117
|
*/
|
|
113
118
|
seeds: 'all' | Iterable<string>
|
|
114
|
-
closure: boolean
|
|
115
119
|
/** Read configs from the lock instead of evaluating them (`--frozen`). */
|
|
116
120
|
lock: Lockfile | null
|
|
117
121
|
evalCache: LoadProjectConfigOptions['evalCache']
|
|
@@ -148,7 +152,8 @@ export interface LoadedProjects {
|
|
|
148
152
|
* enters scope.
|
|
149
153
|
*/
|
|
150
154
|
export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
|
|
151
|
-
const { plugins,
|
|
155
|
+
const { plugins, lock, workspaceRoot } = args
|
|
156
|
+
const graph = args.closure ? args.packageGraph : null
|
|
152
157
|
// A package with no config file declares no tasks — unless a plugin
|
|
153
158
|
// fills the `project` stage, in which case it is a project the stage may
|
|
154
159
|
// give tasks to (the zero-migration shape: `turbo.json` or `package.json`
|
|
@@ -178,7 +183,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
178
183
|
}
|
|
179
184
|
}
|
|
180
185
|
const walked = new Set<string>()
|
|
181
|
-
const considerWithDeps = (name: string): void => {
|
|
186
|
+
const considerWithDeps = (packageGraph: PackageGraph, name: string): void => {
|
|
182
187
|
consider(name)
|
|
183
188
|
// Every config-bearing project already considered: the closure can
|
|
184
189
|
// add nothing, and asking for it would build the package graph's
|
|
@@ -190,8 +195,10 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
190
195
|
}
|
|
191
196
|
const seeds = args.seeds === 'all' ? metaByName.keys() : [...args.seeds]
|
|
192
197
|
for (const seed of seeds) consider(seed)
|
|
193
|
-
if (
|
|
194
|
-
for (const seed of args.seeds === 'all' ? metaByName.keys() : seeds)
|
|
198
|
+
if (graph !== null && pending.length < metaByName.size) {
|
|
199
|
+
for (const seed of args.seeds === 'all' ? metaByName.keys() : seeds) {
|
|
200
|
+
considerWithDeps(graph, seed)
|
|
201
|
+
}
|
|
195
202
|
}
|
|
196
203
|
|
|
197
204
|
// Load in rounds to a fixpoint. A `pkg#task` dependsOn entry names a
|
|
@@ -227,7 +234,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
227
234
|
if (pre !== undefined) {
|
|
228
235
|
projects.set(meta.name, pre)
|
|
229
236
|
for (const name of crossDepProjects(pre.config)) {
|
|
230
|
-
if (
|
|
237
|
+
if (graph !== null) considerWithDeps(graph, name)
|
|
231
238
|
else consider(name)
|
|
232
239
|
}
|
|
233
240
|
continue
|
|
@@ -258,7 +265,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
258
265
|
const named = rootName === undefined ? config : rootSpelled(config, rootName)
|
|
259
266
|
projects.set(meta.name, { name: meta.name, dir: meta.dir, config: named })
|
|
260
267
|
for (const name of crossDepProjects(named)) {
|
|
261
|
-
if (
|
|
268
|
+
if (graph !== null) considerWithDeps(graph, name)
|
|
262
269
|
else consider(name)
|
|
263
270
|
}
|
|
264
271
|
}
|
|
@@ -349,7 +356,6 @@ export async function loadResolvedProjects(
|
|
|
349
356
|
cacheDir,
|
|
350
357
|
plugins,
|
|
351
358
|
projectMetas: metas,
|
|
352
|
-
packageGraph: buildPackageGraph([...metas]),
|
|
353
359
|
seeds: opts.scope ?? 'all',
|
|
354
360
|
closure: false,
|
|
355
361
|
lock: null,
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -193,7 +193,34 @@ export function invocationCommand(argv: readonly string[]): string {
|
|
|
193
193
|
return [...argv.slice(0, sep), '--', `<${rest} argument${rest === 1 ? '' : 's'}>`].join(' ')
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The bounds the CLI and the workspace config already hold, at the façade
|
|
198
|
+
* (C-61): a `concurrency` of 0, a negative or NaN left no worker slot and
|
|
199
|
+
* the run waited for good; a `retries` of NaN retried a failing task
|
|
200
|
+
* without end; a `timeout` of 0, a negative, NaN or past the timer's range
|
|
201
|
+
* killed every task at once.
|
|
202
|
+
*/
|
|
203
|
+
function refuseRunNumbers(options: RunOptions): void {
|
|
204
|
+
const refuse = (name: string, value: number, rule: string): never => {
|
|
205
|
+
throw new UserError(`RunOptions.${name} is ${String(value)}: it must be ${rule}`)
|
|
206
|
+
}
|
|
207
|
+
// No task named read "No projects declare task(s): ." (C-61).
|
|
208
|
+
if (options.tasks.length === 0 || options.tasks.includes(''))
|
|
209
|
+
throw new UserError(`RunOptions.tasks names no task: give at least one task name`)
|
|
210
|
+
const { concurrency, retries, timeout } = options
|
|
211
|
+
if (concurrency !== undefined && !(Number.isInteger(concurrency) && concurrency > 0))
|
|
212
|
+
refuse('concurrency', concurrency, 'a positive integer')
|
|
213
|
+
if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
|
|
214
|
+
refuse('retries', retries, 'a non-negative integer')
|
|
215
|
+
if (
|
|
216
|
+
timeout !== undefined &&
|
|
217
|
+
!(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
|
|
218
|
+
)
|
|
219
|
+
refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
196
222
|
export async function run(options: RunOptions): Promise<RunSummary> {
|
|
223
|
+
refuseRunNumbers(options)
|
|
197
224
|
// Color decision: a custom logger (tests, embedders) handles its
|
|
198
225
|
// own formatting and asserts on plain strings, so we suppress
|
|
199
226
|
// ANSI escapes for them. Only the defaultLogger (real terminal
|
|
@@ -844,7 +871,21 @@ async function runOnBus(
|
|
|
844
871
|
const foreground = options.log === undefined && (options.handleSignals ?? true)
|
|
845
872
|
// An aborted run's children are already being torn down: nothing to hold.
|
|
846
873
|
const hold = options.holdPersistent === true && !stopRun.signal.aborted
|
|
847
|
-
|
|
874
|
+
// A run that failed elsewhere holds nothing: `vx run dev --all` with one
|
|
875
|
+
// server that never became ready sat on the others for good, so a
|
|
876
|
+
// script hung, and the Ctrl-C that ended it read 130 over the failure
|
|
877
|
+
// (C-60). A kept server's own crash ends the wait below as before, and
|
|
878
|
+
// `--continue=always` asked to keep going, and does. The watch loop
|
|
879
|
+
// (`holdPersistent`) still holds: a failing test in a cycle stopped
|
|
880
|
+
// the dev server the next change would restart anyway.
|
|
881
|
+
let keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
|
|
882
|
+
const failedElsewhere =
|
|
883
|
+
![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
|
|
884
|
+
[...persistentRegistry.values()].some(
|
|
885
|
+
(c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
|
|
886
|
+
)
|
|
887
|
+
if (failedElsewhere && !hold && options.continueMode !== 'always')
|
|
888
|
+
keepAlive = { nodes: [], children: [] }
|
|
848
889
|
const crashedPersistent = (
|
|
849
890
|
await shutdownPersistent(persistentRegistry, keepAlive.children)
|
|
850
891
|
).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
|
|
@@ -98,6 +98,13 @@ export interface AffectedArgs {
|
|
|
98
98
|
* package graph cannot see (item 1085).
|
|
99
99
|
*/
|
|
100
100
|
taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
|
|
101
|
+
/**
|
|
102
|
+
* The untracked files, workspace-relative, from a walk the caller shares
|
|
103
|
+
* with the run (`GitEnumeration.untracked`); null or absent spawns `git
|
|
104
|
+
* ls-files --others` here. The selection's own walk was a second one the
|
|
105
|
+
* run then paid again (I-26).
|
|
106
|
+
*/
|
|
107
|
+
untracked?: () => Promise<readonly string[] | null>
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
export interface FingerprintClaims {
|
|
@@ -186,7 +193,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
186
193
|
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
187
194
|
// so a brand-new source file changes a task's cache key. Union it in or
|
|
188
195
|
// `--affected` skips a package that genuinely has new work.
|
|
189
|
-
|
|
196
|
+
(async () =>
|
|
197
|
+
(await args.untracked?.().catch(() => null)) ??
|
|
198
|
+
gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
|
|
190
199
|
])
|
|
191
200
|
|
|
192
201
|
// vx-lock.json (workspace-root metadata) is excluded like a gitignored
|
|
@@ -153,7 +153,15 @@ export async function frozenProjectConfigs(
|
|
|
153
153
|
const entries = metas.map((meta) => {
|
|
154
154
|
const rel = relPosix(root, meta.configPath)
|
|
155
155
|
const entry = lock.projects[meta.name]
|
|
156
|
-
if (
|
|
156
|
+
if (entry !== undefined && entry.configPath !== rel) {
|
|
157
|
+
// A renamed or moved config: "has no entry for a" named an entry the
|
|
158
|
+
// lock holds, under its old path (D-88).
|
|
159
|
+
throw new UserError(
|
|
160
|
+
`${LOCKFILE_NAME} locks "${meta.name}" at ${entry.configPath}, but its config is ${rel} now — ` +
|
|
161
|
+
`run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
if (!entry) {
|
|
157
165
|
throw new UserError(
|
|
158
166
|
`${LOCKFILE_NAME} has no entry for "${meta.name}" (${rel}) — ` +
|
|
159
167
|
`run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
|
|
@@ -407,6 +407,20 @@ export function migrateScripts(
|
|
|
407
407
|
typeof outsideScripts === 'object' &&
|
|
408
408
|
outsideScripts !== null &&
|
|
409
409
|
Object.values(outsideScripts).some((v) => typeof v === 'string' && v !== '')
|
|
410
|
+
const unnamedMaps = (): string[] => {
|
|
411
|
+
const meta: ProjectMeta = {
|
|
412
|
+
name: 'package.json',
|
|
413
|
+
dir: outsideDir!,
|
|
414
|
+
packageJson: outside as never,
|
|
415
|
+
configPath: null,
|
|
416
|
+
}
|
|
417
|
+
const scripts = scriptsOf(meta)
|
|
418
|
+
return Object.keys(rootScripts(meta)).filter((n) => {
|
|
419
|
+
// A hook rides in its script's command and is no script to name.
|
|
420
|
+
const base = /^(?:pre|post)(.+)$/.exec(n)?.[1]
|
|
421
|
+
return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
|
|
422
|
+
})
|
|
423
|
+
}
|
|
410
424
|
const rootName =
|
|
411
425
|
root?.name ??
|
|
412
426
|
(outsideRuns
|
|
@@ -418,6 +432,14 @@ export function migrateScripts(
|
|
|
418
432
|
notes.push(
|
|
419
433
|
`${rootName} (the workspace root): its scripts that check the whole repo are its tasks; those that run the members (\`pnpm -r\`, \`--filter\`, a runner) or share a member's task name are left out`,
|
|
420
434
|
)
|
|
435
|
+
} else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
|
|
436
|
+
// react's nameless root: "its scripts run the workspace" was not why,
|
|
437
|
+
// and 30 of its scripts (`build`, `lint`, `test`) map once it has a
|
|
438
|
+
// name (D-87).
|
|
439
|
+
const would = unnamedMaps()
|
|
440
|
+
notes.push(
|
|
441
|
+
`package.json (the workspace root) not mapped: it has no "name", and vx names a project by it; give it one and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
|
|
442
|
+
)
|
|
421
443
|
} else if (rootName !== undefined) {
|
|
422
444
|
// A nameless root's vx.config is skipped (vx names projects by their
|
|
423
445
|
// manifest's name), so the hand-written one needs a name first (vuejs/core).
|