@vzn/vx 0.0.84 → 0.0.85
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/tar-stream.ts +21 -1
- package/src/cli/why.ts +25 -0
- package/src/exec/executor.ts +5 -4
- package/src/orchestrator/execute-task.ts +29 -2
- package/src/orchestrator/stable-keys.ts +58 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.85",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.85",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.85",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.85",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.85"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -30,6 +30,14 @@ export class TarFormatError extends Error {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const BLOCK = 512
|
|
33
|
+
/**
|
|
34
|
+
* An extended header's body is read whole, so its size is a claim on
|
|
35
|
+
* memory the stream has not paid for: a 1 GiB pax header zstd-compresses
|
|
36
|
+
* to ~32 KB and cost 2 GiB of RSS where a regular entry of that size
|
|
37
|
+
* streams in 32 MiB (L-1). A real one holds a path (≤ PATH_MAX) and a few
|
|
38
|
+
* numbers; a MiB is far past any writer's.
|
|
39
|
+
*/
|
|
40
|
+
const MAX_EXTENDED_HEADER = 1024 * 1024
|
|
33
41
|
const decoder = new TextDecoder()
|
|
34
42
|
|
|
35
43
|
function field(h: Uint8Array, off: number, len: number): string {
|
|
@@ -173,6 +181,11 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
|
|
|
173
181
|
pendingPath = undefined
|
|
174
182
|
pendingSize = undefined
|
|
175
183
|
|
|
184
|
+
if ((type === 'x' || type === 'L' || type === 'g') && size > MAX_EXTENDED_HEADER) {
|
|
185
|
+
throw new TarFormatError(
|
|
186
|
+
`extended header of ${size} bytes, past ${MAX_EXTENDED_HEADER} (a hostile archive?)`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
176
189
|
if (type === 'x' || type === 'L') {
|
|
177
190
|
// Extended header: applies to the NEXT entry only.
|
|
178
191
|
const body = await src.exact(padded, 'an extended header')
|
|
@@ -182,7 +195,14 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
|
|
|
182
195
|
const p = pax.get('path')
|
|
183
196
|
if (p !== undefined) pendingPath = p
|
|
184
197
|
const s = pax.get('size')
|
|
185
|
-
if (s !== undefined)
|
|
198
|
+
if (s !== undefined) {
|
|
199
|
+
// Digits only: `Number` reads `0.5` and `-1`, and a fractional
|
|
200
|
+
// size moved the reader to a fractional offset.
|
|
201
|
+
if (!/^[0-9]+$/.test(s) || !Number.isSafeInteger(Number(s))) {
|
|
202
|
+
throw new TarFormatError(`bad pax size: ${JSON.stringify(s)}`)
|
|
203
|
+
}
|
|
204
|
+
pendingSize = Number(s)
|
|
205
|
+
}
|
|
186
206
|
} else {
|
|
187
207
|
pendingPath = field(body, 0, size)
|
|
188
208
|
}
|
package/src/cli/why.ts
CHANGED
|
@@ -27,6 +27,26 @@ interface WhyArgs {
|
|
|
27
27
|
error?: string
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* What to do about a changed key component, by the kind `cache/key-fold.ts`
|
|
32
|
+
* captures. Held to that file's kinds in both directions by
|
|
33
|
+
* `tests/why.test.ts`.
|
|
34
|
+
*/
|
|
35
|
+
export const WHAT_TO_DO: Readonly<Record<string, string>> = {
|
|
36
|
+
file: 'an edit re-runs by design; a file the task does not read belongs out of cache.inputs.files',
|
|
37
|
+
env: "the variable's value moved; one the task does not read belongs out of cache.inputs.env",
|
|
38
|
+
runtime: 'the command printed something else; drop timestamps and paths from its output',
|
|
39
|
+
'ws-runtime':
|
|
40
|
+
'the workspace command printed something else; drop timestamps and paths from its output',
|
|
41
|
+
forward: 'the arguments after -- differ; the same arguments share one entry',
|
|
42
|
+
package: "the project's package.json changed (a dependency, version or script)",
|
|
43
|
+
workspace:
|
|
44
|
+
'a lockfile or root manifest changed, which re-keys every task; a lockfile plugin keys each project on its own dependencies',
|
|
45
|
+
config: "the task's evaluated config changed (its vx.config or a file that imports)",
|
|
46
|
+
upstream: "a dependency's key moved; `vx why <that task>` says why",
|
|
47
|
+
plugin: "a key plugin's material changed; that plugin decides what it folds",
|
|
48
|
+
}
|
|
49
|
+
|
|
30
50
|
export function parseWhyArgs(args: readonly string[]): WhyArgs {
|
|
31
51
|
const out: WhyArgs = { format: 'pretty' }
|
|
32
52
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -204,6 +224,11 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
204
224
|
: `${e.before} → ${e.after}`
|
|
205
225
|
lines.push(` ${e.change.padEnd(7)} ${e.kind.padEnd(kindW)} ${e.name} ${beforeAfter}`)
|
|
206
226
|
}
|
|
227
|
+
const kinds = [...new Set(diff.entries.map((e) => e.kind))].filter((k) => k in WHAT_TO_DO)
|
|
228
|
+
if (kinds.length > 0) {
|
|
229
|
+
lines.push('', ' what to do:')
|
|
230
|
+
for (const k of kinds) lines.push(` ${k.padEnd(kindW)} ${WHAT_TO_DO[k]}`)
|
|
231
|
+
}
|
|
207
232
|
} else if (why.hashChanged === true) {
|
|
208
233
|
lines.push(` detail ${diff.note}`)
|
|
209
234
|
}
|
package/src/exec/executor.ts
CHANGED
|
@@ -140,10 +140,11 @@ export interface ExecuteRequest {
|
|
|
140
140
|
readonly onStdout: (chunk: string) => void
|
|
141
141
|
readonly onStderr: (chunk: string) => void
|
|
142
142
|
/**
|
|
143
|
-
* Aborted when the run stops
|
|
144
|
-
* `RunOptions.signal
|
|
145
|
-
* for `execute
|
|
146
|
-
* not one core can reach (
|
|
143
|
+
* Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
|
|
144
|
+
* `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
|
|
145
|
+
* work and returns; core waits for `execute`, and a child an executor
|
|
146
|
+
* spawned is not one core can reach (H-10, H-12). A non-zero exit after
|
|
147
|
+
* the timeout's abort is recorded `timedOut`.
|
|
147
148
|
*/
|
|
148
149
|
readonly signal?: AbortSignal
|
|
149
150
|
/** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
|
|
@@ -472,6 +472,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
472
472
|
// (`--timeout`/`RunOptions.timeout` → `VX_TASK_TIMEOUT` → workspace
|
|
473
473
|
// `timeout`, already collapsed into `args.timeout` by run.ts).
|
|
474
474
|
const effectiveTimeout = step.timeout ?? args.timeout
|
|
475
|
+
// `signal` stops a plugin executor on the run's stop AND on
|
|
476
|
+
// `exec.timeout`: core cannot kill a process an executor spawned, so a
|
|
477
|
+
// declared timeout meant nothing on one that kept its own clock (H-12).
|
|
478
|
+
// The local executor keeps its own timer (it signals the process group).
|
|
479
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | undefined
|
|
480
|
+
let timeoutFired = false
|
|
481
|
+
function requestSignal(): AbortSignal {
|
|
482
|
+
const stop = new AbortController()
|
|
483
|
+
const abort = (): void => stop.abort(args.stopSignal?.reason)
|
|
484
|
+
if (args.stopSignal?.aborted === true) abort()
|
|
485
|
+
else args.stopSignal?.addEventListener('abort', abort, { once: true })
|
|
486
|
+
if (effectiveTimeout !== undefined) {
|
|
487
|
+
clearTimeout(timeoutTimer)
|
|
488
|
+
timeoutFired = false
|
|
489
|
+
timeoutTimer = setTimeout(() => {
|
|
490
|
+
timeoutFired = true
|
|
491
|
+
stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
|
|
492
|
+
}, effectiveTimeout)
|
|
493
|
+
}
|
|
494
|
+
return stop.signal
|
|
495
|
+
}
|
|
475
496
|
|
|
476
497
|
// When the task started, as a ns offset from run start — captured for
|
|
477
498
|
// EVERY outcome (hits included) so the run-detail timeline reflects when
|
|
@@ -701,7 +722,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
701
722
|
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
702
723
|
// and still prints it plainly for a UserError.
|
|
703
724
|
const endExec = span('miss: execute')
|
|
704
|
-
|
|
725
|
+
let res = await args.executor
|
|
705
726
|
.execute(req)
|
|
706
727
|
.then((r: unknown) => {
|
|
707
728
|
assertExecuteResult(args.executor.name, node.id, r)
|
|
@@ -713,7 +734,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
713
734
|
await sweepPlaceholders(placeholders)
|
|
714
735
|
throw err
|
|
715
736
|
})
|
|
737
|
+
.finally(() => clearTimeout(timeoutTimer))
|
|
716
738
|
endExec()
|
|
739
|
+
// An executor that stopped on the timeout's abort exits non-zero; say
|
|
740
|
+
// why, so the frame, the retry line and `timedOut` read as a timeout.
|
|
741
|
+
if (timeoutFired && res.exitCode !== 0 && res.timedOut !== true) {
|
|
742
|
+
res = { ...res, timedOut: true }
|
|
743
|
+
}
|
|
717
744
|
violations = [...res.violations]
|
|
718
745
|
// A denial under a dependency the key does not answer for says only
|
|
719
746
|
// ENOENT; the line beside it names the package and how to key it. Only
|
|
@@ -875,7 +902,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
875
902
|
onStdout: (chunk) => log.taskStdout(node, chunk),
|
|
876
903
|
onStderr: (chunk) => log.taskStderr(node, chunk),
|
|
877
904
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
878
|
-
|
|
905
|
+
signal: requestSignal(),
|
|
879
906
|
...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
|
|
880
907
|
...(inputs !== undefined ? { inputs } : {}),
|
|
881
908
|
...(cfgCacheable ? { cacheKey: hash } : {}),
|
|
@@ -81,8 +81,23 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
81
81
|
// preliminary. A dependency this key folds needs no pass-through here:
|
|
82
82
|
// a keyed task with such a rewriter unfolded is unstable itself, and
|
|
83
83
|
// instability is inherited below.
|
|
84
|
+
// - wideById: the subset of outputProjects whose writes are undeclared
|
|
85
|
+
// (`undeclaredWriteReach`), so they may land anywhere in the project.
|
|
86
|
+
// A declared-output producer reaches a same-project reader only where
|
|
87
|
+
// its output globs can meet the reader's inputs (`outputsOf`, A-20).
|
|
84
88
|
const projects = new ProjectIndex(args.nodes)
|
|
85
89
|
const outputProjectsById = new Map<string, ProjectSet>()
|
|
90
|
+
const wideById = new Map<string, ProjectSet>()
|
|
91
|
+
// Every project's declared `outputs.files`, over all its tasks: a superset
|
|
92
|
+
// of what any one reader's upstream producers declare.
|
|
93
|
+
const outputsOf = new Map<string, string[]>()
|
|
94
|
+
for (const n of args.nodes.values()) {
|
|
95
|
+
const files = n.config.cache?.outputs.files ?? []
|
|
96
|
+
if (files.length === 0) continue
|
|
97
|
+
const list = outputsOf.get(n.projectName)
|
|
98
|
+
if (list === undefined) outputsOf.set(n.projectName, [...files])
|
|
99
|
+
else list.push(...files)
|
|
100
|
+
}
|
|
86
101
|
const rewritersById = new Map<string, ProjectSet>()
|
|
87
102
|
const unfoldedById = new Map<string, ProjectSet>()
|
|
88
103
|
const wsRewriters = new Set<string>()
|
|
@@ -115,6 +130,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
115
130
|
// Fold every dep's accumulated producers + the dep's own declared
|
|
116
131
|
// outputs into this node's transitive-upstream producer sets.
|
|
117
132
|
const outputProjects = projects.empty()
|
|
133
|
+
let wide: ProjectSet | undefined
|
|
118
134
|
let rewriters: ProjectSet | undefined
|
|
119
135
|
let unfolded: ProjectSet | undefined
|
|
120
136
|
let folds: ((dep: string) => boolean) | undefined
|
|
@@ -126,6 +142,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
126
142
|
if (!depNode) continue
|
|
127
143
|
const upstreamOfDep = outputProjectsById.get(dep)
|
|
128
144
|
if (upstreamOfDep !== undefined) outputProjects.addAll(upstreamOfDep)
|
|
145
|
+
const wideOfDep = wideById.get(dep)
|
|
146
|
+
if (wideOfDep !== undefined) (wide ??= projects.empty()).addAll(wideOfDep)
|
|
129
147
|
if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
|
|
130
148
|
const depOut = depNode.config.cache?.outputs
|
|
131
149
|
if ((depOut?.files?.length ?? 0) > 0) outputProjects.add(depNode.projectName)
|
|
@@ -135,8 +153,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
135
153
|
// bytes it wrote: its key taken up front was the pre-producer one, and
|
|
136
154
|
// the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
|
|
137
155
|
const reach = undeclaredWriteReach(depNode, args.workspaceRoot)
|
|
138
|
-
if (reach === 'project')
|
|
139
|
-
|
|
156
|
+
if (reach === 'project') {
|
|
157
|
+
outputProjects.add(depNode.projectName)
|
|
158
|
+
;(wide ??= projects.empty()).add(depNode.projectName)
|
|
159
|
+
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
140
160
|
const cached = depNode.config.cache !== undefined
|
|
141
161
|
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot)) wsOutputUpstream = true
|
|
142
162
|
if (!anyUnfolded) continue
|
|
@@ -172,6 +192,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
172
192
|
if (wsRewriterOfDep || rewritesWs) wsUnfoldedHere = true
|
|
173
193
|
}
|
|
174
194
|
outputProjectsById.set(id, outputProjects)
|
|
195
|
+
if (wide !== undefined) wideById.set(id, wide)
|
|
175
196
|
wsOutputUpstreamById.set(id, wsOutputUpstream)
|
|
176
197
|
if (rewriters !== undefined) rewritersById.set(id, rewriters)
|
|
177
198
|
if (unfolded !== undefined) unfoldedById.set(id, unfolded)
|
|
@@ -207,6 +228,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
207
228
|
unfolded === undefined ? outputProjects : unfolded.or(outputProjects),
|
|
208
229
|
wsOutputUpstream || wsUnfoldedHere,
|
|
209
230
|
dirByProject,
|
|
231
|
+
{
|
|
232
|
+
wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
|
|
233
|
+
outputsOf,
|
|
234
|
+
},
|
|
210
235
|
)
|
|
211
236
|
if (unstable) unstableById.add(id)
|
|
212
237
|
|
|
@@ -292,12 +317,30 @@ export function dependsOnSiblingOutputs(
|
|
|
292
317
|
upstreamOutputProjects: ProjectNames,
|
|
293
318
|
hasWsOutputUpstream: boolean,
|
|
294
319
|
dirByProject?: ReadonlyMap<string, string>,
|
|
320
|
+
sameProject?: {
|
|
321
|
+
/** The producers whose writes may land anywhere in their project. */
|
|
322
|
+
wide: ProjectNames | undefined
|
|
323
|
+
/** Each project's declared `outputs.files`, over all its tasks. */
|
|
324
|
+
outputsOf: ReadonlyMap<string, readonly string[]>
|
|
325
|
+
},
|
|
295
326
|
): boolean {
|
|
296
327
|
const cache = node.config.cache
|
|
297
328
|
// A cache-disabled task has no key to prefetch anyway; treat as
|
|
298
329
|
// unstable-irrelevant (caller filters on cacheEnabled).
|
|
299
330
|
if (cache === undefined) return false
|
|
300
|
-
if (upstreamOutputProjects.has(node.projectName))
|
|
331
|
+
if (upstreamOutputProjects.has(node.projectName)) {
|
|
332
|
+
// A same-project producer whose writes are all declared reaches this
|
|
333
|
+
// task's inputs only where the globs can meet: a `test` reading `src/**`
|
|
334
|
+
// after a `build` writing `dist/**` keys the same before and after it,
|
|
335
|
+
// and classing it unstable hashed every such key twice more at 5,000
|
|
336
|
+
// projects (A-20). Undeclared writes, and a rewriter this key does not
|
|
337
|
+
// fold, may land anywhere: those stay project-wide.
|
|
338
|
+
if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
|
|
339
|
+
const inputs = cache.inputs?.files
|
|
340
|
+
const outputs = sameProject.outputsOf.get(node.projectName) ?? []
|
|
341
|
+
if (inputs === undefined || outputs.length === 0) return true
|
|
342
|
+
if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
|
|
343
|
+
}
|
|
301
344
|
// A root-anchored output is boundary-IGNORING by design, so it can land
|
|
302
345
|
// inside THIS task's own project dir — where an ordinary project-relative
|
|
303
346
|
// input reads it. Neither other clause sees that: `upstreamOutputProjects`
|
|
@@ -341,15 +384,8 @@ export function workspaceInputsReach(
|
|
|
341
384
|
): boolean {
|
|
342
385
|
for (const raw of workspaceFiles) {
|
|
343
386
|
if (raw.startsWith('!')) continue
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
const literal: string[] = []
|
|
347
|
-
for (const seg of segments) {
|
|
348
|
-
if (!isLiteralPattern(seg)) break
|
|
349
|
-
literal.push(seg)
|
|
350
|
-
}
|
|
351
|
-
if (literal.length === 0) return true
|
|
352
|
-
const prefix = literal.join('/')
|
|
387
|
+
const prefix = literalPrefix(raw)
|
|
388
|
+
if (prefix === '') return true
|
|
353
389
|
for (const dir of dirs) {
|
|
354
390
|
if (dir === '' || dir === '.') return true
|
|
355
391
|
if (prefix === dir || prefix.startsWith(`${dir}/`) || dir.startsWith(`${prefix}/`))
|
|
@@ -359,6 +395,16 @@ export function workspaceInputsReach(
|
|
|
359
395
|
return false
|
|
360
396
|
}
|
|
361
397
|
|
|
398
|
+
/** A glob's leading literal segments, `''` when its first holds a wildcard. */
|
|
399
|
+
function literalPrefix(glob: string): string {
|
|
400
|
+
const literal: string[] = []
|
|
401
|
+
for (const seg of normalizeGlob(glob).split('/')) {
|
|
402
|
+
if (!isLiteralPattern(seg)) break
|
|
403
|
+
literal.push(seg)
|
|
404
|
+
}
|
|
405
|
+
return literal.join('/')
|
|
406
|
+
}
|
|
407
|
+
|
|
362
408
|
/** What the stability gate reads of a set of project names. */
|
|
363
409
|
interface ProjectNames extends Iterable<string> {
|
|
364
410
|
has(name: string): boolean
|