@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -24,11 +24,12 @@ import { UserError } from '../util/index.js'
24
24
  * 'name' same-project task `name`
25
25
  * '^name' `name` task in every dep workspace
26
26
  * 'pkg#name' specific package's `name` task
27
- * 'name.*' task-NAME patterns — the task half of any form above may
28
- * contain `*` (same glob as dependsOn patterns). A filter
29
- * that matched literally here while dependsOn expanded the
30
- * same string would silently select ZERO upstream hashes —
31
- * a stale-hit trap, so the two surfaces share the matcher.
27
+ * 'name.*' patterns — EITHER half of any form above may contain `*`
28
+ * (same glob as dependsOn patterns), including the project
29
+ * half of `pkg#name`. A filter that matched literally here
30
+ * would silently select ZERO upstream hashes and decouple the
31
+ * task from its dependencies — a stale-hit trap, so every
32
+ * name is matched through the shared glob.
32
33
  * '!<form>' exclude — any of the above with a leading `!`
33
34
  *
34
35
  * Patterns are applied in order; last write wins, so
@@ -69,19 +70,19 @@ export function filterUpstreamHashes(
69
70
  }
70
71
  })
71
72
 
72
- // Per-spec task-name matcher, compiled once (exact compare or pattern).
73
- const matchers = specs.map((spec) => taskMatcher(spec))
73
+ // Per-spec predicate, compiled once (exact compares + patterns).
74
+ const matchers = specs.map((spec) => specMatcher(spec))
74
75
 
75
76
  // Dedup by hash (the key fold's unit), but remember the first task id
76
77
  // seen for each hash so the diff row can name the upstream.
77
78
  const selected = new Map<string, string>()
