@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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.635",
|
|
4
|
+
"description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": {
|
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
},
|
|
16
16
|
"types": "./src/index.ts",
|
|
17
17
|
"bin": {
|
|
18
|
-
"vx": "./launcher.
|
|
18
|
+
"vx": "./launcher.sh"
|
|
19
19
|
},
|
|
20
20
|
"engines": {
|
|
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.635",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.635",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.635",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.635"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"docs/patterns.md",
|
|
43
43
|
"docs/security.md",
|
|
44
44
|
"docs/features.md",
|
|
45
|
+
"launcher.sh",
|
|
45
46
|
"launcher.cjs",
|
|
46
47
|
"README.md",
|
|
47
48
|
"LICENSE"
|
|
@@ -64,6 +65,14 @@
|
|
|
64
65
|
"nx",
|
|
65
66
|
"bun",
|
|
66
67
|
"workspaces",
|
|
67
|
-
"ci"
|
|
68
|
+
"ci",
|
|
69
|
+
"task-orchestration",
|
|
70
|
+
"turborepo-alternative",
|
|
71
|
+
"nx-alternative",
|
|
72
|
+
"remote-cache",
|
|
73
|
+
"sandbox",
|
|
74
|
+
"incremental-build",
|
|
75
|
+
"mcp",
|
|
76
|
+
"pnpm"
|
|
68
77
|
]
|
|
69
78
|
}
|
package/schemas/last.json
CHANGED
|
@@ -170,7 +170,7 @@
|
|
|
170
170
|
},
|
|
171
171
|
"locations": {
|
|
172
172
|
"type": "array",
|
|
173
|
-
"description": "on a failed row: the existing files its output names, in order of first appearance",
|
|
173
|
+
"description": "on a failed row: the existing files its output names, in order of first appearance; a file it also names with a line drops its bare mention",
|
|
174
174
|
"items": {
|
|
175
175
|
"type": "object",
|
|
176
176
|
"additionalProperties": false,
|
|
@@ -181,6 +181,10 @@
|
|
|
181
181
|
"col": { "type": "integer" }
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
},
|
|
185
|
+
"fixedIn": {
|
|
186
|
+
"type": "string",
|
|
187
|
+
"description": "on a failed row: the first later run where this task passed (ran or hit); absent while it has not"
|
|
184
188
|
}
|
|
185
189
|
}
|
|
186
190
|
}
|
package/schemas/show.json
CHANGED
package/schemas/why.json
CHANGED
|
@@ -77,7 +77,10 @@
|
|
|
77
77
|
"type": ["boolean", "null"],
|
|
78
78
|
"description": "on a hit: true when outputs were restored, false when up-to-date"
|
|
79
79
|
},
|
|
80
|
-
"cached": {
|
|
80
|
+
"cached": {
|
|
81
|
+
"type": ["boolean", "null"],
|
|
82
|
+
"description": "whether the task declares a cache; not whether this run hit it"
|
|
83
|
+
},
|
|
81
84
|
"startedAt": { "type": "integer", "description": "epoch ms" }
|
|
82
85
|
}
|
|
83
86
|
},
|
|
@@ -76,7 +76,9 @@ export interface RemoteCacheLayer {
|
|
|
76
76
|
* straight into the local cache — an HTTP wire returns its `fetch`
|
|
77
77
|
* `Response`, a chunked wire `new Response(stream)`, bytes in hand
|
|
78
78
|
* `new Blob([bytes])`. `durationMs` is the producing task's duration
|
|
79
|
-
* when the wire carries it.
|
|
79
|
+
* when the wire carries it. A body that errors before its end stores
|
|
80
|
+
* nothing and the read is a miss, so a layer can check bytes as they pass
|
|
81
|
+
* (a digest, a signature) and refuse at the end.
|
|
80
82
|
*/
|
|
81
83
|
get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
|
|
82
84
|
/**
|
package/src/cli/last.ts
CHANGED
|
@@ -384,7 +384,15 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
384
384
|
)
|
|
385
385
|
const tasks = (detail?.tasks ?? []).map((t) => {
|
|
386
386
|
const f = failures.get(`${t.project}#${t.task}`)
|
|
387
|
-
return f === undefined
|
|
387
|
+
return f === undefined
|
|
388
|
+
? t
|
|
389
|
+
: {
|
|
390
|
+
...t,
|
|
391
|
+
output: f.output,
|
|
392
|
+
locations: f.locations,
|
|
393
|
+
...(f.sandboxFix !== undefined ? { sandboxFix: f.sandboxFix } : {}),
|
|
394
|
+
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
395
|
+
}
|
|
388
396
|
})
|
|
389
397
|
process.stdout.write(`${JSON.stringify({ invocation: inv, tasks })}\n`)
|
|
390
398
|
return 0
|
|
@@ -10,7 +10,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
10
10
|
},
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
|
-
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir,
|
|
13
|
+
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
|
|
14
14
|
test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
package/src/cli/run.ts
CHANGED
|
@@ -479,7 +479,9 @@ function onCI(v: string | undefined): boolean {
|
|
|
479
479
|
async function scopeFilters(
|
|
480
480
|
parsed: RunArgs,
|
|
481
481
|
cwd: string,
|
|
482
|
-
): Promise<
|
|
482
|
+
): Promise<
|
|
483
|
+
{ filterStrings: string[]; affectedFilter?: string } | { error: string; code?: string }
|
|
484
|
+
> {
|
|
483
485
|
// `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
|
|
484
486
|
// changed projects AND their dependents — what a CI gate must run, what
|
|
485
487
|
// the flag's name says, what the guides promised while the sugar was the
|
|
@@ -523,7 +525,7 @@ export async function resolveRunOptions(
|
|
|
523
525
|
verb: 'run' | 'watch' = 'run',
|
|
524
526
|
/** Given, it learns the ref `--affected` diffed from (`--dry=json` states it). */
|
|
525
527
|
scopeOut?: { affectedBase?: string },
|
|
526
|
-
): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
|
|
528
|
+
): Promise<RunOptions | { error: string; code?: string } | { nothingSelected: string }> {
|
|
527
529
|
for (const t of tasks) {
|
|
528
530
|
const idx = t.indexOf('#')
|
|
529
531
|
if (idx >= 0) {
|
|
@@ -566,7 +568,7 @@ export async function resolveRunOptions(
|
|
|
566
568
|
affectedFilter,
|
|
567
569
|
)
|
|
568
570
|
: undefined
|
|
569
|
-
if (resolved !== undefined && 'error' in resolved) return
|
|
571
|
+
if (resolved !== undefined && 'error' in resolved) return resolved
|
|
570
572
|
if (bareTasks.length === 0) {
|
|
571
573
|
projects = undefined
|
|
572
574
|
} else if (resolved !== undefined) {
|
|
@@ -696,11 +698,12 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
696
698
|
// `--filter` / `--affected` scope the menu, as they scope a bare task:
|
|
697
699
|
// the menu listed every project and the anchored pick ran outside it.
|
|
698
700
|
const scope = await scopeFilters(parsed, cwd)
|
|
699
|
-
if ('error' in scope) return refuse('run', args, scope.error, 'VX_E_USAGE')
|
|
701
|
+
if ('error' in scope) return refuse('run', args, scope.error, scope.code ?? 'VX_E_USAGE')
|
|
700
702
|
let only: ReadonlySet<string> | undefined
|
|
701
703
|
if (scope.filterStrings.length > 0) {
|
|
702
704
|
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
|
|
703
|
-
if ('error' in selected)
|
|
705
|
+
if ('error' in selected)
|
|
706
|
+
return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
|
|
704
707
|
if ('empty' in selected) {
|
|
705
708
|
process.stderr.write(`vx run: ${selected.empty}\n`)
|
|
706
709
|
return 0
|
|
@@ -716,7 +719,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
716
719
|
|
|
717
720
|
const scopeOut: { affectedBase?: string } = {}
|
|
718
721
|
const resolved = await resolveRunOptions(parsed, cwd, tasks, 'run', scopeOut)
|
|
719
|
-
if ('error' in resolved) return refuse('run', args, resolved.error, 'VX_E_USAGE')
|
|
722
|
+
if ('error' in resolved) return refuse('run', args, resolved.error, resolved.code ?? 'VX_E_USAGE')
|
|
720
723
|
if ('nothingSelected' in resolved) {
|
|
721
724
|
process.stderr.write(`vx run: ${resolved.nothingSelected}\n`)
|
|
722
725
|
return 0
|
package/src/cli/select.ts
CHANGED
|
@@ -197,7 +197,7 @@ export type FilterResolution =
|
|
|
197
197
|
/** What the other includes selected (`RunOptions.selectedOutright`). */
|
|
198
198
|
outright?: string[]
|
|
199
199
|
}
|
|
200
|
-
|
|
|
200
|
+
| Refusal
|
|
201
201
|
| { empty: string }
|
|
202
202
|
|
|
203
203
|
/**
|
|
@@ -239,16 +239,23 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
|
|
|
239
239
|
return out
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
/** A refusal a resolver returns; `code` is the thrown one's own, when it has one. */
|
|
243
|
+
type Refusal = { error: string; code?: string }
|
|
244
|
+
|
|
245
|
+
function refusalOf(err: unknown): Refusal {
|
|
246
|
+
if (!(err instanceof Error)) return { error: String(err) }
|
|
247
|
+
return isUserError(err) && err.code !== 'VX_E_REFUSED'
|
|
248
|
+
? { error: err.message, code: err.code }
|
|
249
|
+
: { error: err.message }
|
|
250
|
+
}
|
|
251
|
+
|
|
242
252
|
/**
|
|
243
253
|
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
244
254
|
* projects and their dependents. An empty base is the workspace's
|
|
245
255
|
* `affectedBase` (or a plugin's `config` stage, from nx.json's
|
|
246
256
|
* `defaultBase` or `TURBO_SCM_BASE`), then the guess.
|
|
247
257
|
*/
|
|
248
|
-
export async function affectedFilterFor(
|
|
249
|
-
cwd: string,
|
|
250
|
-
affected: string,
|
|
251
|
-
): Promise<string | { error: string }> {
|
|
258
|
+
export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
|
|
252
259
|
const root = await findWorkspaceRoot(cwd)
|
|
253
260
|
let base = affected
|
|
254
261
|
if (base === '') base = (await loadCliWorkspace(root)).workspaceConfig?.affectedBase ?? ''
|
|
@@ -257,7 +264,7 @@ export async function affectedFilterFor(
|
|
|
257
264
|
base = await defaultAffectedBase(root)
|
|
258
265
|
} catch (err) {
|
|
259
266
|
if (!isUserError(err)) throw err
|
|
260
|
-
return
|
|
267
|
+
return refusalOf(err)
|
|
261
268
|
}
|
|
262
269
|
}
|
|
263
270
|
return `...[${base}]`
|
|
@@ -276,7 +283,7 @@ export async function resolveFilters(
|
|
|
276
283
|
try {
|
|
277
284
|
parsed = raw.map((r) => parseFilter(r, root))
|
|
278
285
|
} catch (err) {
|
|
279
|
-
return
|
|
286
|
+
return refusalOf(err)
|
|
280
287
|
}
|
|
281
288
|
// `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
|
|
282
289
|
const walksGraph = parsed.some(
|
|
@@ -299,7 +306,7 @@ export async function resolveFilters(
|
|
|
299
306
|
try {
|
|
300
307
|
edges = taskEdgesFrom(await stagedOnce())
|
|
301
308
|
} catch (err) {
|
|
302
|
-
return
|
|
309
|
+
return refusalOf(err)
|
|
303
310
|
}
|
|
304
311
|
}
|
|
305
312
|
// A tag lives in the config, so a `tag:` selector needs the staged load
|
|
@@ -309,7 +316,7 @@ export async function resolveFilters(
|
|
|
309
316
|
try {
|
|
310
317
|
tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
|
|
311
318
|
} catch (err) {
|
|
312
|
-
return
|
|
319
|
+
return refusalOf(err)
|
|
313
320
|
}
|
|
314
321
|
}
|
|
315
322
|
const graph = buildPackageGraph(projects, edges)
|
|
@@ -353,8 +360,7 @@ export async function resolveFilters(
|
|
|
353
360
|
}
|
|
354
361
|
affectedByFilter.set(f, names)
|
|
355
362
|
} catch (err) {
|
|
356
|
-
|
|
357
|
-
return { error: msg }
|
|
363
|
+
return refusalOf(err)
|
|
358
364
|
}
|
|
359
365
|
}
|
|
360
366
|
|
package/src/cli/show.ts
CHANGED
|
@@ -145,12 +145,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
145
145
|
let affected: string | undefined
|
|
146
146
|
if (parsed.affected !== undefined) {
|
|
147
147
|
const f = await affectedFilterFor(process.cwd(), parsed.affected)
|
|
148
|
-
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, 'VX_E_USAGE')
|
|
148
|
+
if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
|
|
149
149
|
affected = f
|
|
150
150
|
raw.unshift(f)
|
|
151
151
|
}
|
|
152
152
|
const r = await resolveFilters(process.cwd(), raw, { noCreate: true }, affected)
|
|
153
|
-
if ('error' in r) throw new UserError(`vx show: ${r.error}`, 'VX_E_USAGE')
|
|
153
|
+
if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
|
|
154
154
|
if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
|
|
155
155
|
selected = new Set('empty' in r ? [] : r.names)
|
|
156
156
|
if ('names' in r && r.affected !== undefined)
|
package/src/config.ts
CHANGED
|
@@ -65,9 +65,11 @@ export interface WorkspaceConfig {
|
|
|
65
65
|
* Plugins registered for this workspace, consulted in this order once
|
|
66
66
|
* per `vx run`: each fills any of the pipeline stages and capabilities
|
|
67
67
|
* below, and core's own executor and cache store sit at the tail of
|
|
68
|
-
* every list. See `docs/design/pipeline-2026-09.md`.
|
|
68
|
+
* every list. See `docs/design/pipeline-2026-09.md`. An `undefined`,
|
|
69
|
+
* `null` or `false` entry is skipped, so a factory that declines
|
|
70
|
+
* (`github()` off Actions) or `cond && plugin()` needs no filter.
|
|
69
71
|
*/
|
|
70
|
-
plugins?: readonly Plugin[]
|
|
72
|
+
plugins?: readonly (Plugin | undefined | null | false)[]
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/** `WorkspaceConfig.rules`. */
|
|
@@ -488,6 +490,16 @@ export interface CacheConfig {
|
|
|
488
490
|
inputs: CacheInputs
|
|
489
491
|
/** What this task produces. Captured for restore on a cache hit. */
|
|
490
492
|
outputs: CacheOutputs
|
|
493
|
+
/**
|
|
494
|
+
* What a write to an input while the command runs means. `'withhold'`
|
|
495
|
+
* (the default): the entry is not saved, whatever the file holds after,
|
|
496
|
+
* since an edit undone mid-run cannot be told from no edit. `'compare'`:
|
|
497
|
+
* a file rewritten to the bytes the key folded counts as unchanged, for
|
|
498
|
+
* a task that rewrites its own input identically (tsdown's
|
|
499
|
+
* `package.json`). `VX_REWRITTEN_INPUTS=compare` sets the default for
|
|
500
|
+
* every task that does not say.
|
|
501
|
+
*/
|
|
502
|
+
rewrittenInputs?: 'withhold' | 'compare'
|
|
491
503
|
}
|
|
492
504
|
|
|
493
505
|
export interface CacheOutputs {
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -208,6 +208,15 @@ export interface TaskOutcome {
|
|
|
208
208
|
* inline in the task's block instead of as loose status output.
|
|
209
209
|
*/
|
|
210
210
|
sandboxViolationLines?: string[]
|
|
211
|
+
/** The `exec.sandbox.allow` entries that would have granted the denials. */
|
|
212
|
+
sandboxFix?: {
|
|
213
|
+
read?: string[]
|
|
214
|
+
write?: string[]
|
|
215
|
+
network?: string[]
|
|
216
|
+
systemInfo?: string[]
|
|
217
|
+
/** Credential paths denied and left out of the grants: an agent applies the fix as given. */
|
|
218
|
+
withheld?: string[]
|
|
219
|
+
}
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
export type ContinueMode = 'never' | 'deps-ok' | 'always'
|
|
@@ -804,14 +813,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
804
813
|
// tasks are never asked, so they never park.
|
|
805
814
|
const takeFitting = (): string | undefined => {
|
|
806
815
|
if (holding) return undefined
|
|
807
|
-
// No pools
|
|
808
|
-
//
|
|
816
|
+
// No pools and a full exec lane: nothing on the exec queue can be
|
|
817
|
+
// admitted, so it is not scanned — an admit policy only narrows
|
|
818
|
+
// the count gate, never widens it (scanning under one cost the
|
|
819
|
+
// history plugin's cold bench run 10 s, 2026-10-09) — scanning it
|
|
809
820
|
// would pop and re-park every ready exec task on every tick, and
|
|
810
821
|
// with a wide frontier that is O(R) per tick, O(R²) per run (the
|
|
811
822
|
// 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
|
|
812
823
|
// first cut let the scan run past a full exec lane, 2026-09-10).
|
|
813
824
|
// This is the legacy O(1) gate, kept per lane.
|
|
814
|
-
const execAdmissible = poolOf !== undefined ||
|
|
825
|
+
const execAdmissible = poolOf !== undefined || execRoom()
|
|
815
826
|
if (execAdmissible) {
|
|
816
827
|
while (execReady.size > 0) {
|
|
817
828
|
const seq = execReady.peekSeq()
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -302,6 +302,7 @@ export interface BuildGraphOptions {
|
|
|
302
302
|
export function undeclaredDepsError(taskId: string, name: string): UserError {
|
|
303
303
|
return new UserError(
|
|
304
304
|
`Task ${taskId} depends on ^${name} but no project in the workspace declares ${name}`,
|
|
305
|
+
'VX_E_DEPENDS_ON',
|
|
305
306
|
)
|
|
306
307
|
}
|
|
307
308
|
|
|
@@ -492,7 +493,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
492
493
|
spec = parseDependencySpec(raw)
|
|
493
494
|
} catch (err) {
|
|
494
495
|
if (err instanceof DependencySpecError) {
|
|
495
|
-
throw new UserError(`Task ${id}: ${err.message}
|
|
496
|
+
throw new UserError(`Task ${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
|
|
496
497
|
}
|
|
497
498
|
throw err
|
|
498
499
|
}
|
|
@@ -503,14 +504,21 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
503
504
|
// operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
|
|
504
505
|
// they name a namespace of tasks to add (Nx 19.5 parity).
|
|
505
506
|
if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
|
|
506
|
-
throw new UserError(
|
|
507
|
+
throw new UserError(
|
|
508
|
+
`Task ${id}: dependsOn does not accept bare wildcards (got "${raw}")`,
|
|
509
|
+
'VX_E_DEPENDS_ON',
|
|
510
|
+
)
|
|
507
511
|
}
|
|
508
512
|
if (spec.negated) {
|
|
509
|
-
throw new UserError(
|
|
513
|
+
throw new UserError(
|
|
514
|
+
`Task ${id}: dependsOn does not accept negation (got "${raw}")`,
|
|
515
|
+
'VX_E_DEPENDS_ON',
|
|
516
|
+
)
|
|
510
517
|
}
|
|
511
518
|
if (spec.kind === 'cross' && (isTaskPattern(spec.task) || isTaskPattern(spec.project))) {
|
|
512
519
|
throw new UserError(
|
|
513
520
|
`Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
|
|
521
|
+
'VX_E_DEPENDS_ON',
|
|
514
522
|
)
|
|
515
523
|
}
|
|
516
524
|
if (spec.kind === 'self') {
|
|
@@ -530,6 +538,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
530
538
|
if (!visit(frame, projectName, spec.task, false)) {
|
|
531
539
|
throw new UserError(
|
|
532
540
|
`Task ${id} depends on ${taskId(projectName, spec.task)} but no such task is declared`,
|
|
541
|
+
'VX_E_DEPENDS_ON',
|
|
533
542
|
)
|
|
534
543
|
}
|
|
535
544
|
goPast(frame, projectName, spec.task, new Set([projectName]))
|
|
@@ -594,9 +603,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
594
603
|
if (spec.project === '//')
|
|
595
604
|
throw new UserError(
|
|
596
605
|
`Task ${id} depends on //#${spec.task}, Turbo's root package, but the workspace root is no project here: give it a package.json name and a vx.config with that task`,
|
|
606
|
+
'VX_E_DEPENDS_ON',
|
|
597
607
|
)
|
|
598
608
|
throw new UserError(
|
|
599
609
|
`Task ${id} depends on ${taskId(spec.project, spec.task)} but no such project or task is declared`,
|
|
610
|
+
'VX_E_DEPENDS_ON',
|
|
600
611
|
)
|
|
601
612
|
}
|
|
602
613
|
goPast(frame, spec.project, spec.task, new Set([projectName, spec.project]))
|
|
@@ -1109,7 +1120,7 @@ function collideAcross(
|
|
|
1109
1120
|
`cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
|
|
1110
1121
|
`cache-hit restore, so whichever of these runs second DELETES the other's output`
|
|
1111
1122
|
if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
|
|
1112
|
-
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}
|
|
1123
|
+
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
|
|
1113
1124
|
}
|
|
1114
1125
|
if (reaches(files.id, ws.id)) {
|
|
1115
1126
|
;(files.addsToOutputsOf ??= []).push(ws.id)
|
|
@@ -1132,11 +1143,13 @@ function collideAcross(
|
|
|
1132
1143
|
`${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
|
|
1133
1144
|
`cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
|
|
1134
1145
|
`and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
|
|
1146
|
+
'VX_E_OUTPUT_OVERLAP',
|
|
1135
1147
|
)
|
|
1136
1148
|
}
|
|
1137
1149
|
throw new UserError(
|
|
1138
1150
|
`${head} and the run still reports success. Give each task its own output path` +
|
|
1139
1151
|
(exclusive ? '.' : ', or make one depend on the other.'),
|
|
1152
|
+
'VX_E_OUTPUT_OVERLAP',
|
|
1140
1153
|
)
|
|
1141
1154
|
}
|
|
1142
1155
|
}
|
|
@@ -1373,7 +1386,8 @@ function collide(
|
|
|
1373
1386
|
// `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
|
|
1374
1387
|
// in either order, and the refusal below stands.
|
|
1375
1388
|
const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
|
|
1376
|
-
if (up !== undefined && exclusive)
|
|
1389
|
+
if (up !== undefined && exclusive)
|
|
1390
|
+
throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
|
|
1377
1391
|
if (up !== undefined && down !== undefined) {
|
|
1378
1392
|
const downGlobs = splitNegations(
|
|
1379
1393
|
(field === 'files'
|
|
@@ -1386,6 +1400,7 @@ function collide(
|
|
|
1386
1400
|
}
|
|
1387
1401
|
throw new UserError(
|
|
1388
1402
|
`${head} and the run still reports success. Give each task its own output path.`,
|
|
1403
|
+
'VX_E_OUTPUT_OVERLAP',
|
|
1389
1404
|
)
|
|
1390
1405
|
}
|
|
1391
1406
|
}
|
|
@@ -1440,6 +1455,7 @@ function refuseSelfClean(node: TaskNode): void {
|
|
|
1440
1455
|
`its own output — vx removes a task's outputs before it runs, so the task would ` +
|
|
1441
1456
|
`delete its own sources. A task that rewrites files in place (a formatter) declares ` +
|
|
1442
1457
|
`no outputs.`,
|
|
1458
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1443
1459
|
)
|
|
1444
1460
|
}
|
|
1445
1461
|
}
|
|
@@ -1663,6 +1679,7 @@ function readsOutputs(
|
|
|
1663
1679
|
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1664
1680
|
`cache.inputs.${field}, or set rules: { upfrontKeys: false } in vx.workspace.ts to ` +
|
|
1665
1681
|
`let it wait for its producer.`,
|
|
1682
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1666
1683
|
)
|
|
1667
1684
|
}
|
|
1668
1685
|
}
|
package/src/index.ts
CHANGED
|
@@ -65,7 +65,12 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
|
|
|
65
65
|
// (`@vzn/vx-schedule-history` does; a plugin package reaches core only
|
|
66
66
|
// through this façade, which is what put these here).
|
|
67
67
|
export { LocalHistoryProvider } from './orchestrator/index.js'
|
|
68
|
-
export type {
|
|
68
|
+
export type {
|
|
69
|
+
HistoryProvider,
|
|
70
|
+
HistoryTable,
|
|
71
|
+
TaskHistory,
|
|
72
|
+
TaskResources,
|
|
73
|
+
} from './orchestrator/index.js'
|
|
69
74
|
export type { PreparedRun } from './orchestrator/index.js'
|
|
70
75
|
// `RunOptions.affected`: what `--affected`'s diff changed, per project.
|
|
71
76
|
export type { AffectedChanges } from './workspace/index.js'
|
|
@@ -78,6 +78,7 @@ import type { FingerprintWatch } from './fingerprint-watch.js'
|
|
|
78
78
|
import { restoreHit } from './hit-restore.js'
|
|
79
79
|
import type { MissExplainer } from './miss-reason.js'
|
|
80
80
|
import { shellVerdict } from './shell-verdict.js'
|
|
81
|
+
import { sandboxFix } from './sandbox-fix.js'
|
|
81
82
|
// The hit path's entry stays importable from here (tests).
|
|
82
83
|
export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
|
|
83
84
|
import type { DeferredOutputs } from './deferred-outputs.js'
|
|
@@ -336,6 +337,18 @@ async function restoreOrMiss(
|
|
|
336
337
|
}
|
|
337
338
|
}
|
|
338
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Does a write to an input during the command count as unchanged when it
|
|
342
|
+
* left the bytes the key folded (`movedInput`'s `writesByBytes`)? Only when
|
|
343
|
+
* the task says `compare`, or says nothing and `VX_REWRITTEN_INPUTS` is
|
|
344
|
+
* `compare`: by default any such write withholds the save, on CI too,
|
|
345
|
+
* since a concurrent task can change an input and put it back (owner,
|
|
346
|
+
* 2026-10-12).
|
|
347
|
+
*/
|
|
348
|
+
function rewritesByBytes(cache: CacheConfig | undefined): boolean {
|
|
349
|
+
return (cache?.rewrittenInputs ?? process.env['VX_REWRITTEN_INPUTS']) === 'compare'
|
|
350
|
+
}
|
|
351
|
+
|
|
339
352
|
/**
|
|
340
353
|
* The workspace fingerprint every key here folded has moved (a task
|
|
341
354
|
* rewrote the lockfile), said once per run.
|
|
@@ -1558,7 +1571,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1558
1571
|
*/
|
|
1559
1572
|
async function movedSinceKey(): Promise<string | null | undefined> {
|
|
1560
1573
|
if (described!.hash !== hash) return null
|
|
1561
|
-
return (
|
|
1574
|
+
return (
|
|
1575
|
+
(await movedInput(
|
|
1576
|
+
described!.facts,
|
|
1577
|
+
cache,
|
|
1578
|
+
described!.describedAt,
|
|
1579
|
+
rewritesByBytes(args.node.config.cache),
|
|
1580
|
+
)) ?? described!.added()
|
|
1581
|
+
)
|
|
1562
1582
|
}
|
|
1563
1583
|
|
|
1564
1584
|
/**
|
|
@@ -1608,6 +1628,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1608
1628
|
}
|
|
1609
1629
|
|
|
1610
1630
|
const finalViolations = violations
|
|
1631
|
+
const fixOf = (fix: TaskOutcome['sandboxFix']) => (fix === undefined ? {} : { sandboxFix: fix })
|
|
1611
1632
|
|
|
1612
1633
|
return {
|
|
1613
1634
|
node,
|
|
@@ -1640,6 +1661,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1640
1661
|
sandboxViolations: finalViolations.filter((v) => v.hint !== true).length,
|
|
1641
1662
|
// A denied path is the task's own spelling, a secret in it too.
|
|
1642
1663
|
sandboxViolationLines: finalViolations.map((v) => secrets?.mask(v.line) ?? v.line),
|
|
1664
|
+
...fixOf(
|
|
1665
|
+
sandboxFix(
|
|
1666
|
+
finalViolations,
|
|
1667
|
+
node.projectDir,
|
|
1668
|
+
(t) => secrets?.mask(t) ?? t,
|
|
1669
|
+
cacheCfg?.inputs.files,
|
|
1670
|
+
),
|
|
1671
|
+
),
|
|
1643
1672
|
}
|
|
1644
1673
|
: {}),
|
|
1645
1674
|
}
|
|
@@ -32,6 +32,7 @@ import { paint, type ColorSupport } from './colors.js'
|
|
|
32
32
|
import { neverStarted } from './summary.js'
|
|
33
33
|
import { outcomeLabel, ranNoCache, skippedReason } from './events.js'
|
|
34
34
|
import { fileMemo, linkPaths } from './path-links.js'
|
|
35
|
+
import { sandboxFixLine } from './sandbox-fix.js'
|
|
35
36
|
|
|
36
37
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
37
38
|
|
|
@@ -529,7 +530,32 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
529
530
|
// every record for telemetry.
|
|
530
531
|
const body = [...new Set(vlines)]
|
|
531
532
|
// Red, not amber: a violation fails the task, so it reads as a failure.
|
|
532
|
-
|
|
533
|
+
// vx's note, after the evidence: the grants that would have allowed it.
|
|
534
|
+
// A read the output depends on is a key input too; the sandbox never
|
|
535
|
+
// adds one (owner, 2026-10-09).
|
|
536
|
+
const fix = outcome.sandboxFix
|
|
537
|
+
const line = fix === undefined ? undefined : sandboxFixLine(fix)
|
|
538
|
+
const dim = (text: string): string => paint('', text, colors, { dim: true })
|
|
539
|
+
const note =
|
|
540
|
+
fix === undefined
|
|
541
|
+
? []
|
|
542
|
+
: [
|
|
543
|
+
...(line !== undefined ? [dim(`fix: ${line}`)] : []),
|
|
544
|
+
...(fix.read !== undefined
|
|
545
|
+
? [dim('a read the output depends on belongs in cache.inputs.files too')]
|
|
546
|
+
: []),
|
|
547
|
+
...(fix.withheld !== undefined
|
|
548
|
+
? [dim(`credential path, grant it only on purpose: ${fix.withheld.join(', ')}`)]
|
|
549
|
+
: []),
|
|
550
|
+
'',
|
|
551
|
+
]
|
|
552
|
+
return [
|
|
553
|
+
sectionLine(`SANDBOX VIOLATIONS (${body.length})`, ERROR, colors),
|
|
554
|
+
'',
|
|
555
|
+
...body,
|
|
556
|
+
'',
|
|
557
|
+
...note,
|
|
558
|
+
]
|
|
533
559
|
}
|
|
534
560
|
|
|
535
561
|
export function formatFrameClose(
|