@vzn/vx 0.0.369 → 0.0.371
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 +27 -11
- package/src/cache/schema.ts +11 -5
- package/src/cli/select.ts +9 -1
- package/src/exec/sandbox-runtime.ts +24 -0
- package/src/exec/sandbox-violations.ts +39 -0
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/prepare.ts +4 -1
- package/src/orchestrator/projects.ts +15 -0
- package/src/orchestrator/run.ts +16 -1
- package/src/workspace/affected.ts +8 -4
- package/src/workspace/config-eval.ts +11 -1
- package/src/workspace/migrate-scripts.ts +19 -1
- package/src/workspace/project-loader.ts +40 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.371",
|
|
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.371",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.371",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.371",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.371"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -223,7 +223,16 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
223
223
|
// the last entry's bytes on disk under a green run (item 886). A row
|
|
224
224
|
// is current only with the inode and ctime recorded after the save
|
|
225
225
|
// or restore that wrote it. The cache KEY is unchanged.
|
|
226
|
-
|
|
226
|
+
// v29: entries.stdout moved to entry_stdout. SQLite rewrites a whole
|
|
227
|
+
// record on UPDATE, so the run-end `accessed_at` bump rewrote each
|
|
228
|
+
// hit's stored stdout (up to 16 MB) with its overflow pages: 200
|
|
229
|
+
// hits of 1 MB cost the close 125-150 ms. The cache KEY is
|
|
230
|
+
// unchanged.
|
|
231
|
+
export const SCHEMA_VERSION = 'v29'
|
|
232
|
+
|
|
233
|
+
/** An entry row with its stdout, which lives apart (v29); none stored reads as ''. */
|
|
234
|
+
const SELECT_ENTRY =
|
|
235
|
+
"SELECT e.*, COALESCE(s.stdout, '') AS stdout FROM entries e LEFT JOIN entry_stdout s ON s.hash = e.hash"
|
|
227
236
|
|
|
228
237
|
/** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
|
|
229
238
|
function schemaOrdinal(version: string): number {
|
|
@@ -439,6 +448,8 @@ export class Cache implements CacheLayer {
|
|
|
439
448
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
440
449
|
private readonly deleteEntryRow: ReturnType<Database['prepare']>
|
|
441
450
|
private readonly selectEntry: ReturnType<Database['prepare']>
|
|
451
|
+
private readonly upsertStdout: ReturnType<Database['prepare']>
|
|
452
|
+
private readonly deleteStdout: ReturnType<Database['prepare']>
|
|
442
453
|
private readonly entryExists: ReturnType<Database['prepare']>
|
|
443
454
|
private readonly bumpAccessed: ReturnType<Database['prepare']>
|
|
444
455
|
private readonly touched = new Set<string>()
|
|
@@ -675,7 +686,7 @@ export class Cache implements CacheLayer {
|
|
|
675
686
|
}
|
|
676
687
|
if (found === SCHEMA_VERSION) return null
|
|
677
688
|
this.db.exec(
|
|
678
|
-
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs;',
|
|
689
|
+
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs; DROP TABLE IF EXISTS entry_stdout;',
|
|
679
690
|
)
|
|
680
691
|
this.db
|
|
681
692
|
.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
|
|
@@ -697,10 +708,9 @@ export class Cache implements CacheLayer {
|
|
|
697
708
|
|
|
698
709
|
this.deleteEntryRow = this.db.prepare('DELETE FROM entries WHERE hash = ?')
|
|
699
710
|
this.insertEntry = this.db.prepare(`
|
|
700
|
-
INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes,
|
|
701
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
|
|
711
|
+
INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, created_at, accessed_at, cpu_ms, peak_rss_bytes)
|
|
712
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
702
713
|
ON CONFLICT(hash) DO UPDATE SET
|
|
703
|
-
stdout = excluded.stdout,
|
|
704
714
|
project = excluded.project,
|
|
705
715
|
task = excluded.task,
|
|
706
716
|
command = excluded.command,
|
|
@@ -711,12 +721,15 @@ export class Cache implements CacheLayer {
|
|
|
711
721
|
cpu_ms = excluded.cpu_ms,
|
|
712
722
|
peak_rss_bytes = excluded.peak_rss_bytes
|
|
713
723
|
`)
|
|
714
|
-
this.
|
|
715
|
-
|
|
716
|
-
|
|
724
|
+
this.upsertStdout = this.db.prepare(
|
|
725
|
+
'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
|
|
726
|
+
)
|
|
727
|
+
this.deleteStdout = this.db.prepare('DELETE FROM entry_stdout WHERE hash = ?')
|
|
728
|
+
this.selectEntry = this.db.prepare(`${SELECT_ENTRY} WHERE e.hash = ?`)
|
|
729
|
+
// `has` asks only whether the row is there, with no stdout joined. A
|
|
717
730
|
// column off the index, so the table row is still read and a corrupt
|
|
718
731
|
// table refuses here as it does on `get` (`SELECT 1` answers from the
|
|
719
|
-
// index alone)
|
|
732
|
+
// index alone).
|
|
720
733
|
this.entryExists = this.db.prepare('SELECT exit_code FROM entries WHERE hash = ?')
|
|
721
734
|
this.bumpAccessed = this.db.prepare('UPDATE entries SET accessed_at = ? WHERE hash = ?')
|
|
722
735
|
// INSERT OR IGNORE: re-saving the same hash (idempotent ingest /
|
|
@@ -961,7 +974,7 @@ export class Cache implements CacheLayer {
|
|
|
961
974
|
const placeholders = chunk.map(() => '?').join(',')
|
|
962
975
|
rows.push(
|
|
963
976
|
...(this.db
|
|
964
|
-
.query(
|
|
977
|
+
.query(`${SELECT_ENTRY} WHERE e.hash IN (${placeholders})`)
|
|
965
978
|
.all(...(chunk as readonly SQLQueryBindings[])) as EntryRow[]),
|
|
966
979
|
)
|
|
967
980
|
}
|
|
@@ -1580,6 +1593,8 @@ export class Cache implements CacheLayer {
|
|
|
1580
1593
|
// (not the per-run path) so a warm all-cache-hit run — which never
|
|
1581
1594
|
// saves — writes none of them.
|
|
1582
1595
|
const insertEntry = this.insertEntry
|
|
1596
|
+
const upsertStdout = this.upsertStdout
|
|
1597
|
+
const deleteStdout = this.deleteStdout
|
|
1583
1598
|
const outputs = this.outputs
|
|
1584
1599
|
const insertEntryInput = this.insertEntryInput
|
|
1585
1600
|
const inputComponents = meta.inputComponents
|
|
@@ -1629,7 +1644,6 @@ export class Cache implements CacheLayer {
|
|
|
1629
1644
|
0,
|
|
1630
1645
|
meta.durationMs,
|
|
1631
1646
|
totalBytes,
|
|
1632
|
-
stdoutText,
|
|
1633
1647
|
now,
|
|
1634
1648
|
now,
|
|
1635
1649
|
// From the artifact, on both paths: a save indexes what it just
|
|
@@ -1637,6 +1651,8 @@ export class Cache implements CacheLayer {
|
|
|
1637
1651
|
scanned.exec?.cpuMs ?? null,
|
|
1638
1652
|
scanned.exec?.peakRssBytes ?? null,
|
|
1639
1653
|
)
|
|
1654
|
+
if (stdoutText === '') deleteStdout.run(hash)
|
|
1655
|
+
else upsertStdout.run(hash, stdoutText)
|
|
1640
1656
|
outputs.replaceFileRows(hash, outputFileRows)
|
|
1641
1657
|
// INSERT OR IGNORE: identical inputs derive this same hash, so a
|
|
1642
1658
|
// re-save's rows are identical — keep the first set, skip the rest.
|
package/src/cache/schema.ts
CHANGED
|
@@ -18,10 +18,8 @@ export function createTables(db: Database): void {
|
|
|
18
18
|
`)
|
|
19
19
|
|
|
20
20
|
db.exec(`
|
|
21
|
-
-- The queryable index: command, exit_code, duration, size
|
|
22
|
-
-- timestamps
|
|
23
|
-
-- carries so it survives a remote round trip; a local hit
|
|
24
|
-
-- replays it from here.
|
|
21
|
+
-- The queryable index: command, exit_code, duration, size and
|
|
22
|
+
-- timestamps; stdout in entry_stdout below.
|
|
25
23
|
CREATE TABLE IF NOT EXISTS entries (
|
|
26
24
|
hash TEXT PRIMARY KEY,
|
|
27
25
|
project TEXT NOT NULL,
|
|
@@ -30,13 +28,21 @@ export function createTables(db: Database): void {
|
|
|
30
28
|
exit_code INTEGER NOT NULL,
|
|
31
29
|
duration_ms INTEGER NOT NULL,
|
|
32
30
|
size_bytes INTEGER NOT NULL,
|
|
33
|
-
stdout TEXT NOT NULL DEFAULT '',
|
|
34
31
|
created_at INTEGER NOT NULL,
|
|
35
32
|
accessed_at INTEGER NOT NULL,
|
|
36
33
|
-- v26: the producing execution's usage, from the artifact's sidecar.
|
|
37
34
|
cpu_ms INTEGER,
|
|
38
35
|
peak_rss_bytes INTEGER
|
|
39
36
|
);
|
|
37
|
+
-- v29: an entry's stdout, which the <hash>.tar.zst artifact also
|
|
38
|
+
-- carries so it survives a remote round trip; a local hit replays it
|
|
39
|
+
-- from here. Its own table: an UPDATE rewrites a whole record, and the
|
|
40
|
+
-- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
|
|
41
|
+
CREATE TABLE IF NOT EXISTS entry_stdout (
|
|
42
|
+
hash TEXT PRIMARY KEY,
|
|
43
|
+
stdout TEXT NOT NULL,
|
|
44
|
+
FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
45
|
+
);
|
|
40
46
|
CREATE TABLE IF NOT EXISTS runs (
|
|
41
47
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
48
|
-- '' when the outcome derived no cache key (skipped / persistent).
|
package/src/cli/select.ts
CHANGED
|
@@ -26,7 +26,12 @@ 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 { maskedLine, nearest, UserError } from '../util/index.js'
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
claimedAffected,
|
|
31
|
+
fingerprintClaims,
|
|
32
|
+
gitOfDiscovery,
|
|
33
|
+
keepDiscoveryGraph,
|
|
34
|
+
} from '../orchestrator/index.js'
|
|
30
35
|
import {
|
|
31
36
|
type CliLoadOptions,
|
|
32
37
|
discoverCliProjects,
|
|
@@ -251,6 +256,9 @@ export async function resolveFilters(
|
|
|
251
256
|
}
|
|
252
257
|
}
|
|
253
258
|
const graph = buildPackageGraph(projects, edges)
|
|
259
|
+
// The graph a run reusing this discovery builds is this one when no task
|
|
260
|
+
// edge went into it.
|
|
261
|
+
if (edges === undefined) keepDiscoveryGraph(projects, graph)
|
|
254
262
|
|
|
255
263
|
// Resolve every `[<since>]` filter against git before the pure
|
|
256
264
|
// applyFilters pass runs. One spawn per distinct ref — usually
|
|
@@ -88,6 +88,7 @@ import {
|
|
|
88
88
|
srtDefaultWritePaths,
|
|
89
89
|
} from './sandbox-deny-scan.js'
|
|
90
90
|
import {
|
|
91
|
+
hiddenReadsOutside,
|
|
91
92
|
parseStraceViolations,
|
|
92
93
|
refusedConnections,
|
|
93
94
|
refusedWrites,
|
|
@@ -1820,6 +1821,16 @@ async function runSandboxedOnce(
|
|
|
1820
1821
|
})
|
|
1821
1822
|
}
|
|
1822
1823
|
|
|
1824
|
+
if (exitCode !== 0) {
|
|
1825
|
+
const hidden = hiddenReadsOutside(recorded, {
|
|
1826
|
+
within: args.reportWithin,
|
|
1827
|
+
linked: args.reportLinked,
|
|
1828
|
+
config: args.config,
|
|
1829
|
+
skip: [taskTmpRoot()],
|
|
1830
|
+
})
|
|
1831
|
+
if (hidden.length > 0) violations.push(hiddenReadsHint(hidden, args.reportWithin))
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1823
1834
|
try {
|
|
1824
1835
|
SandboxManager.cleanupAfterCommand()
|
|
1825
1836
|
} catch {
|
|
@@ -1867,6 +1878,19 @@ function outsideWritesHint(paths: readonly string[]): SandboxViolation {
|
|
|
1867
1878
|
}
|
|
1868
1879
|
}
|
|
1869
1880
|
|
|
1881
|
+
function hiddenReadsHint(paths: readonly string[], within: string): SandboxViolation {
|
|
1882
|
+
const shown = paths.slice(0, 5).join(', ')
|
|
1883
|
+
const more = paths.length > 5 ? ` and ${paths.length - 5} more` : ''
|
|
1884
|
+
return {
|
|
1885
|
+
timestamp: new Date(),
|
|
1886
|
+
hint: true,
|
|
1887
|
+
line:
|
|
1888
|
+
`vx: the sandbox hid paths outside the project that exist on this machine, which are ` +
|
|
1889
|
+
`not reported as violations: ${shown}${more}. If the task reads one, grant it, e.g. ` +
|
|
1890
|
+
`\`allow: { read: ['${path.relative(toRealPath(within), paths[0]!)}'] }\`.`,
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1870
1894
|
/**
|
|
1871
1895
|
* Is `dir` inside a path the sandbox granted for reading? bwrap binds and
|
|
1872
1896
|
* seatbelt rules are both prefix-based, so a grant covers the subtree.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// task's owner can act on — inside the project, minus the loopback denial
|
|
5
5
|
// no grant can avoid, minus what the task chose to ignore.
|
|
6
6
|
|
|
7
|
+
import { statSync } from 'node:fs'
|
|
7
8
|
import path from 'node:path'
|
|
8
9
|
import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
|
|
9
10
|
import { bindableReads } from './sandbox-binds.js'
|
|
@@ -500,6 +501,44 @@ export function refusedWritesOutside(
|
|
|
500
501
|
return [...paths]
|
|
501
502
|
}
|
|
502
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The paths outside the project a read was denied that exist on this
|
|
506
|
+
* machine, files first: the reads `reportableViolations` drops at the
|
|
507
|
+
* wall. A task that failed on one said only what its tool says for a
|
|
508
|
+
* missing file — `tsc` extending a root `tsconfig.base.json` read "File
|
|
509
|
+
* not found" — and nothing named the sandbox (2026-10-02). Never a
|
|
510
|
+
* violation — a failed task's hint (`runSandboxed`). What a process walks
|
|
511
|
+
* through to reach its cwd (the project's ancestors) and what `skip` names
|
|
512
|
+
* are left out, as is a path that does not exist on the host: that miss
|
|
513
|
+
* is the tool's own.
|
|
514
|
+
*/
|
|
515
|
+
export function hiddenReadsOutside(
|
|
516
|
+
violations: readonly SandboxViolation[],
|
|
517
|
+
opts: {
|
|
518
|
+
within: string
|
|
519
|
+
linked?: readonly string[]
|
|
520
|
+
config: ResolvedSandboxConfig
|
|
521
|
+
skip: readonly string[]
|
|
522
|
+
},
|
|
523
|
+
): string[] {
|
|
524
|
+
const reported = reportedWithin(opts.within, opts.linked ?? [])
|
|
525
|
+
const home = toRealPath(opts.within)
|
|
526
|
+
const skip = opts.skip.map(toRealPath)
|
|
527
|
+
const found = new Map<string, boolean>()
|
|
528
|
+
for (const v of filterIgnored(describe(violations), opts.config.ignore)) {
|
|
529
|
+
const p = v.path
|
|
530
|
+
if (p === undefined || v.hint === true || !v.ignorable?.includes('read') || reported(v))
|
|
531
|
+
continue
|
|
532
|
+
if (found.has(p) || atOrUnder(home, p) || skip.some((s) => atOrUnder(p, s))) continue
|
|
533
|
+
try {
|
|
534
|
+
found.set(p, statSync(p).isDirectory())
|
|
535
|
+
} catch {
|
|
536
|
+
// Not on the host either: the tool's own miss.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return [...found].sort((a, b) => Number(a[1]) - Number(b[1])).map(([p]) => p)
|
|
540
|
+
}
|
|
541
|
+
|
|
503
542
|
/** Every record described: a seatbelt one parsed, a Linux one as produced. */
|
|
504
543
|
function describe(violations: readonly SandboxViolation[]): SandboxViolation[] {
|
|
505
544
|
return violations.map((v) =>
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import {
|
|
58
58
|
discoverProjects,
|
|
59
59
|
gitOfDiscovery,
|
|
60
|
+
graphOfDiscovery,
|
|
60
61
|
loadProjects,
|
|
61
62
|
loadWorkspacePlugins,
|
|
62
63
|
type LoadedProjects,
|
|
@@ -223,7 +224,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
223
224
|
// those configs are evaluated. Side effect, deliberate and
|
|
224
225
|
// Turbo-like: a broken config in an unrelated package no longer
|
|
225
226
|
// fails a scoped run — it surfaces when that package enters scope.
|
|
226
|
-
const packageGraph =
|
|
227
|
+
const packageGraph =
|
|
228
|
+
(reused !== undefined ? graphOfDiscovery(reused.projects) : undefined) ??
|
|
229
|
+
buildPackageGraph(projectMetas)
|
|
227
230
|
// Seeds: explicit scope, plus anchored pkg#task targets (which bypass
|
|
228
231
|
// scope by design). With no explicit scope, bare task names fan out
|
|
229
232
|
// across the whole workspace — but when EVERY spec is anchored, the
|
|
@@ -67,6 +67,21 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
|
|
|
67
67
|
return discoveredGit.get(projects)
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The package graph a selection pass built over a discovery, by that
|
|
72
|
+
* discovery's array, when it folded no task edge in: the graph a run that
|
|
73
|
+
* reuses the discovery (`RunOptions.discovered`) would build again.
|
|
74
|
+
*/
|
|
75
|
+
const discoveredGraph = new WeakMap<readonly ProjectMeta[], PackageGraph>()
|
|
76
|
+
|
|
77
|
+
export function graphOfDiscovery(projects: readonly ProjectMeta[]): PackageGraph | undefined {
|
|
78
|
+
return discoveredGraph.get(projects)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: PackageGraph): void {
|
|
82
|
+
discoveredGraph.set(projects, graph)
|
|
83
|
+
}
|
|
84
|
+
|
|
70
85
|
/**
|
|
71
86
|
* The workspace's projects: core's discovery, then the plugin `discover`
|
|
72
87
|
* stage. With no plugin declaring it, `listProjects` alone.
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -544,7 +544,21 @@ async function runOnBus(
|
|
|
544
544
|
// (`signals.ts`); the handlers are removed in the finally below so
|
|
545
545
|
// repeated run() calls (test suites) never stack listeners.
|
|
546
546
|
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
|
|
547
|
-
|
|
547
|
+
// A server that ends on its own while the graph still runs is said at
|
|
548
|
+
// once: its dependants were failing against it, and the end of the run
|
|
549
|
+
// was the first word of it (C-69). Not once the graph is done (the end
|
|
550
|
+
// of the run and the keep-alive wait say it) nor under a stop.
|
|
551
|
+
let graphDone = false
|
|
552
|
+
const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
|
|
553
|
+
override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
|
|
554
|
+
super.set(id, child)
|
|
555
|
+
void child.exited.then((code) => {
|
|
556
|
+
if (code !== 0 && !graphDone && !stopRun.signal.aborted)
|
|
557
|
+
log.status(`vx: ${id} exited with code ${code} while the run went on`)
|
|
558
|
+
})
|
|
559
|
+
return this
|
|
560
|
+
}
|
|
561
|
+
})()
|
|
548
562
|
// One stop for the run: an embedder's `RunOptions.signal` and a process
|
|
549
563
|
// signal both abort it. The scheduler stops dispatching (it reads the
|
|
550
564
|
// signal), the children are torn down, and run() returns through its own
|
|
@@ -951,6 +965,7 @@ async function runOnBus(
|
|
|
951
965
|
// Empty when the short-circuit didn't fire → byte-identical.
|
|
952
966
|
restoreTier: shortCircuit.restoreTier,
|
|
953
967
|
})
|
|
968
|
+
graphDone = true
|
|
954
969
|
|
|
955
970
|
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
956
971
|
// of the rest, before the summary prints. Scoped to the real CLI
|
|
@@ -125,8 +125,11 @@ export interface FingerprintClaims {
|
|
|
125
125
|
export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
|
|
126
126
|
// Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
|
|
127
127
|
// vx already diffs from the merge base (three dots' meaning), and the
|
|
128
|
-
// working tree it diffs to holds HEAD (D-117).
|
|
129
|
-
|
|
128
|
+
// working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
|
|
129
|
+
// diffs from A itself (G-145). Any other range is refused.
|
|
130
|
+
const headRange = /^(.+?)(\.{2,3})HEAD$/s.exec(args.since)
|
|
131
|
+
const since = headRange?.[1] ?? args.since
|
|
132
|
+
const fromMergeBase = headRange?.[2] !== '..'
|
|
130
133
|
// The base reaches git as an argument, never through a shell, so `$(…)`
|
|
131
134
|
// is opaque — but an option-like value is not: `--output=<path>` is a
|
|
132
135
|
// real `git diff` option and an arbitrary file write. This is a security
|
|
@@ -155,8 +158,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
155
158
|
// CI `--affected`), and hides your own edit when the base later landed
|
|
156
159
|
// byte-identical content. Turbo and Nx both diff from the merge base;
|
|
157
160
|
// when there is none (unrelated histories, a detached probe) the ref
|
|
158
|
-
// itself is the base, as before.
|
|
159
|
-
|
|
161
|
+
// itself is the base, as before. Turbo's two-dot range `A..HEAD` is the
|
|
162
|
+
// one form that diffs from A itself.
|
|
163
|
+
const base = fromMergeBase ? await mergeBase(args.workspaceRoot, since) : since
|
|
160
164
|
|
|
161
165
|
const [diffed, untracked] = await Promise.all([
|
|
162
166
|
// `--no-renames` is crucial for project-affected detection: with
|
|
@@ -109,6 +109,7 @@ self.onmessage = async (e) => {
|
|
|
109
109
|
const own = (o) => new Map(Reflect.ownKeys(o).map((k) => [k, Object.getOwnPropertyDescriptor(o, k)]))
|
|
110
110
|
const before = watched?.map(([, o]) => own(o))
|
|
111
111
|
const envBefore = watched ? { ...live } : null
|
|
112
|
+
const cwdBefore = watched ? globalThis.process.cwd() : null
|
|
112
113
|
const same = (a, b) =>
|
|
113
114
|
a !== undefined && b !== undefined && a.value === b.value && a.get === b.get && a.set === b.set &&
|
|
114
115
|
a.writable === b.writable && a.enumerable === b.enumerable && a.configurable === b.configurable
|
|
@@ -122,6 +123,7 @@ self.onmessage = async (e) => {
|
|
|
122
123
|
})
|
|
123
124
|
for (const k of new Set([...Object.keys(envBefore), ...Object.keys(live)]))
|
|
124
125
|
if (envBefore[k] !== live[k]) out.push('process.env.' + k)
|
|
126
|
+
if (globalThis.process.cwd() !== cwdBefore) out.push('process.cwd (a chdir)')
|
|
125
127
|
return out
|
|
126
128
|
}
|
|
127
129
|
try {
|
|
@@ -137,6 +139,7 @@ self.onmessage = async (e) => {
|
|
|
137
139
|
ok: true,
|
|
138
140
|
nonJson,
|
|
139
141
|
json: isObject && nonJson.length === 0 ? JSON.stringify(mod) : null,
|
|
142
|
+
fn: typeof mod === 'function',
|
|
140
143
|
changed: changed(),
|
|
141
144
|
})
|
|
142
145
|
} catch (err) {
|
|
@@ -156,12 +159,16 @@ self.onmessage = async (e) => {
|
|
|
156
159
|
}
|
|
157
160
|
`
|
|
158
161
|
|
|
162
|
+
const FUNCTION_EXPORT = (): void => {}
|
|
163
|
+
|
|
159
164
|
const WORKER_URL = `data:text/javascript,${encodeURIComponent(WORKER_SRC)}`
|
|
160
165
|
|
|
161
166
|
interface WorkerReply {
|
|
162
167
|
id: number
|
|
163
168
|
ok: boolean
|
|
164
169
|
json: string | null
|
|
170
|
+
/** The default export is a function, which JSON cannot carry back. */
|
|
171
|
+
fn?: boolean
|
|
165
172
|
nonJson: NonJsonValue[]
|
|
166
173
|
name: string
|
|
167
174
|
message: string
|
|
@@ -325,7 +332,10 @@ export async function evaluateConfigFresh(configPath: string): Promise<unknown>
|
|
|
325
332
|
})
|
|
326
333
|
const [nonJson] = reply.nonJson
|
|
327
334
|
if (nonJson !== undefined) throw new UserError(nonJsonMessage(configPath, nonJson))
|
|
328
|
-
|
|
335
|
+
// A function stands in for the one the config exported, so the caller's
|
|
336
|
+
// refusal names it as the in-process load's does.
|
|
337
|
+
if (reply.json === null) return reply.fn === true ? FUNCTION_EXPORT : null
|
|
338
|
+
return JSON.parse(reply.json) as unknown
|
|
329
339
|
} finally {
|
|
330
340
|
// In the `finally`, not after the await: a REJECTED evaluation — a config
|
|
331
341
|
// with a typo, the common case while editing — would otherwise skip the
|
|
@@ -738,6 +738,24 @@ export function migrateScripts(
|
|
|
738
738
|
return !LIFECYCLE.test(n) && (base === undefined || !(base in scripts))
|
|
739
739
|
})
|
|
740
740
|
}
|
|
741
|
+
// Why a root mapped nothing: `eslint .` beside a member's `lint` runs
|
|
742
|
+
// nothing of the workspace, and the note said it did.
|
|
743
|
+
const rootLeftOut = (): string => {
|
|
744
|
+
if (rootMeta === undefined) return 'run the workspace'
|
|
745
|
+
const scripts = scriptsOf(rootMeta)
|
|
746
|
+
const named = Object.keys(scripts).filter(
|
|
747
|
+
(n) => typeof scripts[n] === 'string' && scripts[n] !== '' && !LIFECYCLE.test(n),
|
|
748
|
+
)
|
|
749
|
+
const runs = runningMembers(
|
|
750
|
+
scripts,
|
|
751
|
+
rootMeta.dir,
|
|
752
|
+
metas.filter((m) => m !== root).map((m) => m.dir),
|
|
753
|
+
)
|
|
754
|
+
const shared = named.filter((n) => memberTasks.has(n) && !runs.has(n))
|
|
755
|
+
if (shared.length === 0) return 'run the workspace'
|
|
756
|
+
const list = `${shared.slice(0, 8).join(', ')}${shared.length > 8 ? ', …' : ''}`
|
|
757
|
+
return `${shared.length < named.length ? 'run the workspace or ' : ''}share a member's task name (${list})`
|
|
758
|
+
}
|
|
741
759
|
const rootName =
|
|
742
760
|
root?.name ??
|
|
743
761
|
(outsideRuns
|
|
@@ -768,7 +786,7 @@ export function migrateScripts(
|
|
|
768
786
|
// A nameless root's vx.config is skipped (vx names projects by their
|
|
769
787
|
// manifest's name), so the hand-written one needs a name first (vuejs/core).
|
|
770
788
|
notes.push(
|
|
771
|
-
`${rootName} (the workspace root) not mapped: its scripts
|
|
789
|
+
`${rootName} (the workspace root) not mapped: its scripts ${rootLeftOut()}; declare its own tasks in its vx.config by hand` +
|
|
772
790
|
(rootName === 'package.json' ? ', after giving its package.json a "name"' : ''),
|
|
773
791
|
)
|
|
774
792
|
}
|
|
@@ -37,12 +37,21 @@ export const WORKSPACE_CONFIG_FILENAMES = [
|
|
|
37
37
|
'vx.workspace.cjs',
|
|
38
38
|
]
|
|
39
39
|
|
|
40
|
+
/** What a function default export is told; the playground says the same. */
|
|
41
|
+
const EXPORTED_A_FUNCTION =
|
|
42
|
+
'it exports a function, and vx reads the object itself — export what the function returns'
|
|
43
|
+
|
|
40
44
|
/** Project configs already loaded in this process, by absolute path. */
|
|
41
45
|
const loadedConfigs = new Set<string>()
|
|
42
46
|
|
|
43
47
|
function assertDefaultObject(mod: unknown, kind: string, configPath: string): void {
|
|
44
48
|
if (!mod || typeof mod !== 'object') {
|
|
45
|
-
|
|
49
|
+
// Vite's `defineConfig(() => ({ … }))` shape: vx reads the object, and
|
|
50
|
+
// the bare refusal did not say what was there instead (D-110).
|
|
51
|
+
throw new UserError(
|
|
52
|
+
`${kind} config at ${configPath} did not export a default object` +
|
|
53
|
+
(typeof mod === 'function' ? `: ${EXPORTED_A_FUNCTION}` : ''),
|
|
54
|
+
)
|
|
46
55
|
}
|
|
47
56
|
}
|
|
48
57
|
|
|
@@ -441,6 +450,7 @@ export async function loadProjectConfigs(
|
|
|
441
450
|
// `node:stream`), ~7 ms of a two-config warm run where every load hit.
|
|
442
451
|
let builtins: BuiltinSnapshot = []
|
|
443
452
|
let env: Readonly<Record<string, string | undefined>> = {}
|
|
453
|
+
let cwd = ''
|
|
444
454
|
// More than one evaluation in flight: a change seen after one load may
|
|
445
455
|
// be another's.
|
|
446
456
|
let overlapping = false
|
|
@@ -484,7 +494,9 @@ export async function loadProjectConfigs(
|
|
|
484
494
|
: await loadDefaultExport(configPath, 'Project', bytes!)
|
|
485
495
|
// Before anything reads through them: a replaced `Array.prototype.includes`
|
|
486
496
|
// turned the JSON-data walk's own check into "a cyclic reference".
|
|
487
|
-
const changed = repeat
|
|
497
|
+
const changed = repeat
|
|
498
|
+
? []
|
|
499
|
+
: [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
|
|
488
500
|
// Loads overlap, so another config's change can surface after this
|
|
489
501
|
// one: named here, the refusal blamed the wrong file (D-119). The round
|
|
490
502
|
// finds the one that made it.
|
|
@@ -536,6 +548,7 @@ export async function loadProjectConfigs(
|
|
|
536
548
|
if (misses.length > 0) {
|
|
537
549
|
builtins = builtinSnapshot()
|
|
538
550
|
env = { ...process.env }
|
|
551
|
+
cwd = process.cwd()
|
|
539
552
|
}
|
|
540
553
|
overlapping = misses.length > 1
|
|
541
554
|
let next = 0
|
|
@@ -554,7 +567,10 @@ export async function loadProjectConfigs(
|
|
|
554
567
|
if (r.closure !== undefined) learnedClosures.push(r.closure)
|
|
555
568
|
}
|
|
556
569
|
}
|
|
557
|
-
const changed =
|
|
570
|
+
const changed =
|
|
571
|
+
misses.length > 0
|
|
572
|
+
? [...restoreBuiltins(builtins), ...restoreEnv(env), ...restoreCwd(cwd)]
|
|
573
|
+
: []
|
|
558
574
|
if (first?.failed instanceof ChangedInRound) {
|
|
559
575
|
for (const i of misses) {
|
|
560
576
|
const configPath = prepared[i]!.configPath
|
|
@@ -644,9 +660,13 @@ class ChangedInRound {
|
|
|
644
660
|
/** Without `configPath`, no config alone was found to make the change. */
|
|
645
661
|
function builtinsChanged(changed: readonly string[], configPath?: string): UserError {
|
|
646
662
|
const who = configPath === undefined ? 'a project config' : configPath
|
|
647
|
-
const env =
|
|
648
|
-
|
|
649
|
-
|
|
663
|
+
const env =
|
|
664
|
+
(changed.some((c) => c.startsWith('process.env.'))
|
|
665
|
+
? '; a task gets an env var through `exec.env.define` or `passThrough`'
|
|
666
|
+
: '') +
|
|
667
|
+
(changed.some((c) => c.startsWith('process.cwd'))
|
|
668
|
+
? '; a task runs in its project directory, and `cd <dir> && …` in `exec.command` moves it'
|
|
669
|
+
: '')
|
|
650
670
|
return new UserError(
|
|
651
671
|
`${who} changed ${changed.join(', ')} while it was evaluated — a config must not change the built-ins vx runs on: other configs are read through them and cache keys are made with them${env}`,
|
|
652
672
|
)
|
|
@@ -702,6 +722,20 @@ function restoreEnv(before: Readonly<Record<string, string | undefined>>): strin
|
|
|
702
722
|
return changed
|
|
703
723
|
}
|
|
704
724
|
|
|
725
|
+
/**
|
|
726
|
+
* A config's `process.chdir()` moved the whole process: every relative path
|
|
727
|
+
* vx resolves after it, its own and a plugin's, read from the config's
|
|
728
|
+
* choice (D-120). Put back, and named as the change.
|
|
729
|
+
*/
|
|
730
|
+
function restoreCwd(before: string): string[] {
|
|
731
|
+
// Through globalThis: the playground bundles this module, and its
|
|
732
|
+
// browser shim carries no free process global.
|
|
733
|
+
const proc = globalThis.process
|
|
734
|
+
if (before === '' || proc.cwd() === before) return []
|
|
735
|
+
proc.chdir(before)
|
|
736
|
+
return ['process.cwd (a chdir)']
|
|
737
|
+
}
|
|
738
|
+
|
|
705
739
|
function sameDescriptor(a: PropertyDescriptor, b: PropertyDescriptor): boolean {
|
|
706
740
|
return (
|
|
707
741
|
a.value === b.value &&
|