78
79
  for (let i = 0; i < specs.length; i++) {
79
80
  const spec = specs[i]!
80
- const taskMatches = matchers[i]!
81
+ const matches = matchers[i]!
81
82
  for (const u of upstream) {
82
83
  if (!u.hash) continue
83
84
  const isSelf = u.node.projectName === selfProjectName
84
- if (!matches(spec, u, isSelf, taskMatches)) continue
85
+ if (!matches(u, isSelf)) continue
85
86
  if (spec.negated) selected.delete(u.hash)
86
87
  else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
87
88
  }
@@ -89,33 +90,73 @@ export function filterUpstreamHashes(
89
90
  return [...selected].map(([hash, id]) => [id, hash])
90
91
  }
91
92
 
92
- /** Exact-name compare, or the shared `*`-glob when the form is a pattern. */
93
- function taskMatcher(spec: DependencySpec): (name: string) => boolean {
94
- if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') return () => true
95
- if (isTaskPattern(spec.task)) {
96
- const re = compileTaskPattern(spec.task)
97
- return (name) => re.test(name)
93
+ /**
94
+ * Expand any GROUP among these outcomes into the real tasks it stands for,
95
+ * transitively, preserving order and de-duplicating by task id.
96
+ *
97
+ * A group has no `exec`, so it has no outputs and no cache entry — its hash
98
+ * is a synthetic roll-up. A dependent that asked the local index what its
99
+ * upstream produced therefore got an EMPTY output list for a group, which is
100
+ * invisible locally (the members' outputs are already on disk, put there by
101
+ * their own tasks) and fatal for an input-shipping executor, where that list
102
+ * IS the input root: `dependsOn: ['install']` shipped a worker an action with
103
+ * none of what `install` chains.
104
+ *
105
+ * Groups may nest, so the walk recurses. Only what the FILTER already
106
+ * selected is passed in, so a group excluded from a task's
107
+ * `cache.inputs.tasks` brings no members with it.
108
+ */
109
+ export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
110
+ const out: TaskOutcome[] = []
111
+ const seen = new Set<string>()
112
+ const walk = (list: readonly TaskOutcome[]): void => {
113
+ for (const u of list) {
114
+ if (u.groupUpstream !== undefined) {
115
+ walk(u.groupUpstream)
116
+ continue
117
+ }
118
+ if (seen.has(u.node.id)) continue
119
+ seen.add(u.node.id)
120
+ out.push(u)
121
+ }
122
+ }
123
+ walk(upstream)
124
+ return out
125
+ }
126
+
127
+ /** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
128
+ function nameMatcher(name: string): (candidate: string) => boolean {
129
+ if (isTaskPattern(name)) {
130
+ const re = compileTaskPattern(name)
131
+ return (candidate) => re.test(candidate)
98
132
  }
99
- const exact = spec.task
100
- return (name) => name === exact
133
+ return (candidate) => candidate === name
101
134
  }
102
135
 
103
- function matches(
104
- spec: DependencySpec,
105
- u: TaskOutcome,
106
- isSelf: boolean,
107
- taskMatches: (name: string) => boolean,
108
- ): boolean {
136
+ /**
137
+ * Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
138
+ * form glob: a filter only ever selects from upstreams that already exist,
139
+ * so a package pattern is unambiguous here — unlike dependsOn, which must
140
+ * materialize concrete edges and therefore rejects it.
141
+ */
142
+ function specMatcher(spec: DependencySpec): (u: TaskOutcome, isSelf: boolean) => boolean {
109
143
  switch (spec.kind) {
110
144
  case 'wildcardSelf':
111
- return isSelf
145
+ return (_u, isSelf) => isSelf
112
146
  case 'wildcardDeps':
113
- return !isSelf
114
- case 'self':
115
- return isSelf && taskMatches(u.node.taskName)
116
- case 'deps':
117
- return !isSelf && taskMatches(u.node.taskName)
118
- case 'cross':
119
- return u.node.projectName === spec.project && taskMatches(u.node.taskName)
147
+ return (_u, isSelf) => !isSelf
148
+ case 'self': {
149
+ const task = nameMatcher(spec.task)
150
+ return (u, isSelf) => isSelf && task(u.node.taskName)
151
+ }
152
+ case 'deps': {
153
+ const task = nameMatcher(spec.task)
154
+ return (u, isSelf) => !isSelf && task(u.node.taskName)
155
+ }
156
+ case 'cross': {
157
+ const project = nameMatcher(spec.project)
158
+ const task = nameMatcher(spec.task)
159
+ return (u) => project(u.node.projectName) && task(u.node.taskName)
160
+ }
120
161
  }
121
162
  }
@@ -52,7 +52,7 @@ export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<st
52
52
 
53
53
  /** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
54
54
  * digest always folds ALL entries, so detection never depends on the map. */
55
- export const FP_MAX_FILES = 500
55
+ const FP_MAX_FILES = 500
56
56
 
57
57
  /** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
58
58
  * payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
@@ -101,7 +101,12 @@ export function undeclaredInputPaths(
101
101
  ): string[] {
102
102
  const rels = new Set<string>()
103
103
  for (const v of violations) {
104
- const m = /\[([^\]]+)\]\s*$/.exec(v.line)
104
+ // Two wire shapes: Linux strace lines carry `[path]`; macOS
105
+ // sandbox-exec lines end in a bare absolute path
106
+ // (`node(123) deny(1) file-read-data /abs/path`). Fall back to the
107
+ // raw line only when neither parses — a path the user can act on
108
+ // beats a log line every time.
109
+ const m = /\[([^\]]+)\]\s*$/.exec(v.line) ?? /\s(\/\S+)\s*$/.exec(v.line)
105
110
  const raw = m ? m[1]! : v.line
106
111
  rels.add(
107
112
  path.isAbsolute(raw) ? path.relative(workspaceRoot, raw).split(path.sep).join('/') : raw,
@@ -138,6 +143,9 @@ export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[]
138
143
  case 'not-verified':
139
144
  notVerified++
140
145
  break
146
+ case 'unverifiable-remote-only':
147
+ na++
148
+ break
141
149
  }
142
150
  }
143
151
  const lines = [
@@ -156,6 +164,8 @@ export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[]
156
164
  lines.push(' add them to cache.inputs.files / workspaceFiles')
157
165
  } else if (v.kind === 'rerun-failed') {
158
166
  lines.push(` ✗ ${o.node.id} — verify re-run failed (exit ${v.exitCode})`)
167
+ } else if (v.kind === 'unverifiable-remote-only') {
168
+ lines.push(` ⚠ ${o.node.id} — remote-only: no local execution to prove (unverified)`)
159
169
  } else if (v.kind === 'allowed-nondeterministic') {
160
170
  lines.push(` ⚠ ${o.node.id} — nondeterministic (allowed)`)
161
171
  }
@@ -0,0 +1,11 @@
1
+ // Core's cache as a plugin: the `.vx/cache` handle the host opened (the run
2
+ // index + the local artifact store). Declared like any other cache layer;
3
+ // put a remote layer BEFORE it to look there first.
4
+
5
+ import type { VxPlugin } from '@vzn/vx'
6
+
7
+ export const LOCAL_CACHE_PLUGIN = 'vx/local-cache'
8
+
9
+ export function localCachePlugin(): VxPlugin {
10
+ return { name: LOCAL_CACHE_PLUGIN, cache: (ctx) => ctx.localCache }
11
+ }
@@ -0,0 +1,45 @@
1
+ // Core's executor as a plugin: spawn the task's command in-process, exactly
2
+ // the `runCommand` / `runSandboxed` call the orchestrator used to make
3
+ // directly. Imports core only through the public `@vzn/vx` surface so this
4
+ // directory can become its own package unchanged.
5
+
6
+ import { runCommand, runSandboxed, type TaskExecutor, type VxPlugin } from '@vzn/vx'
7
+
8
+ export const LOCAL_EXECUTOR_PLUGIN = 'vx/local-executor'
9
+
10
+ /** The executor itself — for tests and for plugins that wrap local execution. */
11
+ export function localExecutor(): TaskExecutor {
12
+ return {
13
+ name: 'local',
14
+ async execute(req) {
15
+ const common = {
16
+ command: req.command,
17
+ cwd: req.cwd,
18
+ env: req.env,
19
+ forwardArgs: req.forwardArgs,
20
+ onStdout: req.onStdout,
21
+ onStderr: req.onStderr,
22
+ capture: req.capture,
23
+ ...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
24
+ ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
25
+ }
26
+ if (req.sandbox === undefined) {
27
+ const res = await runCommand(common)
28
+ return { ...res, violations: [] }
29
+ }
30
+ return await runSandboxed({
31
+ ...common,
32
+ baseAllowRead: req.sandbox.baseAllowRead,
33
+ baseAllowWrite: req.sandbox.baseAllowWrite,
34
+ baseDenyRead: req.sandbox.baseDenyRead,
35
+ config: req.sandbox.config,
36
+ settleOnCleanExit: req.sandbox.settleOnCleanExit,
37
+ })
38
+ },
39
+ }
40
+ }
41
+
42
+ /** Declare in vx.workspace.ts: `plugins: [localExecutorPlugin(), …]`. Accepts every task. */
43
+ export function localExecutorPlugin(): VxPlugin {
44
+ return { name: LOCAL_EXECUTOR_PLUGIN, executor: () => localExecutor() }
45
+ }
@@ -0,0 +1,109 @@
1
+ // History-based scheduling as a plugin: order ready tasks by their expected
2
+ // REMAINING critical-path duration (own p50 + the longest chain of
3
+ // dependents), learned from the local run history. This was core's opt-in
4
+ // `predictive` mode until 2026-09-02; it is the reference `schedule` plugin
5
+ // now — declared like any other, and paying its history read only in
6
+ // workspaces that ask for it.
7
+ //
8
+ // Cache hits are NOT modelled as zero-cost: predicting cache state needs
9
+ // the key and a probe, which the scheduler handles at run time (a confirmed
10
+ // hit is backfill, never a critical-path task). The estimate here is "what
11
+ // if everything ran", which is exactly the case where order matters.
12
+
13
+ import { LocalHistoryProvider, type HistoryTable, type TaskNode, type VxPlugin } from '@vzn/vx'
14
+
15
+ const SCHEDULE_HISTORY_PLUGIN = 'vx/schedule-history'
16
+
17
+ /** Default duration when neither task history nor a workspace median exists. */
18
+ const DEFAULT_DURATION_MS = 1000
19
+
20
+ export interface ScheduleHistoryOptions {
21
+ /** How many recent runs per task to learn from. Default 20. */
22
+ readonly window?: number
23
+ }
24
+
25
+ export function scheduleHistoryPlugin(options: ScheduleHistoryOptions = {}): VxPlugin {
26
+ return {
27
+ name: SCHEDULE_HISTORY_PLUGIN,
28
+ async schedule(nodes, ctx) {
29
+ const provider =
30
+ options.window === undefined
31
+ ? new LocalHistoryProvider(ctx.localCache.dbHandle())
32
+ : new LocalHistoryProvider(ctx.localCache.dbHandle(), options.window)
33
+ let table: HistoryTable
34
+ try {
35
+ table = await provider.loadFor([...nodes.keys()])
36
+ } catch (err) {
37
+ // Failing open: a broken history read costs the ordering, never the run.
38
+ ctx.warn(
39
+ `[vx] schedule-history: falling back to the baseline order: ${err instanceof Error ? err.message : String(err)}`,
40
+ )
41
+ return undefined
42
+ }
43
+ return criticalPathPriorities([...nodes.values()], table)
44
+ },
45
+ }
46
+ }
47
+
48
+ /**
49
+ * For each node, the expected remaining critical-path duration: its own p50
50
+ * plus the maximum over its dependents. A node with no history takes the
51
+ * workspace median; an empty history takes a flat default.
52
+ */
53
+ export function criticalPathPriorities(
54
+ nodes: readonly TaskNode[],
55
+ history: HistoryTable,
56
+ ): ReadonlyMap<string, number> {
57
+ const p50s: number[] = []
58
+ for (const h of history.values()) {
59
+ if (h.p50DurationMs !== undefined) p50s.push(h.p50DurationMs)
60
+ }
61
+ p50s.sort((a, b) => a - b)
62
+ const workspaceMedian =
63
+ p50s.length > 0
64
+ ? (p50s[Math.floor(p50s.length / 2)] ?? DEFAULT_DURATION_MS)
65
+ : DEFAULT_DURATION_MS
66
+
67
+ const dependentsOf = new Map<string, string[]>()
68
+ for (const n of nodes) {
69
+ for (const upstreamId of n.deps) {
70
+ const list = dependentsOf.get(upstreamId)
71
+ if (list) list.push(n.id)
72
+ else dependentsOf.set(upstreamId, [n.id])
73
+ }
74
+ }
75
+ const ownDuration = (n: TaskNode): number => history.get(n.id)?.p50DurationMs ?? workspaceMedian
76
+
77
+ // Reverse-topological pass: a node's value is final once every dependent's
78
+ // is, so start from the sinks (no dependents) and release each upstream
79
+ // when its last dependent has been scored.
80
+ const memo = new Map<string, number>()
81
+ const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
82
+ const pending = new Map<string, number>()
83
+ const queue: TaskNode[] = []
84
+ for (const n of nodes) {
85
+ const count = dependentsOf.get(n.id)?.length ?? 0
86
+ pending.set(n.id, count)
87
+ if (count === 0) queue.push(n)
88
+ }
89
+ let head = 0
90
+ while (head < queue.length) {
91
+ const n = queue[head++]!
92
+ let downstream = 0
93
+ for (const dep of dependentsOf.get(n.id) ?? []) {
94
+ const d = memo.get(dep) ?? 0
95
+ if (d > downstream) downstream = d
96
+ }
97
+ memo.set(n.id, ownDuration(n) + downstream)
98
+ for (const up of n.deps) {
99
+ const left = (pending.get(up) ?? 0) - 1
100
+ pending.set(up, left)
101
+ if (left === 0) {
102
+ const upNode = nodeById.get(up)
103
+ if (upNode) queue.push(upNode)
104
+ }
105
+ }
106
+ }
107
+ for (const n of nodes) if (!memo.has(n.id)) memo.set(n.id, ownDuration(n))
108
+ return memo
109
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Levenshtein distance, capped: anything past two edits reads as 3. Used
3
+ * by the "did you mean" hints for task names and flags, where a hint
4
+ * beyond two edits would guess rather than help.
5
+ */
6
+ export function editDistance(a: string, b: string): number {
7
+ if (Math.abs(a.length - b.length) > 2) return 3
8
+ let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
9
+ for (let i = 1; i <= a.length; i++) {
10
+ const cur = [i]
11
+ for (let j = 1; j <= b.length; j++) {
12
+ cur[j] = Math.min(
13
+ prev[j]! + 1,
14
+ cur[j - 1]! + 1,
15
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
16
+ )
17
+ }
18
+ prev = cur
19
+ }
20
+ return Math.min(prev[b.length]!, 3)
21
+ }
@@ -1,7 +1,7 @@
1
1
  // Shared error types used across modules to distinguish user-input
2
2
  // failures (clean message only) from internal bugs (full stack trace).
3
3
  //
4
- // `bin.ts` consults `instanceof UserError` to decide what to print.
4
+ // `bin.ts` and the scheduler consult `isUserError` to decide what to print.
5
5
 
6
6
  export class UserError extends Error {
7
7
  constructor(message: string) {
@@ -9,3 +9,17 @@ export class UserError extends Error {
9
9
  this.name = 'UserError'
10
10
  }
11
11
  }
12
+
13
+ /**
14
+ * `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
15
+ * core. A compiled `vx` binary carries core inside it while a plugin in the
16
+ * workspace imports `@vzn/vx` from node_modules, so a plugin's `UserError`
17
+ * is a different class object and `instanceof` is false — a plugin verb's
18
+ * "bad flag --x" printed as `UserError: bad flag --x` with a stack, and a
19
+ * REAPI refusal would have read as an "internal error" (reproduced through
20
+ * the real binary, 2026-09-03). The name is the contract that survives the
21
+ * copy boundary.
22
+ */
23
+ export function isUserError(err: unknown): err is UserError {
24
+ return err instanceof UserError || (err instanceof Error && err.name === 'UserError')
25
+ }
package/src/util/index.ts CHANGED
@@ -1,8 +1,13 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { UserError } from './errors.js'
4
+ export { isUserError, UserError } from './errors.js'
5
5
  export { xxh3, xxh3hex } from './hash.js'
6
- export { relPosix } from './paths.js'
6
+ export { mark, printTimings, span } from './timing.js'
7
+ export { clampInt, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
8
+ export { relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.js'
9
+ export { settleWithin, teardownTimeoutMs } from './settle.js'
7
10
  export { parseSize } from './size.js'
11
+ export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
8
12
  export { ulid } from './ulid.js'
13
+ export { editDistance } from './edit-distance.js'
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The largest delay `setTimeout` actually honours: 2^31 - 1 ms (~24.8 days).
3
+ *
4
+ * A larger delay does NOT saturate and does NOT throw — it silently becomes
5
+ * **1 ms**, which is the exact INVERSE of what the caller asked for. A task
6
+ * declaring a 317-year timeout is SIGTERMed 4 ms after it spawns and reported
7
+ * `failed`, and the only clue is a `TimeoutOverflowWarning` on stderr that a CI
8
+ * log swallows. Every surface that accepts a millisecond delay bounds it
9
+ * against this, so "effectively no limit" can never mean "kill immediately".
10
+ */
11
+ export const MAX_TIMEOUT_MS = 2 ** 31 - 1
12
+
13
+ /**
14
+ * Clamp to an INTEGER in `[min, max]`; a non-finite value collapses to `min`.
15
+ *
16
+ * The floor is load-bearing wherever the result reaches SQL: a fractional
17
+ * `LIMIT` is a `datatype mismatch` error, not a smaller page.
18
+ */
19
+ export function clampInt(n: number, min: number, max: number): number {
20
+ if (!Number.isFinite(n)) return min
21
+ return Math.min(max, Math.max(min, Math.floor(n)))
22
+ }
23
+
24
+ /**
25
+ * Parse a PLAIN decimal integer from user input; `null` for anything else.
26
+ *
27
+ * `Number()` is the wrong tool at an argument boundary: it silently accepts
28
+ * hex (`0x10` → 16), exponents (`1e3` → 1000), fractions, a leading `+`, and
29
+ * surrounding whitespace — so a typo becomes a different number instead of an
30
+ * error. Values past `Number.MAX_SAFE_INTEGER` are rejected too: they parse to
31
+ * a number the user did not type (`9007199254740993` → `…92`).
32
+ */
33
+ export function parseDecimalInt(input: string): number | null {
34
+ if (!/^\d+$/.test(input)) return null
35
+ const n = Number(input)
36
+ return Number.isSafeInteger(n) ? n : null
37
+ }
package/src/util/paths.ts CHANGED
@@ -14,3 +14,46 @@ export function toPosix(p: string): string {
14
14
  export function relPosix(from: string, to: string): string {
15
15
  return toPosix(path.relative(from, to))
16
16
  }
17
+
18
+ /**
19
+ * The leading, wildcard-free part of a glob, trimmed back to whole path
20
+ * components (`dist/sub-**` → `dist`). `.` when the first component is
21
+ * already a wildcard, i.e. the glob can reach anything in its anchor dir.
22
+ *
23
+ * Two callers with different needs share it deliberately: the sandbox
24
+ * baseline joins it onto a dir to get a write prefix, and the deferral
25
+ * eligibility gate compares two of them for overlap. A second copy is
26
+ * how the two would disagree about what a prefix is.
27
+ */
28
+ export function staticPrefix(glob: string): string {
29
+ const wildcardIdx = glob.search(/[*?[\]]/)
30
+ if (wildcardIdx === -1) return glob
31
+ const head = glob.slice(0, wildcardIdx)
32
+ const lastSep = head.lastIndexOf('/')
33
+ if (lastSep === -1) return '.'
34
+ return head.slice(0, lastSep) || '/'
35
+ }
36
+
37
+ /**
38
+ * The directories a task's declared outputs cover WHOLE — every glob is
39
+ * `<dir>/**` with a plain, non-root, non-escaping `<dir>` — or `null` when
40
+ * any glob is shaped otherwise. The directory-mtime short-circuit on a warm
41
+ * hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees: with
42
+ * every directory under `<dir>` recorded, a file added or removed anywhere
43
+ * the glob could see bumps a recorded directory's mtime (its parent, or a
44
+ * new directory whose creation bumped a recorded ancestor). A root-anchored
45
+ * `**\/*.js` has no such closed set, so it keeps the walk.
46
+ */
47
+ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
48
+ if (globs.length === 0) return null
49
+ const out: string[] = []
50
+ for (const g of globs) {
51
+ const m = /^([^*?[\]{}!]+?)\/\*\*$/.exec(g)
52
+ if (m === null) return null
53
+ const dir = m[1]!.replace(/\/+$/, '')
54
+ if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
55
+ return null
56
+ out.push(dir)
57
+ }
58
+ return [...new Set(out)]
59
+ }
@@ -0,0 +1,48 @@
1
+ // End-of-run settle bound. A plugin's flush/teardown is I/O a third party
2
+ // wrote; it must not hold the run's exit hostage.
3
+
4
+ import { MAX_TIMEOUT_MS } from './num.js'
5
+
6
+ /** Default upper bound on one end-of-run flush/teardown await. */
7
+ const DEFAULT_TIMEOUT_MS = 3000
8
+
9
+ /**
10
+ * The bound to apply, read per call so a test can drive the deadline
11
+ * instead of waiting it out.
12
+ */
13
+ export function teardownTimeoutMs(): number {
14
+ const raw = process.env['VX_TEARDOWN_TIMEOUT_MS']
15
+ // Out-of-range falls back to the DEFAULT rather than clamping to
16
+ // MAX_TIMEOUT_MS, and the distinction matters. This value is a BOUND, not a
17
+ // duration: there is no "no limit" option here, because the whole reason the
18
+ // deadline exists is that a plugin's flush must not hold the run's exit
19
+ // hostage. Clamping would honour "wait ~24.8 days", which defeats it — the
20
+ // run would hang. Unbounded is worse still (the delay becomes 1 ms, so every
21
+ // flush times out and every buffered record is dropped). Falling back keeps
22
+ // the bound real in both directions.
23
+ if (raw !== undefined && /^[0-9]+$/.test(raw)) {
24
+ const n = Number(raw)
25
+ if (n <= MAX_TIMEOUT_MS) return n
26
+ }
27
+ return DEFAULT_TIMEOUT_MS
28
+ }
29
+
30
+ /**
31
+ * Await `p`, giving up after `ms`. Returns true when `p` settled first,
32
+ * false when the deadline won — the caller decides whether a lost result
33
+ * is worth reporting.
34
+ */
35
+ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
36
+ let timer: ReturnType<typeof setTimeout> | undefined
37
+ const deadline = new Promise<false>((resolve) => {
38
+ timer = setTimeout(() => resolve(false), ms)
39
+ })
40
+ try {
41
+ return (await Promise.race([p.then(() => true), deadline])) !== false
42
+ } finally {
43
+ clearTimeout(timer)
44
+ // A rejection landing after the deadline won already resolved the
45
+ // race must not surface as an unhandled-rejection crash.
46
+ void p.catch(() => {})
47
+ }
48
+ }
package/src/util/size.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { parseDecimalInt } from './num.js'
2
+
1
3
  /**
2
4
  * Parse a byte-size string: a bare integer (`"1048576"`) or an integer
3
5
  * with a K/M/G/T suffix, powers of 1024, optional trailing `B`
@@ -8,7 +10,9 @@
8
10
  export function parseSize(input: string): number | null {
9
11
  const m = input.match(/^(\d+)([KMGT])?B?$/i)
10
12
  if (!m) return null
11
- const n = Number(m[1])
13
+ // Digits past 2^53 parse to a different number than the user typed.
14
+ const n = parseDecimalInt(m[1]!)
15
+ if (n === null) return null
12
16
  const u = (m[2] ?? '').toUpperCase()
13
17
  const mult =
14
18
  u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
@@ -0,0 +1,66 @@
1
+ // Bounded head-evicting tail for one stream of one still-running task.
2
+ //
3
+ // A PERSISTENT task (dev server, watcher, daemon) is unbounded by
4
+ // construction: unlike a one-shot command, nothing about it terminating
5
+ // bounds how much it writes. Anything that accumulates its output therefore
6
+ // needs a cap, and the cap has to be honest — a truncated log that reads as
7
+ // complete is worse than one that says what it lost.
8
+ //
9
+ // `orchestrator/logger.ts` is the sole holder: it registers a tail per
10
+ // persistent task at taskStart and keeps one for the rest of the run, so a
11
+ // single mechanism covers both the pre-ready window and everything after.
12
+ // `exec/runner.ts` used to keep a SECOND pre-ready copy fed from the same
13
+ // chunks that nothing ever read; it was deleted rather than kept in sync,
14
+ // since two copies of this rule is how they drift — one grew a bound and
15
+ // the other did not, which is the defect this file was extracted to close
16
+ // (a never-ready `readyWhen` grew vx's heap ~100 MiB/s with the 64 KiB cap
17
+ // sitting unused, because it was only ever engaged once the task became
18
+ // ready). It stays in `util` because `exec` cannot import `orchestrator`,
19
+ // so a future second holder on that side has somewhere to import from.
20
+
21
+ /** Per-stream cap on retained output for a task that may never end. */
22
+ export const PERSISTENT_TAIL_CHARS = 64 * 1024
23
+
24
+ export interface Tail {
25
+ chunks: string[]
26
+ chars: number
27
+ /** Characters evicted from the head. Non-zero means the tail is partial. */
28
+ dropped: number
29
+ }
30
+
31
+ export function createTail(): Tail {
32
+ return { chunks: [], chars: 0, dropped: 0 }
33
+ }
34
+
35
+ export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS): void {
36
+ if (chunk.length === 0) return
37
+ t.chunks.push(chunk)
38
+ t.chars += chunk.length
39
+ // Evict WHOLE chunks from the head — no concatenation until the flush
40
+ // joins once, so a chatty server costs one array push per chunk.
41
+ while (t.chars > limit && t.chunks.length > 1) {
42
+ const gone = t.chunks.shift()!
43
+ t.chars -= gone.length
44
+ t.dropped += gone.length
45
+ }
46
+ // A single chunk over the cap is the one place we copy.
47
+ if (t.chars > limit) {
48
+ const only = t.chunks[0]!
49
+ const keep = only.slice(only.length - limit)
50
+ t.dropped += only.length - keep.length
51
+ t.chunks[0] = keep
52
+ t.chars = keep.length
53
+ }
54
+ }
55
+
56
+ /** Join the retained text. The tail keeps its chunks — see `resetTail`. */
57
+ export function tailText(t: Tail): string {
58
+ return t.chunks.length === 1 ? t.chunks[0]! : t.chunks.join('')
59
+ }
60
+
61
+ /** Drop everything retained, keeping the tail usable for a new phase. */
62
+ export function resetTail(t: Tail): void {
63
+ t.chunks.length = 0
64
+ t.chars = 0
65
+ t.dropped = 0
66
+ }