@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.
Files changed (55) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +1 -0
  22. package/src/cli/select.ts +29 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +35 -18
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/orchestrator/admission.ts +72 -17
  35. package/src/orchestrator/execute-task.ts +39 -25
  36. package/src/orchestrator/hit-restore.ts +7 -1
  37. package/src/orchestrator/miss-save.ts +29 -1
  38. package/src/orchestrator/placement.ts +23 -3
  39. package/src/orchestrator/plugin-host.ts +1 -1
  40. package/src/orchestrator/plugin.ts +9 -1
  41. package/src/orchestrator/prepare.ts +1 -0
  42. package/src/orchestrator/run.ts +75 -12
  43. package/src/orchestrator/sandbox-request.ts +19 -10
  44. package/src/orchestrator/summary.ts +26 -15
  45. package/src/orchestrator/task-hash.ts +7 -2
  46. package/src/orchestrator/telemetry.ts +14 -4
  47. package/src/util/paths.ts +16 -9
  48. package/src/workspace/affected.ts +201 -38
  49. package/src/workspace/config-eval.ts +4 -1
  50. package/src/workspace/config-schema.ts +70 -10
  51. package/src/workspace/fingerprint.ts +6 -0
  52. package/src/workspace/migrate-scripts.ts +11 -5
  53. package/src/workspace/migration.ts +1 -1
  54. package/src/workspace/project-loader.ts +53 -1
  55. package/src/workspace/workspace.ts +54 -14
@@ -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
- return parseLsFilesOutput(text, undecodable)
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 all = untracked.length === 0 ? tracked : tracked.concat(untracked)
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. What
943
- // hides a directory from this listing is a nested repository — a
944
- // submodule, an embedded repository — which the workspace's git holds as
945
- // ONE entry (a gitlink, `dir/` when untracked) and, under a pathspec
946
- // naming the project, as nothing at all; or a directory ignored
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
@@ -23,6 +23,7 @@ export {
23
23
  OUTPUT_DIRS_CAP,
24
24
  FILE_HASH_RACY_MS,
25
25
  OUTPUT_DIRS_RACY_MS,
26
+ racyWindowMs,
26
27
  noteSchemaReset,
27
28
  type SchemaReset,
28
29
  SCHEMA_VERSION,
@@ -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
- recordOutputDirs?(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void>
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(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
535
- return this.local.recordOutputDirs(hash, projectDir, prefixes)
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`. Called
168
- * after a save and after a restore, when the tree is known to equal the
169
- * entry's set. Symlinked directories are not descended (the output walk
170
- * refuses them too). Over `OUTPUT_DIRS_CAP` directories, or on any
171
- * error, the rows are cleared and the next hit keeps the walk.
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 youngest = Date.now() - OUTPUT_DIRS_RACY_MS
222
- if (rows.some(([, mtime]) => mtime > youngest)) ok = false
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
 
@@ -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
- run.forwardArgs ? JSON.stringify(run.forwardArgs) : null,
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, and Bun refuses a
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) — so a frame
71
- // that gets here declared <= cap and produced exactly that. The only way
72
- // this fires again is a decoder that stops validating the declaration.
73
- // That is what it is for; it is not a second live layer, and no test can
74
- // reach it.
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) return zstdDecodeStream(source, hash, cap)
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
- const result = await cache.prune(opts)
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
+ }
@@ -1,20 +1,23 @@
1
1
  // `vx completions <shell>` — a bash / zsh / fish script over the verb table
2
- // and each verb's help cut, so the verbs and every flag of each complete
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, verbHelpText } from './help.js'
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
- /** Every `--flag` the verb's help cut names, plus `--help`; deduped, in order of appearance. */
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
- const flags = new Set<string>(['--help'])
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. Press Ctrl+C',
117
- ' to stop. Cache means most re-runs are near-zero',
118
- ' cost (cache hits restore outputs + replay logs).',
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
- if (!Number.isInteger(code)) {
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 { workspaceRoot, cacheDir: ws.cacheDir, plugins: ws.plugins }
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
  }