@vzn/vx 0.0.49 → 0.0.51
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 +38 -31
- package/package.json +5 -5
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +18 -6
- package/src/cache/cache.ts +150 -67
- package/src/cache/chained-cache.ts +9 -2
- package/src/cache/file-hashes.ts +32 -3
- package/src/cache/git-inputs.ts +96 -11
- package/src/cache/index.ts +1 -0
- package/src/cache/layer.ts +28 -3
- package/src/cache/layered-cache.ts +7 -2
- package/src/cache/output-index.ts +16 -7
- package/src/cache/run-history.ts +5 -1
- package/src/cache/zstd.ts +40 -8
- package/src/cli/cache.ts +35 -9
- package/src/cli/completions.ts +10 -7
- package/src/cli/help.ts +48 -4
- package/src/cli/index.ts +5 -3
- package/src/cli/info.ts +1 -1
- package/src/cli/plugin-commands.ts +10 -2
- package/src/cli/run.ts +1 -0
- package/src/cli/select.ts +29 -2
- package/src/cli/upgrade.ts +110 -22
- package/src/cli/watch.ts +35 -18
- package/src/config.ts +18 -5
- package/src/exec/executor.ts +3 -2
- package/src/exec/index.ts +1 -1
- package/src/exec/runner.ts +102 -24
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +149 -40
- package/src/exec/sandbox-violations.ts +42 -10
- package/src/graph/scheduler.ts +11 -5
- package/src/graph/task-graph.ts +96 -12
- package/src/orchestrator/admission.ts +72 -17
- package/src/orchestrator/execute-task.ts +39 -25
- package/src/orchestrator/hit-restore.ts +7 -1
- package/src/orchestrator/miss-save.ts +29 -1
- package/src/orchestrator/placement.ts +23 -3
- package/src/orchestrator/plugin-host.ts +1 -1
- package/src/orchestrator/plugin.ts +9 -1
- package/src/orchestrator/prepare.ts +1 -0
- package/src/orchestrator/run.ts +75 -12
- package/src/orchestrator/sandbox-request.ts +19 -10
- package/src/orchestrator/summary.ts +26 -15
- package/src/orchestrator/task-hash.ts +7 -2
- package/src/orchestrator/telemetry.ts +14 -4
- package/src/util/paths.ts +16 -9
- package/src/workspace/affected.ts +201 -38
- package/src/workspace/config-eval.ts +4 -1
- package/src/workspace/config-schema.ts +70 -10
- package/src/workspace/fingerprint.ts +6 -0
- package/src/workspace/migrate-scripts.ts +11 -5
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/project-loader.ts +53 -1
- package/src/workspace/workspace.ts +54 -14
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, realpathSync } from 'node:fs'
|
|
9
|
+
import { existsSync, lstatSync, 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`. */
|
|
@@ -257,6 +257,8 @@ interface GitLsResult {
|
|
|
257
257
|
oids: Map<string, string>
|
|
258
258
|
/** Paths flagged skip-worktree / assume-unchanged (only when `-v` was passed). */
|
|
259
259
|
flagged: Set<string>
|
|
260
|
+
/** Gitlinks (mode 160000): a submodule or an embedded repository, one entry for its whole tree. */
|
|
261
|
+
gitlinks: Set<string>
|
|
260
262
|
/** cwd-relative paths whose names are not UTF-8, spelled lossily (`decodeGitZ`). */
|
|
261
263
|
undecodable: string[]
|
|
262
264
|
}
|
|
@@ -334,7 +336,46 @@ export function runGitLsFiles(cwd: string): GitLsResult {
|
|
|
334
336
|
)
|
|
335
337
|
}
|
|
336
338
|
const { text, undecodable } = decodeGitZ(proc.stdout)
|
|
337
|
-
|
|
339
|
+
const parsed = parseLsFilesOutput(text, undecodable)
|
|
340
|
+
const nested = expandNestedRepos(cwd, parsed.files, parsed.gitlinks)
|
|
341
|
+
parsed.files = nested.files
|
|
342
|
+
parsed.undecodable.push(...nested.undecodable)
|
|
343
|
+
return parsed
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Replace each nested repository in a listing — a gitlink (a submodule) or
|
|
348
|
+
* an untracked `dir/` (an embedded repository) — with the files its OWN git
|
|
349
|
+
* lists, prefixed by its path. The outer repository holds one entry for the
|
|
350
|
+
* whole tree and none of its files, and a directory is no input, so a task
|
|
351
|
+
* reading `vendor/lib/x.txt` under `**` folded nothing of it: an edit there
|
|
352
|
+
* was a green hit on the old output while `git status` named the path. The
|
|
353
|
+
* files carry no index OID from here, so they hash by content. A nested
|
|
354
|
+
* repository with no `.git` (a submodule never initialised) has no files to
|
|
355
|
+
* read and stays out. A listing with none is returned as it came.
|
|
356
|
+
*/
|
|
357
|
+
function expandNestedRepos(
|
|
358
|
+
cwd: string,
|
|
359
|
+
files: string[],
|
|
360
|
+
gitlinks: ReadonlySet<string>,
|
|
361
|
+
): { files: string[]; undecodable: string[] } {
|
|
362
|
+
let out: string[] | undefined
|
|
363
|
+
const undecodable: string[] = []
|
|
364
|
+
for (let i = 0; i < files.length; i++) {
|
|
365
|
+
const rel = files[i]!
|
|
366
|
+
const nested = rel.endsWith('/') ? rel.slice(0, -1) : gitlinks.has(rel) ? rel : undefined
|
|
367
|
+
if (nested === undefined) {
|
|
368
|
+
out?.push(rel)
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
out ??= files.slice(0, i)
|
|
372
|
+
const abs = path.join(cwd, nested)
|
|
373
|
+
if (!existsSync(path.join(abs, '.git'))) continue
|
|
374
|
+
const inner = runGitLsFiles(abs)
|
|
375
|
+
for (const f of inner.files) out.push(`${nested}/${f}`)
|
|
376
|
+
for (const f of inner.undecodable) undecodable.push(`${nested}/${f}`)
|
|
377
|
+
}
|
|
378
|
+
return { files: out ?? files, undecodable }
|
|
338
379
|
}
|
|
339
380
|
|
|
340
381
|
/**
|
|
@@ -359,8 +400,9 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
|
|
|
359
400
|
const files: string[] = []
|
|
360
401
|
const oids = new Map<string, string>()
|
|
361
402
|
const flagged = new Set<string>()
|
|
403
|
+
const gitlinks = new Set<string>()
|
|
362
404
|
const undecodable: string[] = []
|
|
363
|
-
if (out.length === 0) return { files, oids, flagged, undecodable }
|
|
405
|
+
if (out.length === 0) return { files, oids, flagged, gitlinks, undecodable }
|
|
364
406
|
// NUL-separated; trailing NUL produces an empty segment we skip.
|
|
365
407
|
for (const record of out.split('\0')) {
|
|
366
408
|
if (record.length === 0) continue
|
|
@@ -373,6 +415,8 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
|
|
|
373
415
|
const stage = m[4]!
|
|
374
416
|
if ((mode === '100644' || mode === '100755' || mode === '120000') && stage === '0') {
|
|
375
417
|
oids.set(filePath, fileIdentity(mode, m[3]!))
|
|
418
|
+
} else if (mode === '160000') {
|
|
419
|
+
gitlinks.add(filePath)
|
|
376
420
|
}
|
|
377
421
|
// A LOWERCASE letter means skip-worktree or assume-unchanged (`S` is the
|
|
378
422
|
// older spelling of skip-worktree): git has been told to stop looking at
|
|
@@ -380,7 +424,7 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
|
|
|
380
424
|
const flag = m[1]
|
|
381
425
|
if (flag !== undefined && (flag === 'S' || (flag >= 'a' && flag <= 'z'))) flagged.add(filePath)
|
|
382
426
|
}
|
|
383
|
-
return { files, oids, flagged, undecodable }
|
|
427
|
+
return { files, oids, flagged, gitlinks, undecodable }
|
|
384
428
|
}
|
|
385
429
|
|
|
386
430
|
/** One completed `git` invocation. */
|
|
@@ -557,6 +601,40 @@ function gitVarList(listing: string): Map<string, string> {
|
|
|
557
601
|
return vars
|
|
558
602
|
}
|
|
559
603
|
|
|
604
|
+
/**
|
|
605
|
+
* `true` when git ignores the executable bit (`core.fileMode=false`, the
|
|
606
|
+
* default on WSL's DrvFs and what `git init` writes on a filesystem without
|
|
607
|
+
* one). Then `git status` reports no `chmod`, and a trusted file's index mode
|
|
608
|
+
* says nothing about the worktree.
|
|
609
|
+
*/
|
|
610
|
+
function fileModeIgnored(gitVars: string): boolean {
|
|
611
|
+
const v = gitVarList(gitVars).get('core.filemode')?.trim().toLowerCase()
|
|
612
|
+
return v === 'false' || v === 'no' || v === 'off' || v === '0'
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Under `core.fileMode=false`, each trusted identity's mode from an lstat of
|
|
617
|
+
* the worktree file, as `hashFile` takes it; the OID stays the index's. The
|
|
618
|
+
* index mode alone kept the key through a `chmod +x`, and the task replayed
|
|
619
|
+
* the output a 644 input built (item 1076). A path that does not stat goes
|
|
620
|
+
* back to the probe.
|
|
621
|
+
*/
|
|
622
|
+
function restampModes(trusted: Map<string, string>, workspaceRoot: string): void {
|
|
623
|
+
for (const [rel, id] of trusted) {
|
|
624
|
+
const colon = id.indexOf(':')
|
|
625
|
+
const oid = colon === -1 ? id : id.slice(colon + 1)
|
|
626
|
+
let st
|
|
627
|
+
try {
|
|
628
|
+
st = lstatSync(path.join(workspaceRoot, rel))
|
|
629
|
+
} catch {
|
|
630
|
+
trusted.delete(rel)
|
|
631
|
+
continue
|
|
632
|
+
}
|
|
633
|
+
const mode = st.isSymbolicLink() ? '120000' : (st.mode & 0o100) !== 0 ? '100755' : '100644'
|
|
634
|
+
trusted.set(rel, fileIdentity(mode, oid))
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
560
638
|
/** `true` when git may rewrite bytes for EVERY auto-detected text file. */
|
|
561
639
|
export function autocrlfConverts(gitVars: string): boolean {
|
|
562
640
|
const v = gitVarList(gitVars).get('core.autocrlf')?.trim().toLowerCase()
|
|
@@ -825,6 +903,7 @@ export async function startGitEnumeration(
|
|
|
825
903
|
files: tracked,
|
|
826
904
|
oids,
|
|
827
905
|
flagged,
|
|
906
|
+
gitlinks,
|
|
828
907
|
undecodable,
|
|
829
908
|
} = parseLsFilesOutput(ls.stdout, ls.undecodable)
|
|
830
909
|
// Normalize `status`'s repo-root-relative paths to workspace-relative (strip
|
|
@@ -846,7 +925,10 @@ export async function startGitEnumeration(
|
|
|
846
925
|
// set. Without a status answer the enumeration is the index alone.
|
|
847
926
|
const untracked =
|
|
848
927
|
parsedStatus === null ? [] : [...stripPrefixFromSet(new Set(parsedStatus.untracked), gitPrefix)]
|
|
849
|
-
const
|
|
928
|
+
const listed = untracked.length === 0 ? tracked : tracked.concat(untracked)
|
|
929
|
+
const nested = expandNestedRepos(workspaceRoot, listed, gitlinks)
|
|
930
|
+
const all = nested.files
|
|
931
|
+
undecodable.push(...nested.undecodable)
|
|
850
932
|
// Aggregate dirtiness for the Tier-3 invocation record — derived from
|
|
851
933
|
// this same status spawn so `run()` needs no second `git status`.
|
|
852
934
|
// null when the status spawn failed (non-repo / git error).
|
|
@@ -883,6 +965,9 @@ export async function startGitEnumeration(
|
|
|
883
965
|
gitVars: vars !== null && vars.exitCode === 0 ? vars.stdout : '',
|
|
884
966
|
spawnGit,
|
|
885
967
|
})
|
|
968
|
+
if (vars !== null && vars.exitCode === 0 && fileModeIgnored(vars.stdout)) {
|
|
969
|
+
restampModes(trusted, workspaceRoot)
|
|
970
|
+
}
|
|
886
971
|
if (parsedStatus !== null && parsedStatus.undecodable.size > 0) {
|
|
887
972
|
undecodable.push(...stripPrefixFromSet(parsedStatus.undecodable, gitPrefix))
|
|
888
973
|
}
|
|
@@ -939,12 +1024,12 @@ export function applyGitEnumeration(
|
|
|
939
1024
|
if (oid !== undefined) projOids.set(path.join(workspaceRoot, rel), oid)
|
|
940
1025
|
}
|
|
941
1026
|
// An empty slice is a directory git did not see, not an empty project:
|
|
942
|
-
// a project has at least its package.json, tracked or untracked.
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
// naming
|
|
947
|
-
// outright. Storing the empty slice made the key never move: `cache.inputs
|
|
1027
|
+
// a project has at least its package.json, tracked or untracked. A
|
|
1028
|
+
// nested repository — a submodule, an embedded repository, which the
|
|
1029
|
+
// workspace's git holds as ONE entry (a gitlink, `dir/` when untracked)
|
|
1030
|
+
// — is listed through its own git above (`expandNestedRepos`), but under
|
|
1031
|
+
// a pathspec naming a project inside it the workspace's git lists
|
|
1032
|
+
// nothing at all; so is a directory ignored outright. Storing the empty slice made the key never move: `cache.inputs
|
|
948
1033
|
// matched no files`, then a stale hit under a green run once the source
|
|
949
1034
|
// changed (2026-09-16). No partition instead: `resolveFiles` spawns
|
|
950
1035
|
// `git ls-files` in the project's own directory, which a nested
|
package/src/cache/index.ts
CHANGED
package/src/cache/layer.ts
CHANGED
|
@@ -377,6 +377,23 @@ export const OUTPUT_DIRS_RACY_MS = 50
|
|
|
377
377
|
/** The stat memo's twin of `OUTPUT_DIRS_RACY_MS`: a file changed within this window of the stat is not memoised. */
|
|
378
378
|
export const FILE_HASH_RACY_MS = 50
|
|
379
379
|
|
|
380
|
+
/**
|
|
381
|
+
* The racy window for one timestamp. `windowMs` assumes a clock finer than
|
|
382
|
+
* itself; a file system that keeps whole seconds (ext3, HFS+, FAT/exFAT,
|
|
383
|
+
* some NFS mounts) stamps a write at 12:00:00.900 as 12:00:00, so a write
|
|
384
|
+
* later in that second keeps the stamp, and a stat 120 ms after the first
|
|
385
|
+
* write passed a 50 ms window: the memo served the first bytes' digest for
|
|
386
|
+
* a same-size rewrite 120 ms on (A-2, reproduced on an ext2 mount). A stamp
|
|
387
|
+
* with no sub-second part is read as such a second and widens the window by
|
|
388
|
+
* it, as git judges racy entries at its index's own granularity. Pass the
|
|
389
|
+
* UNFLOORED stamp: a kernel's nanosecond stamp lands on a whole second about
|
|
390
|
+
* once in 10^9, one set through a millisecond `utimes` once in a thousand,
|
|
391
|
+
* and either is then trusted a second later — never wrongly.
|
|
392
|
+
*/
|
|
393
|
+
export function racyWindowMs(stampMs: number, windowMs: number): number {
|
|
394
|
+
return stampMs % 1000 === 0 ? windowMs + 1000 : windowMs
|
|
395
|
+
}
|
|
396
|
+
|
|
380
397
|
/**
|
|
381
398
|
* Context passed to `get()`. Optional, but required when the lookup
|
|
382
399
|
* may resolve through the remote layer — the local SQL row inserted on
|
|
@@ -568,9 +585,17 @@ export interface CacheLayer {
|
|
|
568
585
|
* `loadOutputDirsBatch` reads them back; `outputDirsCurrent` is true iff
|
|
569
586
|
* every recorded directory still carries its recorded mtime, which proves
|
|
570
587
|
* no file was added or removed anywhere the glob could see. Machine-local
|
|
571
|
-
* state, like the output rows: a remote ingest records none.
|
|
572
|
-
|
|
573
|
-
|
|
588
|
+
* state, like the output rows: a remote ingest records none. `holds`,
|
|
589
|
+
* when given, is asked with every non-directory path the walk saw
|
|
590
|
+
* (project-relative); false records nothing, since a snapshot of a tree
|
|
591
|
+
* the entry does not hold would vouch for it on every later hit.
|
|
592
|
+
*/
|
|
593
|
+
recordOutputDirs?(
|
|
594
|
+
hash: string,
|
|
595
|
+
projectDir: string,
|
|
596
|
+
prefixes: readonly string[],
|
|
597
|
+
holds?: (files: readonly string[]) => boolean,
|
|
598
|
+
): Promise<void>
|
|
574
599
|
/**
|
|
575
600
|
* Stamp `hash`'s output rows with each file's inode and ctime once a
|
|
576
601
|
* save or restore has left the tree equal to the entry (optional — a
|
|
@@ -531,8 +531,13 @@ export class LayeredCache implements CacheLayer {
|
|
|
531
531
|
return this.local.isOutputsCurrent(projectDir, expected)
|
|
532
532
|
}
|
|
533
533
|
|
|
534
|
-
recordOutputDirs(
|
|
535
|
-
|
|
534
|
+
recordOutputDirs(
|
|
535
|
+
hash: string,
|
|
536
|
+
projectDir: string,
|
|
537
|
+
prefixes: readonly string[],
|
|
538
|
+
holds?: (files: readonly string[]) => boolean,
|
|
539
|
+
): Promise<void> {
|
|
540
|
+
return this.local.recordOutputDirs(hash, projectDir, prefixes, holds)
|
|
536
541
|
}
|
|
537
542
|
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
538
543
|
this.local.recordOutputStamps(hash, projectDir, workspaceRoot)
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
OUTPUT_DIRS_CAP,
|
|
17
17
|
OUTPUT_DIRS_RACY_MS,
|
|
18
18
|
type OutputDirRow,
|
|
19
|
+
racyWindowMs,
|
|
19
20
|
type OutputFileRow,
|
|
20
21
|
WORKSPACE_OUTPUT_PREFIX,
|
|
21
22
|
} from './layer.js'
|
|
@@ -164,18 +165,23 @@ export class OutputIndex {
|
|
|
164
165
|
}
|
|
165
166
|
|
|
166
167
|
/**
|
|
167
|
-
* Snapshot every directory under each of `prefixes` for `hash
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
168
|
+
* Snapshot every directory under each of `prefixes` for `hash`, for a
|
|
169
|
+
* tree that equals the entry's set. The run takes it at run end, when
|
|
170
|
+
* the directories are past the racy window, and by then another task or
|
|
171
|
+
* process may have written into them: `holds` is asked with the files
|
|
172
|
+
* the walk saw, and a tree it refuses records nothing (item 1087).
|
|
173
|
+
* Symlinked directories are not descended (the output walk refuses them
|
|
174
|
+
* too). Over `OUTPUT_DIRS_CAP` directories, or on any error, the rows
|
|
175
|
+
* are cleared and the next hit keeps the walk.
|
|
172
176
|
*/
|
|
173
177
|
async recordOutputDirs(
|
|
174
178
|
hash: string,
|
|
175
179
|
projectDir: string,
|
|
176
180
|
prefixes: readonly string[],
|
|
181
|
+
holds?: (files: readonly string[]) => boolean,
|
|
177
182
|
): Promise<void> {
|
|
178
183
|
const rows: Array<[string, number]> = []
|
|
184
|
+
const files: string[] = []
|
|
179
185
|
const walk = async (rel: string, isPrefix = false): Promise<boolean> => {
|
|
180
186
|
const abs = path.join(projectDir, rel)
|
|
181
187
|
let st
|
|
@@ -205,6 +211,8 @@ export class OutputIndex {
|
|
|
205
211
|
for (const e of entries) {
|
|
206
212
|
if (e.isDirectory() && !e.isSymbolicLink()) {
|
|
207
213
|
if (!(await walk(`${rel}/${e.name}`))) return false
|
|
214
|
+
} else {
|
|
215
|
+
files.push(`${rel}/${e.name}`)
|
|
208
216
|
}
|
|
209
217
|
}
|
|
210
218
|
return true
|
|
@@ -218,8 +226,9 @@ export class OutputIndex {
|
|
|
218
226
|
}
|
|
219
227
|
// All or nothing: a racy directory dropped alone would leave its
|
|
220
228
|
// parent trusted while an addition inside it bumps only the dropped one.
|
|
221
|
-
const
|
|
222
|
-
if (rows.some(([, mtime]) => mtime >
|
|
229
|
+
const now = Date.now()
|
|
230
|
+
if (rows.some(([, mtime]) => mtime > now - racyWindowMs(mtime, OUTPUT_DIRS_RACY_MS))) ok = false
|
|
231
|
+
if (ok && holds !== undefined && !holds(files)) ok = false
|
|
223
232
|
this.pendingDirs.set(hash, ok ? rows : null)
|
|
224
233
|
}
|
|
225
234
|
|
package/src/cache/run-history.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// its statements over the store's handle; `Cache` delegates.
|
|
5
5
|
|
|
6
6
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
7
|
+
import { xxh3hex } from '../util/index.js'
|
|
7
8
|
import type { InvocationRecord, RunRecord } from './layer.js'
|
|
8
9
|
|
|
9
10
|
export class RunHistory {
|
|
@@ -98,7 +99,10 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
|
98
99
|
run.status,
|
|
99
100
|
run.exitCode,
|
|
100
101
|
run.durationMs,
|
|
101
|
-
|
|
102
|
+
// A digest, as entry_inputs keeps: args after `--` carry tokens
|
|
103
|
+
// (`--token=…`), and cache.db holds no plaintext secret at rest; nothing
|
|
104
|
+
// reads the column but for whether the args changed (item 1091).
|
|
105
|
+
run.forwardArgs ? xxh3hex(JSON.stringify(run.forwardArgs)) : null,
|
|
102
106
|
run.startedAt,
|
|
103
107
|
run.endedAt,
|
|
104
108
|
run.runId ?? null,
|
package/src/cache/zstd.ts
CHANGED
|
@@ -47,6 +47,36 @@ export function zstdContentSize(b: Uint8Array): bigint | null {
|
|
|
47
47
|
return v
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Whether `b` is exactly ONE zstd frame: its header, its blocks by their
|
|
52
|
+
* own sizes (RFC 8878 §3.1.1.2), the checksum when the header asks for one,
|
|
53
|
+
* and not a byte after. `zstdContentSize` reads the first frame alone, and
|
|
54
|
+
* `Bun.zstdDecompress` decodes every frame there is: a 100-byte frame with a
|
|
55
|
+
* second one appended passed a 64 MiB ceiling and expanded to 2 GiB of
|
|
56
|
+
* memory from a 32 KB remote body before the result's length was checked
|
|
57
|
+
* (A-5). Anything but one whole frame decodes as a stream, under the running
|
|
58
|
+
* count. A few header reads per block, on bytes already in memory.
|
|
59
|
+
*/
|
|
60
|
+
function isOneFrame(b: Uint8Array): boolean {
|
|
61
|
+
if (b.length < 6) return false
|
|
62
|
+
const desc = b[4]!
|
|
63
|
+
const fcsFlag = desc >> 6
|
|
64
|
+
const singleSegment = (desc >> 5) & 1
|
|
65
|
+
const checksum = (desc >> 2) & 1
|
|
66
|
+
const dictIdFlag = desc & 3
|
|
67
|
+
let off = 5 + (singleSegment === 0 ? 1 : 0) + (dictIdFlag === 3 ? 4 : dictIdFlag)
|
|
68
|
+
off += fcsFlag === 0 ? singleSegment : 1 << fcsFlag
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (off + 3 > b.length) return false
|
|
71
|
+
const h = b[off]! | (b[off + 1]! << 8) | (b[off + 2]! << 16)
|
|
72
|
+
const type = (h >> 1) & 3
|
|
73
|
+
if (type === 3) return false
|
|
74
|
+
off += 3 + (type === 1 ? 1 : h >>> 3)
|
|
75
|
+
if ((h & 1) === 1) break
|
|
76
|
+
}
|
|
77
|
+
return off + (checksum === 1 ? 4 : 0) === b.length
|
|
78
|
+
}
|
|
79
|
+
|
|
50
80
|
/**
|
|
51
81
|
* Decompress a zstd artifact that DECLARES its content size, with a hard
|
|
52
82
|
* output ceiling: refused before a byte is allocated when the declaration
|
|
@@ -65,13 +95,13 @@ async function zstdDecompressBounded(
|
|
|
65
95
|
assertDeclaredSize(compressed, hash, cap)
|
|
66
96
|
const out = await Bun.zstdDecompress(compressed)
|
|
67
97
|
// UNREACHABLE as written, and kept deliberately. `assertDeclaredSize`
|
|
68
|
-
// above refuses any frame DECLARING more than the cap,
|
|
98
|
+
// above refuses any frame DECLARING more than the cap, Bun refuses a
|
|
69
99
|
// frame whose declaration disagrees with its body ("Decompression
|
|
70
|
-
// failed" on a forged Frame_Content_Size, measured item 487)
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
100
|
+
// failed" on a forged Frame_Content_Size, measured item 487), and only
|
|
101
|
+
// ONE whole frame comes here (`isOneFrame`) — so what gets here declared
|
|
102
|
+
// <= cap and produced exactly that. Before A-5 a second frame appended
|
|
103
|
+
// after the first reached it, past the whole expansion. The only way it
|
|
104
|
+
// fires again is a decoder that stops validating the declaration.
|
|
75
105
|
if (out.length > cap) {
|
|
76
106
|
throw new CorruptArtifactError(hash, `decompressed to ${out.length} bytes (> ${cap} cap)`)
|
|
77
107
|
}
|
|
@@ -139,14 +169,16 @@ export async function decodedTar(
|
|
|
139
169
|
cap: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
140
170
|
): Promise<ReadableStream<Uint8Array>> {
|
|
141
171
|
if (source instanceof Uint8Array) {
|
|
142
|
-
if (assertDeclaredSize(source, hash, cap) === null) {
|
|
172
|
+
if (assertDeclaredSize(source, hash, cap) === null || !isOneFrame(source)) {
|
|
143
173
|
return zstdDecodeStream(new Blob([source]), hash, cap)
|
|
144
174
|
}
|
|
145
175
|
return oneChunk(await zstdDecompressBounded(source, hash, cap))
|
|
146
176
|
}
|
|
147
177
|
if (source.size <= STREAM_DECODE_FROM) {
|
|
148
178
|
const bytes = await source.bytes()
|
|
149
|
-
if (assertDeclaredSize(bytes, hash, cap) === null
|
|
179
|
+
if (assertDeclaredSize(bytes, hash, cap) === null || !isOneFrame(bytes)) {
|
|
180
|
+
return zstdDecodeStream(source, hash, cap)
|
|
181
|
+
}
|
|
150
182
|
return oneChunk(await zstdDecompressBounded(bytes, hash, cap))
|
|
151
183
|
}
|
|
152
184
|
assertDeclaredSize(await source.slice(0, 32).bytes(), hash, cap)
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
1
2
|
import { Cache, noteSchemaReset } from '../cache/index.js'
|
|
2
3
|
import { seeHelp } from './help.js'
|
|
3
4
|
import { nearest, parseDuration, parseSize } from '../util/index.js'
|
|
@@ -143,6 +144,26 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
143
144
|
// wrong path.
|
|
144
145
|
// A dry run only reads: it never resets the index (item 896).
|
|
145
146
|
const dir = await cliCacheDir(root, parsed.cacheDir)
|
|
147
|
+
// A workspace that never ran has nothing to prune, and opening the cache
|
|
148
|
+
// to find that out made it: `.vx/cache` with a database and a
|
|
149
|
+
// `.gitignore`, where item 900 had held the dry run to making nothing
|
|
150
|
+
// (and a read-only checkout was refused as unwritable instead).
|
|
151
|
+
if (!existsSync(dir)) {
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
parsed.dryRun === true ? 'Would prune 0 entries (0 B)\n' : 'Pruned 0 entries (0 B freed)\n',
|
|
154
|
+
)
|
|
155
|
+
return 0
|
|
156
|
+
}
|
|
157
|
+
if (parsed.dryRun === true) {
|
|
158
|
+
const earlier = await Cache.orphansBeforeReset(dir)
|
|
159
|
+
if (earlier !== null) {
|
|
160
|
+
warnToStderr(
|
|
161
|
+
`[vx] the cache index is schema ${earlier.found} from an earlier vx: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
|
|
162
|
+
)
|
|
163
|
+
printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true)
|
|
164
|
+
return 0
|
|
165
|
+
}
|
|
166
|
+
}
|
|
146
167
|
const cache = parsed.dryRun === true ? Cache.inspect(dir) : new Cache(dir)
|
|
147
168
|
// A prune deletes rows and artifacts; a cache this user cannot write is
|
|
148
169
|
// refused up front with the directory named, as a run refuses it, rather
|
|
@@ -163,18 +184,23 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
|
|
|
163
184
|
if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
|
|
164
185
|
if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
|
|
165
186
|
if (parsed.dryRun) opts.dryRun = true
|
|
166
|
-
|
|
167
|
-
const dry = parsed.dryRun === true
|
|
168
|
-
const orphans =
|
|
169
|
-
result.orphans > 0
|
|
170
|
-
? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
|
|
171
|
-
: ''
|
|
172
|
-
process.stdout.write(
|
|
173
|
-
`${dry ? 'Would prune' : 'Pruned'} ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)}${dry ? '' : ' freed'})${orphans}\n`,
|
|
174
|
-
)
|
|
187
|
+
printPruned(await cache.prune(opts), parsed.dryRun === true)
|
|
175
188
|
} finally {
|
|
176
189
|
cache.close()
|
|
177
190
|
await releaseRunLock()
|
|
178
191
|
}
|
|
179
192
|
return 0
|
|
180
193
|
}
|
|
194
|
+
|
|
195
|
+
function printPruned(
|
|
196
|
+
result: { evicted: number; bytesFreed: number; orphans: number; orphanBytes: number },
|
|
197
|
+
dry: boolean,
|
|
198
|
+
): void {
|
|
199
|
+
const orphans =
|
|
200
|
+
result.orphans > 0
|
|
201
|
+
? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
|
|
202
|
+
: ''
|
|
203
|
+
process.stdout.write(
|
|
204
|
+
`${dry ? 'Would prune' : 'Pruned'} ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)}${dry ? '' : ' freed'})${orphans}\n`,
|
|
205
|
+
)
|
|
206
|
+
}
|
package/src/cli/completions.ts
CHANGED
|
@@ -1,20 +1,23 @@
|
|
|
1
1
|
// `vx completions <shell>` — a bash / zsh / fish script over the verb table
|
|
2
|
-
// and each verb's help
|
|
3
|
-
// from the one text `vx <verb> --help` prints. Plugin verbs of the
|
|
2
|
+
// and each verb's Usage line in the help text, so the verbs and every flag
|
|
3
|
+
// of each complete from the one text `vx <verb> --help` prints. Plugin verbs of the
|
|
4
4
|
// workspace around the cwd are included at generation time; task and
|
|
5
5
|
// project names are not (a completion that evaluates configs on every
|
|
6
6
|
// Tab is the wrong price).
|
|
7
7
|
|
|
8
|
-
import { CORE_VERBS, seeHelp
|
|
8
|
+
import { acceptedFlags, CORE_VERBS, seeHelp } from './help.js'
|
|
9
9
|
|
|
10
10
|
export type CompletionShell = 'bash' | 'zsh' | 'fish'
|
|
11
11
|
const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
|
|
12
12
|
|
|
13
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* `--help` and every flag the verb accepts. Not every `--flag` its help cut
|
|
15
|
+
* mentions: the cut's prose names other verbs' flags, and `vx watch`
|
|
16
|
+
* completed only `--dry --graph --summarize --profile`, the four its line
|
|
17
|
+
* says it refuses, while `vx show` offered `--run` and `--list`.
|
|
18
|
+
*/
|
|
14
19
|
export function verbFlags(verb: string): string[] {
|
|
15
|
-
|
|
16
|
-
for (const f of verbHelpText(verb).match(/--[a-zA-Z][a-zA-Z-]*/g) ?? []) flags.add(f)
|
|
17
|
-
return [...flags]
|
|
20
|
+
return [...new Set(['--help', ...acceptedFlags(verb)])]
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
/** The words that follow `vx <verb>` besides flags: subcommands and fixed choices. */
|
package/src/cli/help.ts
CHANGED
|
@@ -113,9 +113,11 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
113
113
|
'Watch mode:',
|
|
114
114
|
' vx watch <task> Initial run, then re-run on filesystem changes.',
|
|
115
115
|
' Uses the same flags as `vx run` except --dry /',
|
|
116
|
-
' --graph / --summarize / --profile
|
|
117
|
-
'
|
|
118
|
-
'
|
|
116
|
+
' --graph / --summarize / --profile / --report /',
|
|
117
|
+
' --report-file / --verbosity (they plan or report',
|
|
118
|
+
' one run). Press Ctrl+C to stop. Cache means most',
|
|
119
|
+
' re-runs are near-zero cost (cache hits restore',
|
|
120
|
+
' outputs + replay logs).',
|
|
119
121
|
'',
|
|
120
122
|
'Cache management:',
|
|
121
123
|
' vx cache prune --older-than 30d Evict entries last accessed > 30 days ago.',
|
|
@@ -189,7 +191,10 @@ export function seeHelp(verb: string): string {
|
|
|
189
191
|
/**
|
|
190
192
|
* The documented `--flags` of one verb, read from the help text's
|
|
191
193
|
* sections headed `… (for <verb>):` so there is no second list to drift —
|
|
192
|
-
* and no hint that names another verb's flag.
|
|
194
|
+
* and no hint that names another verb's flag. Only the flag an option line
|
|
195
|
+
* OPENS with is one: `--frozen`'s line says "pair with vx lock --check", and
|
|
196
|
+
* reading every flag on it put `--check` in the list, so `vx run --chek`
|
|
197
|
+
* was told "did you mean --check?" and `--check` was then refused.
|
|
193
198
|
*/
|
|
194
199
|
export function documentedFlags(verb: string): string[] {
|
|
195
200
|
const flags = new Set<string>()
|
|
@@ -198,7 +203,46 @@ export function documentedFlags(verb: string): string[] {
|
|
|
198
203
|
if (/^[A-Z][A-Za-z ]*(?: \(for [a-z]+\))?:$/.test(line))
|
|
199
204
|
inVerb = line.endsWith(`(for ${verb}):`)
|
|
200
205
|
if (!inVerb) continue
|
|
206
|
+
const own = /^\s+(--[a-zA-Z][a-zA-Z-]*)/.exec(line)
|
|
207
|
+
if (own !== null) flags.add(own[1]!)
|
|
208
|
+
}
|
|
209
|
+
return [...flags]
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The `vx run` flags `vx watch` refuses: each plans or reports ONE run
|
|
214
|
+
* (`watchRefusal` in watch.ts, held to this list by a test).
|
|
215
|
+
*/
|
|
216
|
+
export const WATCH_REFUSED_FLAGS: readonly string[] = [
|
|
217
|
+
'--dry',
|
|
218
|
+
'--graph',
|
|
219
|
+
'--summarize',
|
|
220
|
+
'--profile',
|
|
221
|
+
'--report',
|
|
222
|
+
'--report-file',
|
|
223
|
+
'--verbosity',
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The flags one verb accepts: those on its own Usage line, and for a verb
|
|
228
|
+
* that takes `[OPTIONS]` (run, watch) the documented run flags — less the
|
|
229
|
+
* ones watch refuses. The Usage line is the verb's synopsis; a section's
|
|
230
|
+
* prose names other verbs' flags (`vx lock --check`, `--run <id>` under
|
|
231
|
+
* `vx why`) and is not read.
|
|
232
|
+
*/
|
|
233
|
+
export function acceptedFlags(verb: string): string[] {
|
|
234
|
+
const quoted = verb.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
235
|
+
const usage = helpText()
|
|
236
|
+
.split('\n')
|
|
237
|
+
.filter((line) => new RegExp(`^ vx ${quoted}( |$)`).test(line))
|
|
238
|
+
const flags = new Set<string>()
|
|
239
|
+
for (const line of usage) {
|
|
201
240
|
for (const f of line.match(/--[a-zA-Z][a-zA-Z-]*/g) ?? []) flags.add(f)
|
|
241
|
+
if (line.includes('[OPTIONS]')) {
|
|
242
|
+
for (const f of documentedFlags('run')) {
|
|
243
|
+
if (verb !== 'watch' || !WATCH_REFUSED_FLAGS.includes(f)) flags.add(f)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
202
246
|
}
|
|
203
247
|
return [...flags]
|
|
204
248
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -79,10 +79,12 @@ export async function run(argv: readonly string[]): Promise<number> {
|
|
|
79
79
|
const code = await resolved.command.run(rest, resolved.ctx)
|
|
80
80
|
// A plugin is a boundary: a JS-authored verb that resolves nothing
|
|
81
81
|
// would reach `process.exit(undefined)` and read as SUCCESS. A verb
|
|
82
|
-
// that cannot say whether it succeeded fails, naming its owner.
|
|
83
|
-
|
|
82
|
+
// that cannot say whether it succeeded fails, naming its owner. So
|
|
83
|
+
// does one past a byte: the OS keeps the low eight bits, and a verb
|
|
84
|
+
// that returned 256 (a count of failures, say) exited 0.
|
|
85
|
+
if (!Number.isInteger(code) || code < 0 || code > 255) {
|
|
84
86
|
throw new UserError(
|
|
85
|
-
`plugin '${resolved.plugin.name}': command '${command}' resolved ${JSON.stringify(code)} instead of an exit code`,
|
|
87
|
+
`plugin '${resolved.plugin.name}': command '${command}' resolved ${JSON.stringify(code)} instead of an exit code (an integer 0–255)`,
|
|
86
88
|
)
|
|
87
89
|
}
|
|
88
90
|
return code
|
package/src/cli/info.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface InfoArgs {
|
|
|
17
17
|
error?: string
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
20
|
+
export function parseInfoArgs(args: readonly string[]): InfoArgs {
|
|
21
21
|
const out: InfoArgs = { format: 'pretty' }
|
|
22
22
|
for (let i = 0; i < args.length; i++) {
|
|
23
23
|
const a = args[i]
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import type { VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
|
|
11
11
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
12
12
|
import { loadCliWorkspace } from './workspace-config.js'
|
|
13
|
+
import { machineParallelism } from '../util/index.js'
|
|
13
14
|
|
|
14
15
|
export interface ResolvedPluginCommand {
|
|
15
16
|
plugin: VxPlugin
|
|
@@ -41,7 +42,7 @@ export interface UnresolvedPluginCommand {
|
|
|
41
42
|
async function workspacePlugins(
|
|
42
43
|
cwd: string,
|
|
43
44
|
): Promise<
|
|
44
|
-
| { workspaceRoot: string; cacheDir: string; plugins: readonly VxPlugin[] }
|
|
45
|
+
| { workspaceRoot: string; cacheDir: string; concurrency: number; plugins: readonly VxPlugin[] }
|
|
45
46
|
| UnresolvedPluginCommand
|
|
46
47
|
| null
|
|
47
48
|
> {
|
|
@@ -57,7 +58,13 @@ async function workspacePlugins(
|
|
|
57
58
|
} catch (err) {
|
|
58
59
|
return { loadError: err instanceof Error ? err.message : String(err) }
|
|
59
60
|
}
|
|
60
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
workspaceRoot,
|
|
63
|
+
cacheDir: ws.cacheDir,
|
|
64
|
+
// What run.ts takes when no --concurrency is given.
|
|
65
|
+
concurrency: ws.workspaceConfig?.concurrency ?? machineParallelism(),
|
|
66
|
+
plugins: ws.plugins,
|
|
67
|
+
}
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
/**
|
|
@@ -84,6 +91,7 @@ export async function resolvePluginCommand(
|
|
|
84
91
|
ctx: {
|
|
85
92
|
workspaceRoot: ws.workspaceRoot,
|
|
86
93
|
cacheDir: ws.cacheDir,
|
|
94
|
+
concurrency: ws.concurrency,
|
|
87
95
|
warn: (m) => process.stderr.write(`${m}\n`),
|
|
88
96
|
},
|
|
89
97
|
}
|
package/src/cli/run.ts
CHANGED
|
@@ -556,6 +556,7 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
556
556
|
...(parsed.frozen ? { frozen: true } : {}),
|
|
557
557
|
},
|
|
558
558
|
)
|
|
559
|
+
if (picked === 'interrupted') return 130
|
|
559
560
|
if (!picked) return 1
|
|
560
561
|
tasks = [`${picked.project}#${picked.task}`]
|
|
561
562
|
}
|