@vzn/vx 0.0.350 → 0.0.353
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 +1 -1
- package/package.json +5 -5
- package/src/cache/cache.ts +23 -1
- package/src/cache/file-hashes.ts +30 -0
- package/src/cache/git-inputs.ts +160 -9
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +33 -8
- package/src/cache/key-fold.ts +5 -1
- package/src/cache/schema.ts +9 -0
- package/src/cli/show.ts +7 -1
- package/src/cli/workspace-config.ts +1 -1
- package/src/exec/index.ts +7 -1
- package/src/exec/sandbox-paths.ts +22 -0
- package/src/exec/sandbox-runtime.ts +48 -3
- package/src/exec/sandbox-violations.ts +41 -6
- package/src/graph/scheduler.ts +9 -1
- package/src/orchestrator/execute-task.ts +2 -0
- package/src/orchestrator/options.ts +2 -1
- package/src/orchestrator/prepare.ts +27 -5
- package/src/orchestrator/projects.ts +9 -3
- package/src/orchestrator/run.ts +10 -2
- package/src/orchestrator/sandbox-request.ts +6 -5
- package/src/orchestrator/shell-verdict.ts +10 -1
- package/src/workspace/config-schema.ts +18 -0
- package/src/workspace/migrate-scripts.ts +6 -4
- package/src/workspace/migration.ts +27 -5
package/README.md
CHANGED
|
@@ -180,7 +180,7 @@ keep your Turbo or Nx remote cache. [Parity map](packages/vx/docs/parity.md) ·
|
|
|
180
180
|
| Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
|
|
181
181
|
| Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive |
|
|
182
182
|
| Clean-tree hashing | **Zero reads** (git index OIDs) | git OIDs | re-hash / daemon |
|
|
183
|
-
| Daemon required for speed | **No** |
|
|
183
|
+
| Daemon required for speed | **No** | No (`turbo run` has none) | Yes |
|
|
184
184
|
| Per-task sandbox | **Yes**: kernel-level, opt-in | No | No |
|
|
185
185
|
| Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
|
|
186
186
|
| OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.353",
|
|
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.353",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.353",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.353",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.353"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -674,7 +674,7 @@ export class Cache implements CacheLayer {
|
|
|
674
674
|
}
|
|
675
675
|
if (found === SCHEMA_VERSION) return null
|
|
676
676
|
this.db.exec(
|
|
677
|
-
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; 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;',
|
|
677
|
+
'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;',
|
|
678
678
|
)
|
|
679
679
|
this.db
|
|
680
680
|
.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
|
|
@@ -798,6 +798,14 @@ export class Cache implements CacheLayer {
|
|
|
798
798
|
hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
|
|
799
799
|
return this.guard(() => this.files.hashFiles(paths))
|
|
800
800
|
}
|
|
801
|
+
/** `BlobSizeMemo`: the sizes of these blobs this cache has learned. */
|
|
802
|
+
knownBlobSizes(oids: readonly string[]): Map<string, number> {
|
|
803
|
+
return this.guard(() => this.files.knownBlobSizes(oids))
|
|
804
|
+
}
|
|
805
|
+
/** `BlobSizeMemo`: remember blob sizes, honouring the local WRITE axis. */
|
|
806
|
+
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
|
|
807
|
+
this.guard(() => this.files.rememberBlobSizes(sizes))
|
|
808
|
+
}
|
|
801
809
|
/**
|
|
802
810
|
* `relPosix` against the run's workspace root, memoized: the same three
|
|
803
811
|
* thousand files are re-relativized for every task of the project, which
|
|
@@ -807,11 +815,25 @@ export class Cache implements CacheLayer {
|
|
|
807
815
|
*/
|
|
808
816
|
private relMemo = new Map<string, string>()
|
|
809
817
|
private relMemoRoot: string | undefined
|
|
818
|
+
/** `root` and a slash, when a file under it is relative by slicing; else undefined. */
|
|
819
|
+
private relPrefix: string | undefined
|
|
810
820
|
private relFor(root: string, file: string): string {
|
|
811
821
|
if (root !== this.relMemoRoot) {
|
|
812
822
|
this.relMemoRoot = root
|
|
813
823
|
this.relMemo.clear()
|
|
824
|
+
this.relPrefix =
|
|
825
|
+
path.sep === '/' && path.isAbsolute(root) && path.normalize(root) === root
|
|
826
|
+
? root.endsWith('/')
|
|
827
|
+
? root
|
|
828
|
+
: `${root}/`
|
|
829
|
+
: undefined
|
|
814
830
|
}
|
|
831
|
+
// An input file is a normalized absolute path (`resolveInputs` makes
|
|
832
|
+
// every one), and under a normalized root that is the root, a slash and
|
|
833
|
+
// its relative path: a slice, where the memo hashed the whole path per
|
|
834
|
+
// file per task (~15 ms over 36,000 files, I-31).
|
|
835
|
+
const prefix = this.relPrefix
|
|
836
|
+
if (prefix !== undefined && file.startsWith(prefix)) return file.slice(prefix.length)
|
|
815
837
|
let rel = this.relMemo.get(file)
|
|
816
838
|
if (rel === undefined) this.relMemo.set(file, (rel = relPosix(root, file)))
|
|
817
839
|
return rel
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -68,12 +68,42 @@ export class FileHashStore {
|
|
|
68
68
|
if (last !== null && now - Number(last.value) < DAY_MS) return
|
|
69
69
|
this.db.transaction(() => {
|
|
70
70
|
this.db.prepare('DELETE FROM file_hashes WHERE seen_at < ?').run(cutoff)
|
|
71
|
+
this.db.prepare('DELETE FROM blob_sizes WHERE seen_at < ?').run(cutoff)
|
|
71
72
|
this.db
|
|
72
73
|
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
73
74
|
.run(FILE_HASHES_SWEPT_AT, String(now))
|
|
74
75
|
})()
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/** Each of `oids` whose size a `rememberBlobSizes` stored: one `IN` query per 900. */
|
|
79
|
+
knownBlobSizes(oids: readonly string[]): Map<string, number> {
|
|
80
|
+
const out = new Map<string, number>()
|
|
81
|
+
for (let i = 0; i < oids.length; i += 900) {
|
|
82
|
+
const chunk = oids.slice(i, i + 900)
|
|
83
|
+
const rows = this.db
|
|
84
|
+
.query(`SELECT oid, size FROM blob_sizes WHERE oid IN (${chunk.map(() => '?').join(',')})`)
|
|
85
|
+
.all(...chunk) as Array<{ oid: string; size: number }>
|
|
86
|
+
for (const r of rows) out.set(r.oid, r.size)
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Store blob sizes in one transaction; a memo, so a full disk skips it. */
|
|
92
|
+
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
|
|
93
|
+
if (!this.write || sizes.size === 0) return
|
|
94
|
+
const now = Date.now()
|
|
95
|
+
const insert = this.db.prepare(
|
|
96
|
+
'INSERT OR REPLACE INTO blob_sizes(oid, size, seen_at) VALUES (?, ?, ?)',
|
|
97
|
+
)
|
|
98
|
+
try {
|
|
99
|
+
this.db.transaction(() => {
|
|
100
|
+
for (const [oid, size] of sizes) insert.run(oid, size, now)
|
|
101
|
+
})()
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (!isIndexFull(err)) throw err
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
77
107
|
/**
|
|
78
108
|
* Content-hash a file (as a git blob OID, v20) with an mtime+size
|
|
79
109
|
* fast path. If the `file_hashes` table has a row for `path` whose
|
package/src/cache/git-inputs.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// task declared. Nothing here reads a config or applies a boundary.
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import { existsSync, lstatSync, realpathSync } from 'node:fs'
|
|
9
|
+
import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
|
|
10
10
|
import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
|
|
11
11
|
|
|
12
12
|
/** Three facts of the repository a directory is in, from one `git rev-parse`. */
|
|
@@ -394,7 +394,14 @@ function expandNestedRepos(
|
|
|
394
394
|
}
|
|
395
395
|
out ??= files.slice(0, i)
|
|
396
396
|
const abs = path.join(cwd, nested)
|
|
397
|
-
if (!existsSync(path.join(abs, '.git')))
|
|
397
|
+
if (!existsSync(path.join(abs, '.git'))) {
|
|
398
|
+
// No repository answers for it: a submodule never initialised (empty)
|
|
399
|
+
// or one whose `.git` was removed to vendor its files, the gitlink
|
|
400
|
+
// left in the index and `git status` silent. Its files are what the
|
|
401
|
+
// task reads, so a walk lists them; they hash by content (A-61).
|
|
402
|
+
for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
|
|
403
|
+
continue
|
|
404
|
+
}
|
|
398
405
|
const inner = runGitLsFiles(abs)
|
|
399
406
|
for (const f of inner.files) out.push(`${nested}/${f}`)
|
|
400
407
|
for (const f of inner.undecodable) undecodable.push(`${nested}/${f}`)
|
|
@@ -402,6 +409,24 @@ function expandNestedRepos(
|
|
|
402
409
|
return { files: out ?? files, undecodable }
|
|
403
410
|
}
|
|
404
411
|
|
|
412
|
+
/** Every file and symlink under `dir`, relative and `/`-separated; a `.git` is skipped, a link not followed. */
|
|
413
|
+
function walkFiles(dir: string, rel = ''): string[] {
|
|
414
|
+
let entries
|
|
415
|
+
try {
|
|
416
|
+
entries = readdirSync(path.join(dir, rel), { withFileTypes: true })
|
|
417
|
+
} catch {
|
|
418
|
+
return []
|
|
419
|
+
}
|
|
420
|
+
const out: string[] = []
|
|
421
|
+
for (const e of entries) {
|
|
422
|
+
if (e.name === '.git') continue
|
|
423
|
+
const r = rel === '' ? e.name : `${rel}/${e.name}`
|
|
424
|
+
if (e.isDirectory()) out.push(...walkFiles(dir, r))
|
|
425
|
+
else out.push(r)
|
|
426
|
+
}
|
|
427
|
+
return out
|
|
428
|
+
}
|
|
429
|
+
|
|
405
430
|
/**
|
|
406
431
|
* A file's identity as the key folds it: its blob OID, prefixed by its git
|
|
407
432
|
* mode unless that is a plain file's (100644). A blob OID holds no mode, so
|
|
@@ -451,6 +476,108 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
|
|
|
451
476
|
return { files, oids, flagged, gitlinks, undecodable }
|
|
452
477
|
}
|
|
453
478
|
|
|
479
|
+
/**
|
|
480
|
+
* `ls-files -s -v -z --debug` as the plain `-s -v -z` stream, plus the size
|
|
481
|
+
* the index records for each regular stage-0 entry's worktree file and its
|
|
482
|
+
* raw OID. With `-z`, each record's NUL is followed by its five
|
|
483
|
+
* newline-ended debug lines (ctime, mtime, dev/ino, uid/gid, size/flags),
|
|
484
|
+
* then the next record.
|
|
485
|
+
*/
|
|
486
|
+
function stripLsDebug(run: GitRun): {
|
|
487
|
+
plain: string
|
|
488
|
+
undecodable: Set<string>
|
|
489
|
+
indexed: Map<string, { oid: string; size: number }>
|
|
490
|
+
} {
|
|
491
|
+
const segs = run.stdout.split('\0')
|
|
492
|
+
const records: string[] = []
|
|
493
|
+
const undecodable = new Set<string>()
|
|
494
|
+
const indexed = new Map<string, { oid: string; size: number }>()
|
|
495
|
+
let record = segs[0]!
|
|
496
|
+
let recordRaw = record
|
|
497
|
+
for (let i = 1; i <= segs.length; i++) {
|
|
498
|
+
const seg = segs[i]
|
|
499
|
+
let rest = ''
|
|
500
|
+
let size = -1
|
|
501
|
+
if (seg !== undefined) {
|
|
502
|
+
let at = 0
|
|
503
|
+
for (let k = 0; k < 5 && at >= 0; k++) {
|
|
504
|
+
const nl = seg.indexOf('\n', at)
|
|
505
|
+
if (nl < 0) at = -1
|
|
506
|
+
else {
|
|
507
|
+
if (k === 4) size = Number(/size: (\d+)/.exec(seg.slice(at, nl))?.[1] ?? -1)
|
|
508
|
+
at = nl + 1
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
rest = at < 0 ? '' : seg.slice(at)
|
|
512
|
+
}
|
|
513
|
+
if (record.length > 0) {
|
|
514
|
+
records.push(record)
|
|
515
|
+
if (run.undecodable.has(recordRaw)) undecodable.add(record)
|
|
516
|
+
const m = LS_FILES_STAGE_RE.exec(record)
|
|
517
|
+
if (m !== null && m[4] === '0' && (m[2] === '100644' || m[2] === '100755')) {
|
|
518
|
+
indexed.set(record.slice(m[0].length), { oid: m[3]!, size })
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
record = rest
|
|
522
|
+
recordRaw = seg ?? ''
|
|
523
|
+
}
|
|
524
|
+
return { plain: records.join('\0'), undecodable, indexed }
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Blob sizes learned once and kept: a blob's size is fixed for its OID. `Cache` is one. */
|
|
528
|
+
export interface BlobSizeMemo {
|
|
529
|
+
knownBlobSizes(oids: readonly string[]): Map<string, number>
|
|
530
|
+
rememberBlobSizes(sizes: ReadonlyMap<string, number>): void
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Drop each trusted OID whose blob is not the size the index recorded for
|
|
535
|
+
* the worktree file (A-60). A clean filter's blob differs from the file it
|
|
536
|
+
* was added from, and git holds a stat-clean entry clean without re-reading
|
|
537
|
+
* it once the filter is gone (`core.autocrlf` turned off, a `.gitattributes`
|
|
538
|
+
* rule removed): `status` and the filter gate, which reads today's config,
|
|
539
|
+
* both let the blob stand for bytes it does not hold. The recorded sizes
|
|
540
|
+
* cost no read of the worktree; the blob sizes come from `memo`, and only
|
|
541
|
+
* the ones it lacks from one `cat-file` (65 ms over 3,000 loose objects,
|
|
542
|
+
* the whole of the cost, measured 2026-10-02). A smudged (racy) entry
|
|
543
|
+
* records 0 and is hashed from disk. An answer that cannot be read trusts
|
|
544
|
+
* nothing.
|
|
545
|
+
*/
|
|
546
|
+
async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
|
|
547
|
+
const { trusted, indexed } = enumeration
|
|
548
|
+
const wanted = new Set<string>()
|
|
549
|
+
for (const rel of trusted.keys()) {
|
|
550
|
+
const entry = indexed.get(rel)
|
|
551
|
+
if (entry !== undefined) wanted.add(entry.oid)
|
|
552
|
+
}
|
|
553
|
+
if (wanted.size === 0) return
|
|
554
|
+
const sizes = memo?.knownBlobSizes([...wanted]) ?? new Map<string, number>()
|
|
555
|
+
const unknown = [...wanted].filter((oid) => !sizes.has(oid))
|
|
556
|
+
if (unknown.length > 0) {
|
|
557
|
+
const run = await enumeration.catFile(unknown.join('\n') + '\n')
|
|
558
|
+
if (run === null || run.exitCode !== 0) {
|
|
559
|
+
trusted.clear()
|
|
560
|
+
return
|
|
561
|
+
}
|
|
562
|
+
const learned = new Map<string, number>()
|
|
563
|
+
for (const line of run.stdout.split('\n')) {
|
|
564
|
+
const sp = line.indexOf(' ')
|
|
565
|
+
const n = Number(line.slice(sp + 1))
|
|
566
|
+
if (sp > 0 && line.slice(sp + 1) !== '' && Number.isInteger(n))
|
|
567
|
+
learned.set(line.slice(0, sp), n)
|
|
568
|
+
}
|
|
569
|
+
for (const [oid, n] of learned) sizes.set(oid, n)
|
|
570
|
+
memo?.rememberBlobSizes(learned)
|
|
571
|
+
}
|
|
572
|
+
for (const rel of trusted.keys()) {
|
|
573
|
+
const entry = indexed.get(rel)
|
|
574
|
+
if (entry === undefined) continue // a symlink: its blob is its target string
|
|
575
|
+
const size = sizes.get(entry.oid)
|
|
576
|
+
// The index keeps the low 32 bits of a size.
|
|
577
|
+
if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rel)
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
454
581
|
/** One completed `git` invocation. */
|
|
455
582
|
interface GitRun {
|
|
456
583
|
exitCode: number
|
|
@@ -825,7 +952,7 @@ export async function populateGitFilesCache(
|
|
|
825
952
|
workspaceRoot,
|
|
826
953
|
gitPathspecs(workspaceRoot, projectDirs, workspaceWide),
|
|
827
954
|
)
|
|
828
|
-
applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
|
|
955
|
+
await applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
|
|
829
956
|
}
|
|
830
957
|
|
|
831
958
|
/** What one workspace-wide enumeration learned, before it is partitioned per project. */
|
|
@@ -851,6 +978,10 @@ export interface GitEnumeration {
|
|
|
851
978
|
undecodable: readonly string[]
|
|
852
979
|
/** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
|
|
853
980
|
startedAtMs: number
|
|
981
|
+
/** Each regular stage-0 entry's raw OID and the worktree size the index recorded. */
|
|
982
|
+
indexed: ReadonlyMap<string, { oid: string; size: number }>
|
|
983
|
+
/** `git cat-file --batch-check` over `stdin`'s OIDs; null when it could not spawn. */
|
|
984
|
+
catFile(stdin: string): Promise<{ exitCode: number; stdout: string } | null>
|
|
854
985
|
}
|
|
855
986
|
|
|
856
987
|
/** Above this many project dirs the enumeration walks the whole tree. */
|
|
@@ -891,7 +1022,11 @@ export async function startGitEnumeration(
|
|
|
891
1022
|
// bulk-populate costs max(ls-files, status) wall time, not the sum
|
|
892
1023
|
// (status alone is ~74 ms on a 1000-project tree; serial spawning
|
|
893
1024
|
// was a measurable warm-path regression vs the pre-OID code).
|
|
894
|
-
const spawnGit = async (
|
|
1025
|
+
const spawnGit = async (
|
|
1026
|
+
args: string[],
|
|
1027
|
+
stdin?: string,
|
|
1028
|
+
env?: Record<string, string>,
|
|
1029
|
+
): Promise<GitRun | null> => {
|
|
895
1030
|
try {
|
|
896
1031
|
const proc = Bun.spawn({
|
|
897
1032
|
// `status` refreshes the index when it can take `index.lock`, so
|
|
@@ -903,6 +1038,7 @@ export async function startGitEnumeration(
|
|
|
903
1038
|
stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
|
|
904
1039
|
stdout: 'pipe',
|
|
905
1040
|
stderr: 'pipe',
|
|
1041
|
+
...(env !== undefined ? { env: { ...process.env, ...env } } : {}),
|
|
906
1042
|
})
|
|
907
1043
|
const [stdout, stderr, exitCode] = await Promise.all([
|
|
908
1044
|
new Response(proc.stdout).bytes(),
|
|
@@ -924,8 +1060,14 @@ export async function startGitEnumeration(
|
|
|
924
1060
|
// tracked paths are dirty AND which files are untracked. Asking
|
|
925
1061
|
// `ls-files --others` for the untracked set walked the same tree a second
|
|
926
1062
|
// time (~50 ms of CPU, concurrent with status but contending with it).
|
|
1063
|
+
// `--debug` adds each entry's recorded stat, for the blob-size check
|
|
1064
|
+
// (`dropResizedOids`, A-60).
|
|
1065
|
+
const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs]).then(
|
|
1066
|
+
(run) =>
|
|
1067
|
+
run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: stripLsDebug(run) },
|
|
1068
|
+
)
|
|
927
1069
|
const running = Promise.all([
|
|
928
|
-
|
|
1070
|
+
listing,
|
|
929
1071
|
// `--ignored=matching` names an ignored path without descending into an
|
|
930
1072
|
// ignored directory: git applies an ignored `.gitattributes` as it does
|
|
931
1073
|
// any other, and the filter gate below must see it (A-19).
|
|
@@ -965,7 +1107,7 @@ export async function startGitEnumeration(
|
|
|
965
1107
|
// at all. `--git-dir` named the per-worktree directory, so the gate
|
|
966
1108
|
// looked where the rule can never be.
|
|
967
1109
|
const facts = repoFacts(workspaceRoot)
|
|
968
|
-
const [ls, status, vars] = await running
|
|
1110
|
+
const [{ run: ls, debug }, status, vars] = await running
|
|
969
1111
|
if (ls === null) {
|
|
970
1112
|
throw gitSpawnRefusal(workspaceRoot)
|
|
971
1113
|
}
|
|
@@ -982,7 +1124,7 @@ export async function startGitEnumeration(
|
|
|
982
1124
|
flagged,
|
|
983
1125
|
gitlinks,
|
|
984
1126
|
undecodable,
|
|
985
|
-
} = parseLsFilesOutput(
|
|
1127
|
+
} = parseLsFilesOutput(debug!.plain, debug!.undecodable)
|
|
986
1128
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
987
1129
|
// the `--show-prefix`) so the dirty set is keyed identically to the trusted
|
|
988
1130
|
// OID map. Without this, when the workspace root is a git subdir, a modified
|
|
@@ -1060,6 +1202,13 @@ export async function startGitEnumeration(
|
|
|
1060
1202
|
untracked: dirty === null ? null : untracked,
|
|
1061
1203
|
undecodable,
|
|
1062
1204
|
startedAtMs,
|
|
1205
|
+
indexed: debug!.indexed,
|
|
1206
|
+
// A missing blob is an answer ("missing"), never a fetch from a partial
|
|
1207
|
+
// clone's promisor remote.
|
|
1208
|
+
catFile: (stdin) =>
|
|
1209
|
+
spawnGit(['cat-file', '--batch-check=%(objectname) %(objectsize)'], stdin, {
|
|
1210
|
+
GIT_NO_LAZY_FETCH: '1',
|
|
1211
|
+
}),
|
|
1063
1212
|
}
|
|
1064
1213
|
}
|
|
1065
1214
|
|
|
@@ -1089,13 +1238,15 @@ export function lazyGitEnumeration(workspaceRoot: string): LazyGitEnumeration {
|
|
|
1089
1238
|
}
|
|
1090
1239
|
|
|
1091
1240
|
/** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
|
|
1092
|
-
export function applyGitEnumeration(
|
|
1241
|
+
export async function applyGitEnumeration(
|
|
1093
1242
|
enumeration: GitEnumeration,
|
|
1094
1243
|
workspaceRoot: string,
|
|
1095
1244
|
projectDirs: readonly string[],
|
|
1096
1245
|
cache: GitFilesCache,
|
|
1097
1246
|
workspaceWide = false,
|
|
1098
|
-
|
|
1247
|
+
memo?: BlobSizeMemo,
|
|
1248
|
+
): Promise<void> {
|
|
1249
|
+
await dropResizedOids(enumeration, memo)
|
|
1099
1250
|
const { all, trusted } = enumeration
|
|
1100
1251
|
cache.setWorktreeDirty(enumeration.dirty)
|
|
1101
1252
|
cache.enumeratedAtMs = enumeration.startedAtMs
|
package/src/cache/index.ts
CHANGED
package/src/cache/inputs.ts
CHANGED
|
@@ -448,6 +448,22 @@ async function runRuntimeCommand(
|
|
|
448
448
|
*/
|
|
449
449
|
const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
|
|
450
450
|
let probeExitHooked = false
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Kill every runtime probe still running, with its tree. A run's stop asks
|
|
454
|
+
* this: a Ctrl-C while a probe ran waited for the probe, or for the signal
|
|
455
|
+
* handler's bound, about 7 s, before vx exited (C-65). Its answer is no
|
|
456
|
+
* longer needed; its caller sees it fail and the run reads it aborted.
|
|
457
|
+
*/
|
|
458
|
+
export function stopRuntimeProbes(): void {
|
|
459
|
+
for (const p of liveProbes) {
|
|
460
|
+
try {
|
|
461
|
+
process.kill(-p.pid, 'SIGKILL')
|
|
462
|
+
} catch {
|
|
463
|
+
// the group is gone
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
451
467
|
function killProbesOnExit(): void {
|
|
452
468
|
if (probeExitHooked) return
|
|
453
469
|
probeExitHooked = true
|
|
@@ -1041,6 +1057,13 @@ interface FilesPlan {
|
|
|
1041
1057
|
positiveGlobs: Bun.Glob[]
|
|
1042
1058
|
/** The literal entries, each naming one path (see `unmatchedLiterals`). */
|
|
1043
1059
|
literals: string[]
|
|
1060
|
+
/**
|
|
1061
|
+
* Whether a project-relative path is an input by the declaration alone:
|
|
1062
|
+
* a positive glob selects it, and no exclude and no own output takes it
|
|
1063
|
+
* back. Projects repeat their relative paths (`src/index.ts`,
|
|
1064
|
+
* `package.json`), so each is matched once per plan.
|
|
1065
|
+
*/
|
|
1066
|
+
verdicts: Map<string, boolean>
|
|
1044
1067
|
}
|
|
1045
1068
|
|
|
1046
1069
|
/**
|
|
@@ -1082,6 +1105,7 @@ function filesPlan(
|
|
|
1082
1105
|
ownOutput: outputMatcher(ownOutputs, globFor),
|
|
1083
1106
|
positiveGlobs: asTrees(positive).map(globFor),
|
|
1084
1107
|
literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
|
|
1108
|
+
verdicts: new Map<string, boolean>(),
|
|
1085
1109
|
}
|
|
1086
1110
|
filesPlans.set(key, plan)
|
|
1087
1111
|
return plan
|
|
@@ -1144,17 +1168,18 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
|
|
|
1144
1168
|
const unmatchedLiterals = new Set(plan.literals)
|
|
1145
1169
|
// First pass: glob-filter to candidate absolute paths (no I/O).
|
|
1146
1170
|
const candidates: string[] = []
|
|
1171
|
+
const verdicts = plan.verdicts
|
|
1147
1172
|
for (const rel of gitFiles) {
|
|
1148
1173
|
if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
|
|
1149
|
-
let
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1174
|
+
let input = verdicts.get(rel)
|
|
1175
|
+
if (input === undefined) {
|
|
1176
|
+
input =
|
|
1177
|
+
positiveGlobs.some((g) => g.match(rel)) &&
|
|
1178
|
+
!excludeGlobs.some((g) => g.match(rel)) &&
|
|
1179
|
+
!ownOutput(rel)
|
|
1180
|
+
verdicts.set(rel, input)
|
|
1155
1181
|
}
|
|
1156
|
-
if (!
|
|
1157
|
-
if (nested(rel) || excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel)) continue
|
|
1182
|
+
if (!input || nested(rel)) continue
|
|
1158
1183
|
candidates.push(path.resolve(args.projectDir, rel))
|
|
1159
1184
|
}
|
|
1160
1185
|
if (unmatchedLiterals.size > 0) {
|
package/src/cache/key-fold.ts
CHANGED
|
@@ -99,7 +99,11 @@ import type { CacheKeyInput } from './layer.js'
|
|
|
99
99
|
// v37: an entry saved while `git status` hid a deletion (paired as an
|
|
100
100
|
// unmerged path's rename source) holds output built without a file its key
|
|
101
101
|
// folds (A-59).
|
|
102
|
-
|
|
102
|
+
// v38: an entry saved while the key folded a filtered LF blob for CRLF
|
|
103
|
+
// bytes on disk, the filter since removed, holds the CRLF build (A-60).
|
|
104
|
+
// v39: an entry saved while a gitlink without `.git` held files the key
|
|
105
|
+
// never listed sits under the key of that directory empty (A-61).
|
|
106
|
+
export const CACHE_VERSION = 'vx-cache-v39'
|
|
103
107
|
|
|
104
108
|
/**
|
|
105
109
|
* The digest a key folds for an input gone between its enumeration and its
|
package/src/cache/schema.ts
CHANGED
|
@@ -115,6 +115,15 @@ export function createTables(db: Database): void {
|
|
|
115
115
|
content_hash TEXT NOT NULL,
|
|
116
116
|
seen_at INTEGER NOT NULL
|
|
117
117
|
);
|
|
118
|
+
-- The size of each index blob the enumeration checked against the
|
|
119
|
+
-- worktree size git recorded (A-60). Fixed for its OID, so a warm
|
|
120
|
+
-- run asks git for none; swept with file_hashes by seen_at, the
|
|
121
|
+
-- time the row was written.
|
|
122
|
+
CREATE TABLE IF NOT EXISTS blob_sizes (
|
|
123
|
+
oid TEXT PRIMARY KEY,
|
|
124
|
+
size INTEGER NOT NULL,
|
|
125
|
+
seen_at INTEGER NOT NULL
|
|
126
|
+
);
|
|
118
127
|
-- v16: per-output-file fingerprints, scoped by the cache entry
|
|
119
128
|
-- that produced them. Lets loadOutputFilesBatch(hashes) answer
|
|
120
129
|
-- "for entry X, what are its outputs supposed to look like?"
|
package/src/cli/show.ts
CHANGED
|
@@ -339,6 +339,12 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
|
|
|
339
339
|
add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
|
|
340
340
|
}
|
|
341
341
|
const labelW = Math.max(...rows.map(([label]) => label.length))
|
|
342
|
-
|
|
342
|
+
// A multi-line value (a generated `vx_script` wrapper, a heredoc)
|
|
343
|
+
// continues under its first line, not at column 0 where it reads as
|
|
344
|
+
// the next task's header.
|
|
345
|
+
const indent = `\n${' '.repeat(labelW + 4)}`
|
|
346
|
+
const body = rows.map(
|
|
347
|
+
([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value.replaceAll('\n', indent)}`,
|
|
348
|
+
)
|
|
343
349
|
return `${taskName}\n${body.join('\n')}\n`
|
|
344
350
|
}
|
package/src/exec/index.ts
CHANGED
|
@@ -29,7 +29,13 @@ export {
|
|
|
29
29
|
thrownReason,
|
|
30
30
|
} from './sandbox-runtime.js'
|
|
31
31
|
export { type DeniedCall } from './sandbox-violations.js'
|
|
32
|
-
export {
|
|
32
|
+
export {
|
|
33
|
+
atOrUnder,
|
|
34
|
+
isMountableLiteral,
|
|
35
|
+
MOUNT_WILDCARDS,
|
|
36
|
+
sandboxReads,
|
|
37
|
+
toRealPath,
|
|
38
|
+
} from './sandbox-paths.js'
|
|
33
39
|
export { bindableWrites, punchWalls } from './sandbox-binds.js'
|
|
34
40
|
export { isLocalExecutor, localExecutor } from './local-executor.js'
|
|
35
41
|
export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
|
|
@@ -76,3 +76,25 @@ export function unique(arr: readonly string[]): string[] {
|
|
|
76
76
|
export function localBindingOn(c: { localBinding?: boolean | readonly number[] }): boolean {
|
|
77
77
|
return c.localBinding === true || (Array.isArray(c.localBinding) && c.localBinding.length > 0)
|
|
78
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Whether a sandboxed task may read `file`: it lies under a read grant,
|
|
82
|
+
* a write grant or a baseline read, by its canonical path. A path no
|
|
83
|
+
* grant holds is not there inside the sandbox, which the shell reports
|
|
84
|
+
* as "not found" with no denial a trace can see (an `execve`).
|
|
85
|
+
*/
|
|
86
|
+
export function sandboxReads(
|
|
87
|
+
sandbox: {
|
|
88
|
+
readonly baseAllowRead: readonly string[]
|
|
89
|
+
readonly config: {
|
|
90
|
+
readonly allowRead: readonly string[]
|
|
91
|
+
readonly allowWrite: readonly string[]
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
file: string,
|
|
95
|
+
): boolean {
|
|
96
|
+
const real = toRealPath(file)
|
|
97
|
+
return [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
|
|
98
|
+
(g) => atOrUnder(real, toRealPath(g)),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
@@ -1006,12 +1006,21 @@ export async function wrapSandboxedCommand(
|
|
|
1006
1006
|
process.platform === 'linux'
|
|
1007
1007
|
? ownGroupCommand(tag, inTmp, args.trace)
|
|
1008
1008
|
: { command: taggedCommand, forwards: false, traced: false }
|
|
1009
|
-
const inner =
|
|
1010
|
-
ports.length > 0 ?
|
|
1009
|
+
const inner = [
|
|
1010
|
+
ports.length > 0 ? portBridgeInner(ports, tag) : '',
|
|
1011
|
+
process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
|
|
1012
|
+
grouped.command,
|
|
1013
|
+
]
|
|
1014
|
+
.filter((part) => part !== '')
|
|
1015
|
+
.join(' ')
|
|
1011
1016
|
let wrapped = await wrapForTask(
|
|
1012
1017
|
SandboxManager,
|
|
1013
1018
|
inner,
|
|
1014
|
-
process.platform === 'linux'
|
|
1019
|
+
process.platform === 'linux'
|
|
1020
|
+
? literalReadPaths(customConfig)
|
|
1021
|
+
: process.platform === 'darwin'
|
|
1022
|
+
? seatbeltBrackets(customConfig)
|
|
1023
|
+
: customConfig,
|
|
1015
1024
|
ports.length > 0 || asksUnixSockets(args.config),
|
|
1016
1025
|
args.config.gitConfig === true,
|
|
1017
1026
|
)
|
|
@@ -1176,6 +1185,30 @@ function literalReadPaths(
|
|
|
1176
1185
|
}
|
|
1177
1186
|
}
|
|
1178
1187
|
|
|
1188
|
+
/**
|
|
1189
|
+
* macOS: a grant's escaped bracket as seatbelt's SRT can read it. vx hands
|
|
1190
|
+
* it the pattern, and SRT compiles any spelling holding `[` as a regex in
|
|
1191
|
+
* which a backslash is a literal one, so `pages/\[id\].tsx` matched no
|
|
1192
|
+
* file and the route could not be granted. `[[]` is a class of one `[`; a
|
|
1193
|
+
* lone `]` is plain text (B-65).
|
|
1194
|
+
*/
|
|
1195
|
+
function seatbeltBrackets(
|
|
1196
|
+
config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
|
|
1197
|
+
): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
|
|
1198
|
+
const fs = config?.filesystem
|
|
1199
|
+
if (fs === undefined) return config
|
|
1200
|
+
const literal = (paths: readonly string[]): string[] =>
|
|
1201
|
+
paths.map((p) => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']'))
|
|
1202
|
+
return {
|
|
1203
|
+
...config,
|
|
1204
|
+
filesystem: {
|
|
1205
|
+
...fs,
|
|
1206
|
+
allowWrite: literal(fs.allowWrite),
|
|
1207
|
+
...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
|
|
1208
|
+
},
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1179
1212
|
/** SRT's wrap, with the socket lift and the git-config grant this task asked for, or none (L-6, B-41). */
|
|
1180
1213
|
function wrapForTask(
|
|
1181
1214
|
SandboxManager: SrtModule['SandboxManager'],
|
|
@@ -1203,6 +1236,18 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
|
|
|
1203
1236
|
return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
|
|
1204
1237
|
}
|
|
1205
1238
|
|
|
1239
|
+
/**
|
|
1240
|
+
* Linux: SRT starts its in-sandbox proxy bridges (`socat TCP-LISTEN:3128`
|
|
1241
|
+
* and `:1080`) in the background and runs the command at once, so a
|
|
1242
|
+
* networked task that dialled the proxy first met "connection refused"
|
|
1243
|
+
* (curl's `000`) on a loaded box (M-20). This waits, in front of the
|
|
1244
|
+
* command, until both listen in the task's network namespace, read off
|
|
1245
|
+
* /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
|
|
1246
|
+
* listens leaves the command to meet the refusal it met before.
|
|
1247
|
+
*/
|
|
1248
|
+
const PROXY_BRIDGE_WAIT =
|
|
1249
|
+
"( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
|
|
1250
|
+
|
|
1206
1251
|
/** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
|
|
1207
1252
|
export function portBridgeSocket(tag: string, port: number): string {
|
|
1208
1253
|
return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
|
|
@@ -100,7 +100,14 @@ export interface DeniedCall {
|
|
|
100
100
|
// a child starts in its parent's cwd as it was at the fork.
|
|
101
101
|
const CHDIR_DONE_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*0`)
|
|
102
102
|
const CHDIR_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED} <unfinished`)
|
|
103
|
-
const CHDIR_RESUMED_RE =
|
|
103
|
+
const CHDIR_RESUMED_RE =
|
|
104
|
+
/^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)(?:\s+(ENOENT|EACCES|EPERM))?/
|
|
105
|
+
// A denied `chdir` is a denied read of the directory: `cd src` into a
|
|
106
|
+
// directory no grant holds failed with no line of the trace's own, and
|
|
107
|
+
// `cd src || …` passed and cached.
|
|
108
|
+
const CHDIR_DENIED_RE = new RegExp(
|
|
109
|
+
`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
|
|
110
|
+
)
|
|
104
111
|
const FCHDIR_RE = /^(\d+)\s+(?:fchdir\(\d+\)|<\.\.\. fchdir resumed>.*?)\s*=\s*0/
|
|
105
112
|
const FORK = '(?:clone3?|v?fork)'
|
|
106
113
|
const FORK_DONE_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*\\)\\s*=\\s*(\\d+)$`)
|
|
@@ -150,6 +157,17 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
150
157
|
out.push(call)
|
|
151
158
|
}
|
|
152
159
|
for (const line of text.split('\n')) {
|
|
160
|
+
// Most lines are opens that succeeded: two substring tests skip them.
|
|
161
|
+
const opener =
|
|
162
|
+
line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
|
|
163
|
+
if (!opener) {
|
|
164
|
+
if (
|
|
165
|
+
cwd !== undefined &&
|
|
166
|
+
(line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
|
|
167
|
+
)
|
|
168
|
+
follow(line)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
153
171
|
const done = STRACE_DONE_RE.exec(line)
|
|
154
172
|
if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
|
|
155
173
|
denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
|
|
@@ -176,15 +194,32 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
|
|
|
176
194
|
}
|
|
177
195
|
continue
|
|
178
196
|
}
|
|
179
|
-
|
|
197
|
+
// A refused or split `chdir` arrives here: its denial counts with or
|
|
198
|
+
// without the cwd tracking.
|
|
199
|
+
follow(line)
|
|
200
|
+
}
|
|
201
|
+
function follow(line: string): void {
|
|
180
202
|
let m: RegExpExecArray | null
|
|
181
|
-
if ((m =
|
|
182
|
-
|
|
183
|
-
|
|
203
|
+
if ((m = CHDIR_DENIED_RE.exec(line)) !== null) {
|
|
204
|
+
denied(m[1]!, { syscall: 'chdir', rawPath: cStringPath(m[2]!), errno: m[3]! })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) {
|
|
208
|
+
chdirring.set(m[1]!, cStringPath(m[2]!))
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
|
|
184
212
|
const to = chdirring.get(m[1]!)
|
|
185
213
|
chdirring.delete(m[1]!)
|
|
186
214
|
if (to !== undefined && m[2] === '0') opsOf(m[1]!).push({ chdir: to })
|
|
187
|
-
|
|
215
|
+
else if (to !== undefined && m[3] !== undefined) {
|
|
216
|
+
denied(m[1]!, { syscall: 'chdir', rawPath: to, errno: m[3] })
|
|
217
|
+
}
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
if (cwd === undefined) return
|
|
221
|
+
if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
|
|
222
|
+
else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
|
|
188
223
|
else if ((m = FORK_DONE_RE.exec(line)) !== null) {
|
|
189
224
|
forked(m[1]!, m[2]!, opsOf(m[1]!).length, line.includes('CLONE_FS'))
|
|
190
225
|
} else if ((m = FORK_UNFINISHED_RE.exec(line)) !== null) {
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -726,14 +726,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
726
726
|
return
|
|
727
727
|
}
|
|
728
728
|
const message = err instanceof Error ? err.message : String(err)
|
|
729
|
+
// After the run's stop a rejection is the stop's doing (a probe
|
|
730
|
+
// it killed, C-65): aborted, as any task it kills, and unsaid.
|
|
731
|
+
const stopped = aborted()
|
|
729
732
|
const outcome: TaskOutcome = withHold({
|
|
730
733
|
node,
|
|
731
|
-
status: 'failed',
|
|
734
|
+
status: stopped ? 'aborted' : 'failed',
|
|
732
735
|
exitCode: 1,
|
|
733
736
|
durationMs: 0,
|
|
734
737
|
})
|
|
735
738
|
leave()
|
|
736
739
|
untrack()
|
|
740
|
+
if (stopped) {
|
|
741
|
+
finishOne(id, outcome)
|
|
742
|
+
tick()
|
|
743
|
+
return
|
|
744
|
+
}
|
|
737
745
|
// A UserError is a config/input failure (e.g. a failed
|
|
738
746
|
// `cache.inputs.runtime` command), not a vx bug — report it
|
|
739
747
|
// plainly, never as an "internal error".
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type TaskExecutor,
|
|
34
34
|
type TaskInputs,
|
|
35
35
|
PersistentReadyError,
|
|
36
|
+
sandboxReads,
|
|
36
37
|
} from '../exec/index.js'
|
|
37
38
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
38
39
|
import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
|
|
@@ -875,6 +876,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
875
876
|
cwd: node.projectDir,
|
|
876
877
|
bins: taskBinDirs(node, args.workspaceRoot),
|
|
877
878
|
signal: res.signal,
|
|
879
|
+
hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
|
|
878
880
|
})
|
|
879
881
|
if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
|
|
880
882
|
// A committed file another task's clean removed, still gone: vx
|
|
@@ -230,7 +230,8 @@ export interface RunOptions {
|
|
|
230
230
|
* The raw command string for the `invocations` row (e.g.
|
|
231
231
|
* `'vx run build test --all'`). When absent, run() falls back to
|
|
232
232
|
* `process.argv.slice(1).join(' ')` so programmatic callers still
|
|
233
|
-
* record something useful.
|
|
233
|
+
* record something useful. Telemetry sinks get it with what follows
|
|
234
|
+
* `--` counted, not quoted, as they get the argv (C-67).
|
|
234
235
|
*/
|
|
235
236
|
command?: string
|
|
236
237
|
/**
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
type LoadReads,
|
|
33
33
|
loadWorkspace,
|
|
34
34
|
FROZEN_WITHOUT_LOCK,
|
|
35
|
+
type Lockfile,
|
|
35
36
|
readLockfile,
|
|
36
37
|
resolveCacheDir,
|
|
37
38
|
type ProjectEntry,
|
|
@@ -282,10 +283,24 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
282
283
|
// so consuming the lock by default would silently serve stale
|
|
283
284
|
// freezes. `vx lock --check` is the full re-evaluation audit.
|
|
284
285
|
// See docs/design/config-lock-2026-06.md.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
// Read once, and only when a config is to be read from it: the CLI's
|
|
287
|
+
// selection pass may have staged every config (`options.staged`), and
|
|
288
|
+
// its load refused a frozen run without a lock. A 1,000-project lock is
|
|
289
|
+
// 1.1 MB of JSON (I-27).
|
|
290
|
+
let lockRead: Promise<Lockfile> | undefined
|
|
291
|
+
const readLock = (): Promise<Lockfile> =>
|
|
292
|
+
(lockRead ??= readLockfile(workspaceRoot).then((read) => {
|
|
293
|
+
if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
294
|
+
return read
|
|
295
|
+
}))
|
|
296
|
+
const staged = options.staged
|
|
297
|
+
const allStaged =
|
|
298
|
+
staged !== undefined &&
|
|
299
|
+
projectMetas.every(
|
|
300
|
+
(m) => typeof m.configPath !== 'string' || m.configPath === '' || staged.has(m.name),
|
|
301
|
+
)
|
|
302
|
+
if (options.frozen === true && !allStaged) await readLock()
|
|
303
|
+
const lock = options.frozen === true ? readLock : null
|
|
289
304
|
|
|
290
305
|
const loadArgs = {
|
|
291
306
|
workspaceRoot,
|
|
@@ -441,7 +456,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
441
456
|
workspaceRoot,
|
|
442
457
|
gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
|
|
443
458
|
))
|
|
444
|
-
applyGitEnumeration(
|
|
459
|
+
await applyGitEnumeration(
|
|
460
|
+
enumeration,
|
|
461
|
+
workspaceRoot,
|
|
462
|
+
projectDirs,
|
|
463
|
+
gitFilesCache,
|
|
464
|
+
usesWorkspaceInputs,
|
|
465
|
+
localCache,
|
|
466
|
+
)
|
|
445
467
|
mark('git enumeration')
|
|
446
468
|
const hashCache = createHashCache()
|
|
447
469
|
const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
|
|
@@ -116,8 +116,12 @@ interface LoadProjectsBase {
|
|
|
116
116
|
* way, since the package graph cannot see the cross form.
|
|
117
117
|
*/
|
|
118
118
|
seeds: 'all' | Iterable<string>
|
|
119
|
-
/**
|
|
120
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Read configs from the lock instead of evaluating them (`--frozen`),
|
|
121
|
+
* asked only when a config is to be read: a run whose every config the
|
|
122
|
+
* CLI's selection pass staged never parses it again.
|
|
123
|
+
*/
|
|
124
|
+
lock: (() => Promise<Lockfile>) | null
|
|
121
125
|
evalCache: LoadProjectConfigOptions['evalCache']
|
|
122
126
|
warn: (message: string) => void
|
|
123
127
|
/**
|
|
@@ -223,7 +227,9 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
223
227
|
typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
|
|
224
228
|
)
|
|
225
229
|
const loaded = lock
|
|
226
|
-
?
|
|
230
|
+
? withFile.length === 0
|
|
231
|
+
? []
|
|
232
|
+
: await frozenProjectConfigs(await lock(), withFile, workspaceRoot, args.evalCache?.store)
|
|
227
233
|
: await loadProjectConfigs(
|
|
228
234
|
withFile.map((m) => m.configPath),
|
|
229
235
|
args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -5,7 +5,12 @@
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
6
|
import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
|
|
7
7
|
import path from 'node:path'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type CacheLayer,
|
|
10
|
+
type CachePolicy,
|
|
11
|
+
FULL_CACHE_POLICY,
|
|
12
|
+
stopRuntimeProbes,
|
|
13
|
+
} from '../cache/index.js'
|
|
9
14
|
import { VERSION } from '../version.js'
|
|
10
15
|
import {
|
|
11
16
|
resetSandbox,
|
|
@@ -533,6 +538,7 @@ async function runOnBus(
|
|
|
533
538
|
// ends after it is the stop's own kill, and was named a crash (item 1061).
|
|
534
539
|
let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
|
|
535
540
|
const onAbort = (): void => {
|
|
541
|
+
stopRuntimeProbes()
|
|
536
542
|
endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
|
|
537
543
|
aborting = terminateChildren(
|
|
538
544
|
() => [...liveChildren, ...persistentRegistry.values()],
|
|
@@ -634,7 +640,9 @@ async function runOnBus(
|
|
|
634
640
|
runContextRecord = {
|
|
635
641
|
runId,
|
|
636
642
|
vxVersion: VERSION,
|
|
637
|
-
command:
|
|
643
|
+
// An embedder's `command` is redacted as the argv is: it passed a
|
|
644
|
+
// token after `--` to every sink verbatim (C-67).
|
|
645
|
+
command: invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
|
|
638
646
|
requestedTasks: [...options.tasks],
|
|
639
647
|
cachePolicy: compactCachePolicy(policy),
|
|
640
648
|
concurrency,
|
|
@@ -187,13 +187,14 @@ export async function sandboxRequestFor(
|
|
|
187
187
|
*/
|
|
188
188
|
cacheDir?: string,
|
|
189
189
|
): Promise<SandboxRequest> {
|
|
190
|
-
//
|
|
191
|
-
//
|
|
190
|
+
// The runtime reads a path holding a bracket as a pattern: on Linux it
|
|
191
|
+
// mounts no such write path (B-60); seatbelt's rules compile it as a
|
|
192
|
+
// character class, so vx's own workspace wall matched nothing (B-65).
|
|
192
193
|
const home = toRealPath(node.projectDir)
|
|
193
|
-
if (process.platform
|
|
194
|
+
if (process.platform !== 'win32' && /[[\]]/.test(home)) {
|
|
194
195
|
throw new UserError(
|
|
195
|
-
`exec.sandbox: ${home} holds a bracket ([ or ]),
|
|
196
|
-
`
|
|
196
|
+
`exec.sandbox: ${home} holds a bracket ([ or ]), which the sandbox runtime reads as a ` +
|
|
197
|
+
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
|
197
198
|
)
|
|
198
199
|
}
|
|
199
200
|
const depDirs = [
|
|
@@ -23,13 +23,22 @@ export function shellVerdict(args: {
|
|
|
23
23
|
bins: string[]
|
|
24
24
|
/** The signal that killed the child when the runner saw one (`RunResult.signal`). */
|
|
25
25
|
signal?: string | undefined
|
|
26
|
+
/** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
|
|
27
|
+
hidden?: ((file: string) => boolean) | undefined
|
|
26
28
|
}): string | undefined {
|
|
27
29
|
if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
|
|
28
30
|
const word = execWord(args.command)
|
|
29
31
|
const what = word ?? 'a command in this task'
|
|
30
32
|
if (word !== undefined && word.includes('/')) {
|
|
31
33
|
const file = path.resolve(args.cwd, word)
|
|
32
|
-
|
|
34
|
+
// The file the host has may be one the sandbox does not: then the
|
|
35
|
+
// shell's "not found" is the sandbox's, and the file's own state (its
|
|
36
|
+
// `#!` line) is not the reason.
|
|
37
|
+
const verdict =
|
|
38
|
+
existsSync(file) && args.hidden?.(file) === true
|
|
39
|
+
? `exists, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
|
|
40
|
+
: fileVerdict(file)
|
|
41
|
+
return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${verdict}`
|
|
33
42
|
}
|
|
34
43
|
if (args.code === 126) {
|
|
35
44
|
return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
|
|
@@ -765,6 +765,24 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
765
765
|
implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
|
|
766
766
|
name: 'the package.json `name` (a project is named by its package)',
|
|
767
767
|
tags: '`--filter` (a name glob or a directory) to select projects',
|
|
768
|
+
// A package.json's, turbo.json's and project.json's keys, and a
|
|
769
|
+
// task's fields one level too high (D-99).
|
|
770
|
+
scripts:
|
|
771
|
+
"`tasks` (a script is a task's `exec.command`; `vx init` writes them from package.json)",
|
|
772
|
+
pipeline: '`tasks` (`bunx @vzn/vx-migrate` writes them from turbo.json)',
|
|
773
|
+
namedInputs: 'a module the vx.config imports, spread into `cache.inputs.files`',
|
|
774
|
+
root: "no field: a project's directory is where its package.json sits",
|
|
775
|
+
sourceRoot: "a task's `cache.inputs.files`",
|
|
776
|
+
projectType: 'no field: vx runs every project alike',
|
|
777
|
+
...Object.fromEntries(
|
|
778
|
+
['dependsOn', 'cache', 'exec', 'description'].map((k) => [
|
|
779
|
+
k,
|
|
780
|
+
`\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
|
|
781
|
+
]),
|
|
782
|
+
),
|
|
783
|
+
inputs: '`tasks.<name>.cache.inputs.files`',
|
|
784
|
+
outputs: '`tasks.<name>.cache.outputs.files`',
|
|
785
|
+
env: '`tasks.<name>.cache.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
|
|
768
786
|
},
|
|
769
787
|
],
|
|
770
788
|
// Turbo's turbo.json and Nx's nx.json keys, written into vx.workspace
|
|
@@ -55,12 +55,14 @@ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepubli
|
|
|
55
55
|
*/
|
|
56
56
|
export function delegatedScript(command: string): string | null {
|
|
57
57
|
const m =
|
|
58
|
-
/^(?:(?:npm run|(pnpm|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
|
|
58
|
+
/^(?:(?:npm run|(pnpm|pn|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
|
|
59
59
|
command.trim(),
|
|
60
60
|
)
|
|
61
61
|
if (m === null) return null
|
|
62
62
|
if (m[4] !== undefined) return m[4]
|
|
63
|
-
const [,
|
|
63
|
+
const [, alias, run, name] = m
|
|
64
|
+
// `pn` is pnpm's own short name (pnpm 11; pnpm/pnpm's scripts run it).
|
|
65
|
+
const manager = alias === 'pn' ? 'pnpm' : alias
|
|
64
66
|
// Bare, the manager's own command wins over a script of that name:
|
|
65
67
|
// `bun test` is Bun's test runner and `bun build` its bundler, never the
|
|
66
68
|
// `test` / `build` script, and a group over the script ran the wrong
|
|
@@ -305,7 +307,7 @@ const MEMBER_FLAG =
|
|
|
305
307
|
function pmRunsMembers(script: string): boolean {
|
|
306
308
|
for (const segment of script.split(/&&|\|\||[;|()]/)) {
|
|
307
309
|
const words = segment.trim().split(/\s+/)
|
|
308
|
-
let i = words.findIndex((w) => /^(pnpm|npm|yarn|bun)$/.test(w))
|
|
310
|
+
let i = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
|
|
309
311
|
// npm/cli runs itself: `node . run test --workspaces`.
|
|
310
312
|
if (i < 0) i = words.findIndex((w, j) => w === 'run' && words[j - 2] === 'node')
|
|
311
313
|
if (i < 0) continue
|
|
@@ -343,7 +345,7 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
|
|
|
343
345
|
|
|
344
346
|
/** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
|
|
345
347
|
const RUNS_SCRIPT =
|
|
346
|
-
/(?:^|[\s;&|(])(?:pnpm|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
|
|
348
|
+
/(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
|
|
347
349
|
|
|
348
350
|
/**
|
|
349
351
|
* The scripts a command runs by name: a package manager's (`pnpm x`, `npm
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync } from 'node:fs'
|
|
9
9
|
import { unlink } from 'node:fs/promises'
|
|
10
10
|
import path from 'node:path'
|
|
11
|
-
import { relPosix, UserError } from '../util/index.js'
|
|
11
|
+
import { executablePath, relPosix, UserError } from '../util/index.js'
|
|
12
12
|
import type { ProjectMeta } from './workspace.js'
|
|
13
13
|
import { loadWorkspace, unreachedHint, unreachedPackages } from './workspace.js'
|
|
14
14
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
@@ -412,14 +412,36 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
412
412
|
report.push('', 'no task caches yet: add the cache block a TODO shows, and a second run hits')
|
|
413
413
|
}
|
|
414
414
|
const installed = existsSync(path.join(root, 'node_modules', '@vzn', 'vx', 'package.json'))
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
)
|
|
415
|
+
// The line is the next thing to type, so it names what that run would
|
|
416
|
+
// refuse without: a git work tree (vx keys inputs on git's view) and,
|
|
417
|
+
// with no scripts mapped, a task to run.
|
|
418
|
+
const run = `${vxInvocation(process.env['npm_config_user_agent'], installed)} run ${firstTask} --all`
|
|
419
|
+
const steps = [
|
|
420
|
+
...(inGitWorkTree(root) ? [] : ['git init']),
|
|
421
|
+
...(empty ? [`declare a task as the example shows`] : []),
|
|
422
|
+
]
|
|
423
|
+
report.push('', `next: ${steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`}`)
|
|
419
424
|
process.stdout.write(`${report.join('\n')}\n`)
|
|
420
425
|
return 0
|
|
421
426
|
}
|
|
422
427
|
|
|
428
|
+
function inGitWorkTree(root: string): boolean {
|
|
429
|
+
try {
|
|
430
|
+
return (
|
|
431
|
+
Bun.spawnSync({
|
|
432
|
+
cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
|
|
433
|
+
cwd: root,
|
|
434
|
+
stdout: 'pipe',
|
|
435
|
+
stderr: 'ignore',
|
|
436
|
+
})
|
|
437
|
+
.stdout.toString()
|
|
438
|
+
.trim() === 'true'
|
|
439
|
+
)
|
|
440
|
+
} catch {
|
|
441
|
+
return false
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
423
445
|
/**
|
|
424
446
|
* How the user runs vx, for the report's `next:` line. After `npx vx init`
|
|
425
447
|
* or `bunx @vzn/vx init` a bare `vx` is on no PATH (the first-five-minutes
|