@vzn/vx 0.0.16 → 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 +66 -89
  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 +88 -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 +12 -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
package/src/config.ts CHANGED
@@ -19,18 +19,12 @@ export interface WorkspaceConfig {
19
19
  * `vx run`. See `docs/design/extension-protocol-2026-06.md` §5.
20
20
  */
21
21
  plugins?: readonly Plugin[]
22
- /**
23
- * Opt in to history-aware predictive scheduling. When `true` and
24
- * `cache.db` has prior runs, the scheduler picks the next ready
25
- * task by expected remaining critical-path duration (HistoryTable
26
- * p50) instead of the static reverse-deps count.
27
- */
28
- predictive?: boolean
29
22
  }
30
23
 
31
24
  /**
32
- * Structural plugin shape — any subset of the run-level capabilities
33
- * (`backend`/`cache`/`telemetry`/`eventSink`) plus optional
25
+ * Structural plugin shape — any subset of the pipeline stages
26
+ * (`config`/`project`/`graph`/`key`/`schedule`), run-level capabilities
27
+ * (`cache`/`executor`/`telemetry`) and CLI `commands`, plus optional
34
28
  * `setup`/`teardown`. The fully-typed `VxPlugin` lives in
35
29
  * `src/orchestrator/plugin.ts` and is what users import; this is a
36
30
  * re-declaration with opaque function types so `config.ts` stays a leaf
@@ -39,10 +33,15 @@ export interface WorkspaceConfig {
39
33
  */
40
34
  export interface Plugin {
41
35
  readonly name: string
42
- backend?(ctx: unknown): unknown
36
+ config?(workspace: unknown, ctx: unknown): unknown
37
+ project?(config: unknown, ctx: unknown): unknown
38
+ graph?(nodes: unknown, ctx: unknown): unknown
39
+ key?(task: unknown, ctx: unknown): unknown
40
+ schedule?(nodes: unknown, ctx: unknown): unknown
41
+ readonly commands?: Readonly<Record<string, unknown>>
43
42
  cache?(ctx: unknown): unknown
43
+ executor?(ctx: unknown): unknown
44
44
  telemetry?(ctx: unknown): unknown
45
- eventSink?(ctx: unknown): unknown
46
45
  setup?(ctx: unknown): void | Promise<void>
47
46
  teardown?(): void | Promise<void>
48
47
  }
@@ -57,7 +56,13 @@ export interface TaskConfig {
57
56
  * Optional one-line description of what this task does. Surfaced in
58
57
  * the interactive task picker and the `--dry` text view so users
59
58
  * can grep for what each task does without opening every config.
60
- * Pure metadata — no effect on caching, scheduling, or execution.
59
+ *
60
+ * No effect on scheduling or execution. It DOES participate in the
61
+ * cache key, because the key hashes the whole resolved task config
62
+ * (`docs/caching.md`, step 5) — deliberate: editing a description
63
+ * isn't a correctness change, but a one-off re-run costs little and
64
+ * carving exceptions out of the resolved object is what invites
65
+ * stale hits.
61
66
  */
62
67
  description?: string
63
68
  /**
@@ -211,6 +216,20 @@ export interface SandboxNetworkConfig {
211
216
  export interface ExecConfig {
212
217
  /** Shell command to run, from the project's directory. */
213
218
  command: string
219
+ /**
220
+ * `false` pins the task to this machine: it is never offered to a remote
221
+ * executor (it talks to a local daemon, the network, Docker, …). Default
222
+ * `true`. Tasks that are persistent, or depend on one, are pinned
223
+ * regardless — a worker cannot reach a port on the submitter.
224
+ *
225
+ * `'only'` is the inverse pin: the task exists to produce a REMOTE input
226
+ * tree (the canonical case is `pnpm install` feeding remote workers) and is
227
+ * a NO-OP on this machine — never executed locally, its declared outputs
228
+ * never cleaned or restored on this disk. With no remote executor declared
229
+ * the task succeeds without running and dependents use whatever ambient
230
+ * state the machine has, exactly as they did before the field existed.
231
+ */
232
+ remote?: boolean | 'only'
214
233
  /** Environment exposed to the child process. */
215
234
  env?: ExecEnv
216
235
  /**
@@ -271,19 +290,17 @@ export interface ExecConfig {
271
290
  * rejects unknown keys.
272
291
  */
273
292
  export interface ResourcesConfig {
293
+ /** CPU cores; fractional is allowed (`0.5`). */
294
+ cpus?: number
295
+ /** Megabytes. `memory: 4096` is four gigabytes. */
296
+ memory?: number
274
297
  /**
275
- * CPU units (fractional ok, e.g. `0.5`), or a `"<n>%"` string of the
276
- * CPU budget (the run's `concurrency`). `cpus: "50%"` on a budget of 8
277
- * reserves 4 units.
278
- */
279
- cpus?: number | string
280
- /**
281
- * Bytes, a size string (`"2GB"`, `"512MB"` — K/M/G/T, powers of 1024),
282
- * or a `"<n>%"` string of the memory budget (`os.totalmem()` unless
283
- * overridden with `--memory` — pass `--memory` in cgroup-limited
284
- * containers, where `os.totalmem()` reports the HOST's RAM).
298
+ * The container image this task needs, MATCHED against what a worker
299
+ * advertises — never a provisioning instruction, since a distributed
300
+ * executor's workers belong to whoever runs the fleet. Ignored by
301
+ * executors that do not run containers.
285
302
  */
286
- memory?: number | string
303
+ image?: string
287
304
  }
288
305
 
289
306
  export interface PersistentConfig {
package/src/exec/env.ts CHANGED
@@ -8,7 +8,13 @@ import path from 'node:path'
8
8
  // 3. define: explicit name=value pairs from the task config.
9
9
  // 4. binPaths prepended to PATH (after all the above resolve PATH).
10
10
 
11
- const ESSENTIAL_ENV: readonly string[] = [
11
+ /**
12
+ * Exported so the schema doc's enumeration of it can be PINNED against the
13
+ * real list: a hand-copied allowlist is how the two drift, and a reader
14
+ * asking "what does my build script actually see?" is asking a
15
+ * security-shaped question that a stale list answers wrongly.
16
+ */
17
+ export const ESSENTIAL_ENV: readonly string[] = [
12
18
  'PATH',
13
19
  'HOME',
14
20
  'SHELL',
@@ -0,0 +1,239 @@
1
+ // The per-task execution contract. `execute-task.ts` decides WHAT to run
2
+ // (command, env, sandbox baselines, capture) and hands a fully-resolved
3
+ // request here; an executor decides WHERE/HOW the process runs. Core ships
4
+ // no executor of its own: `@vzn/vx/plugins/local-executor` is the
5
+ // in-process spawn, declared in vx.workspace.ts like any other. Persistent
6
+ // tasks (`exec.persistent`) never reach an executor: they are local by
7
+ // construction (a worker cannot hand the submitter a listening port) and
8
+ // stay on `runPersistent`.
9
+ //
10
+ // Lives in `exec/` (not `orchestrator/`) so the contract depends only on
11
+ // process primitives — the module-boundary matrix forbids `exec` → `cache`,
12
+ // which is why sandbox baselines arrive pre-resolved on the request.
13
+
14
+ import type { CaptureConfig, RunResult } from './runner.js'
15
+ import type { ResolvedSandboxConfig, SandboxViolation } from './sandbox-runtime.js'
16
+
17
+ /** Sandbox baselines + the user's resolved sandbox block, when the task is sandboxed. */
18
+ export interface ExecuteSandbox {
19
+ readonly baseAllowRead: readonly string[]
20
+ readonly baseAllowWrite: readonly string[]
21
+ readonly baseDenyRead: readonly string[]
22
+ readonly config: ResolvedSandboxConfig
23
+ /**
24
+ * True when an EMPTY violation store on a CLEAN exit will be read as
25
+ * PROOF (`--verify=inputs` treats zero violations as `proven-complete`).
26
+ * On macOS the store is fed asynchronously from the unified log, so the
27
+ * runtime must pay the bounded settle window even for a 0-exit — a leaky
28
+ * task that swallows its own read error exits 0, and without the wait a
29
+ * merely-late violation record turns into a false pass of the verify.
30
+ */
31
+ readonly settleOnCleanExit: boolean
32
+ }
33
+
34
+ /** One declared input file, as the cache key saw it. */
35
+ export interface InputFile {
36
+ /** Workspace-relative POSIX path. */
37
+ readonly path: string
38
+ /** Git blob OID of the WORKTREE bytes (the same digest the key folds). */
39
+ readonly digest: string
40
+ }
41
+
42
+ /**
43
+ * Everything the cache key folds for one task, with values — the set a
44
+ * remote executor needs to reproduce the task somewhere else. Built on the
45
+ * miss path only, from the same resolution that produced the key, so it can
46
+ * never drift from what a hit would have matched. Held in memory for the
47
+ * attempt and never persisted: `env` values and `runtime` output may be
48
+ * secrets (cache.db stores only their digests).
49
+ */
50
+ export interface TaskInputs {
51
+ /** Declared `cache.inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
52
+ readonly files: readonly InputFile[]
53
+ /** Declared `cache.inputs.env` names with their resolved values. */
54
+ readonly env: ReadonlyArray<{ readonly name: string; readonly value: string }>
55
+ /** `cache.inputs.runtime` commands and the output that was folded — a toolchain expectation. */
56
+ readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
57
+ /** `cache.inputs.workspaceRuntime`, same shape. */
58
+ readonly workspaceRuntime: ReadonlyArray<{ readonly command: string; readonly output: string }>
59
+ /**
60
+ * Dependencies whose cache keys are folded. `outputs` lists each one's
61
+ * declared output files (workspace-relative POSIX, as stored with its cache
62
+ * entry) — restored on disk before this task runs, so an input-shipping
63
+ * executor can include them in the input root. Empty when the upstream's
64
+ * entry is not in the local index (a non-cacheable dependency).
65
+ */
66
+ readonly upstream: ReadonlyArray<{
67
+ readonly taskId: string
68
+ readonly hash: string
69
+ readonly outputs: readonly string[]
70
+ }>
71
+ /** Digest of the project's own `package.json` (folded even when no glob lists it). */
72
+ readonly packageJsonDigest: string
73
+ /** Digest of the resolved task config. */
74
+ readonly configDigest: string
75
+ /** The workspace fingerprint (root manifests + lockfile). */
76
+ readonly workspaceFingerprint: string
77
+ }
78
+
79
+ export interface ExecuteRequest {
80
+ /** `${project}#${task}` — for executors that route or log by task. */
81
+ readonly taskId: string
82
+ readonly workspaceRoot: string
83
+ /** Present for cacheable tasks (the miss path); absent when the task declares no `cache`. */
84
+ readonly inputs?: TaskInputs
85
+ /**
86
+ * The task's cache key (present when the task is cacheable). An executor
87
+ * that keeps its own remote record of executions — so a dependent's input
88
+ * tree can reference this task's outputs without the bytes ever landing on
89
+ * this machine — needs a stable address, and the key is that address.
90
+ */
91
+ readonly cacheKey?: string
92
+ /**
93
+ * Cache READS are disabled for this run (`--force` / `--no-cache`). An
94
+ * executor that keeps its OWN remote record of executions must not serve
95
+ * this request from it — the user asked for re-execution, and a private
96
+ * cache that ignores the flag is still a cache.
97
+ */
98
+ readonly refresh?: boolean
99
+ /**
100
+ * `exec.remote: 'only'`: the task produces a REMOTE input tree. The
101
+ * executor should not materialise outputs onto this machine's disk, and
102
+ * may satisfy the request from its own remote record of a previous
103
+ * execution under the same key.
104
+ */
105
+ readonly remoteOnly?: boolean
106
+ /**
107
+ * Whether this task's outputs must land on the submitter's disk now.
108
+ * 'eager' (or absent) = materialise as today. 'deferred' = leave them
109
+ * in the remote store and return `outputs: {kind:'deferred'}` with a
110
+ * closure core can call if a local consumer turns out to need them.
111
+ * A local executor ignores this — it writes in place by construction.
112
+ */
113
+ readonly download?: 'eager' | 'deferred'
114
+ /** The declared output globs — project-relative `files`, root-relative `workspaceFiles`. */
115
+ readonly outputs: {
116
+ readonly files: readonly string[]
117
+ readonly workspaceFiles: readonly string[]
118
+ }
119
+ readonly command: string
120
+ /** Appended to `command`, shell-quoted, by the executor (forwarded CLI args). */
121
+ readonly forwardArgs: readonly string[]
122
+ readonly cwd: string
123
+ readonly env: NodeJS.ProcessEnv
124
+ /**
125
+ * `exec.env.define` verbatim — literal name=value pairs from the task
126
+ * config. `env` above is the fully RESOLVED child environment and is
127
+ * host-specific (it carries this machine's PATH, HOME, TMPDIR…), so an
128
+ * executor that ships the environment somewhere else cannot use it: those
129
+ * values would enter the action identity and split every machine from
130
+ * every other. These are declared in the config, so they are the same
131
+ * everywhere and safe to forward.
132
+ */
133
+ readonly envDefine: Readonly<Record<string, string>>
134
+ readonly capture: CaptureConfig
135
+ readonly timeoutMs?: number
136
+ readonly onStdout: (chunk: string) => void
137
+ readonly onStderr: (chunk: string) => void
138
+ /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
139
+ readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
140
+ readonly sandbox?: ExecuteSandbox
141
+ }
142
+
143
+ export interface ExecuteResult extends RunResult {
144
+ /** Sandbox violations (empty when unsandboxed). */
145
+ readonly violations: readonly SandboxViolation[]
146
+ /**
147
+ * Where this task's outputs are. Absent = `{kind:'disk'}` — in place
148
+ * under `cwd`, which is what every executor did before deferral
149
+ * existed. `deferred` means they are NOT on disk and `materialize()`
150
+ * fetches them; core calls it lazily, at most once, and then saves an
151
+ * ordinary cache entry, so a deferred task leaves no permanent third
152
+ * state behind.
153
+ */
154
+ readonly outputs?: { kind: 'disk' } | { kind: 'deferred'; materialize: () => Promise<void> }
155
+ /**
156
+ * Executor-reported placement label — which machine ran the command
157
+ * (a REAPI worker id, a pool member). Absent = this host. Rides
158
+ * `TaskOutcome.where` into telemetry so a task can be attributed to
159
+ * its worker; never persisted to the analytics store.
160
+ */
161
+ readonly where?: string
162
+ }
163
+
164
+ /** What an executor sees when a task is PLACED — once per task, before scheduling. */
165
+ export interface TaskPlacement {
166
+ readonly taskId: string
167
+ readonly projectName: string
168
+ readonly projectDir: string
169
+ readonly command: string
170
+ /**
171
+ * Must run on this machine: the task is persistent, depends (transitively)
172
+ * on a persistent task, or declares `exec.remote: false`. A `remote`
173
+ * executor is never offered such a task.
174
+ */
175
+ readonly pinnedLocal: boolean
176
+ /** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
177
+ readonly cacheable: boolean
178
+ /**
179
+ * `exec.resources` VERBATIM — CPU cores, megabytes, and the image a
180
+ * worker must be running for this task to be routed to it. All three are
181
+ * requirements MATCHED against what an executor has, never instructions
182
+ * to build a machine: a distributed executor's workers belong to whoever
183
+ * runs the fleet.
184
+ *
185
+ * Placement is where this belongs: `exec.resources` is stripped from the
186
+ * cache key precisely because it decides WHERE a task fits, never what
187
+ * it produces.
188
+ */
189
+ readonly resources?: Readonly<{ cpus?: number; memory?: number; image?: string }>
190
+ }
191
+
192
+ export interface TaskExecutor {
193
+ /** Shown in errors; `'local'` for core's own. */
194
+ readonly name: string
195
+ /** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
196
+ readonly remote?: boolean
197
+ /**
198
+ * How many tasks this executor runs at once. Its tasks then occupy a pool
199
+ * of this size instead of the local worker slots, so a remote pool is not
200
+ * throttled by the local CPU count. Absent = the local pool.
201
+ */
202
+ readonly capacity?: number
203
+ /** Per-task opt-out at placement time. Absent = accepts every task it is offered. */
204
+ accepts?(task: TaskPlacement): boolean
205
+ /**
206
+ * The tasks placed here that have NOT finished yet — called once after
207
+ * placement with the whole set, then again after each completion (any
208
+ * outcome: a cache hit and a failure both remove one).
209
+ *
210
+ * An executor that provisions something per task — a container, an
211
+ * allocation, a pod — otherwise has to guess. `capacity` says how much it
212
+ * MAY run at once; this says how much is actually left, so it can size
213
+ * what it creates to the work that remains and give capacity back the
214
+ * moment the run can no longer use it, rather than holding it until
215
+ * teardown. Absent = the executor does not care and nothing is computed
216
+ * for it.
217
+ */
218
+ demand?(remaining: ReadonlySet<string>): void
219
+ execute(req: ExecuteRequest): Promise<ExecuteResult>
220
+ }
221
+
222
+ /**
223
+ * Place one task: the first executor, in declaration order, that may take
224
+ * it — a `remote` executor is skipped for a `pinnedLocal` task, then
225
+ * `accepts()` decides. Decided once per task before scheduling. The local
226
+ * executor accepts everything, so with it declared this cannot throw.
227
+ */
228
+ export function selectExecutor(
229
+ executors: readonly TaskExecutor[],
230
+ task: TaskPlacement,
231
+ ): TaskExecutor {
232
+ for (const executor of executors) {
233
+ if (task.pinnedLocal && executor.remote === true) continue
234
+ if (executor.accepts === undefined || executor.accepts(task)) return executor
235
+ }
236
+ throw new Error(
237
+ `no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Declare localExecutorPlugin() after the executor that declined to run such tasks locally.`,
238
+ )
239
+ }
package/src/exec/index.ts CHANGED
@@ -2,12 +2,30 @@
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
4
  export { buildIsolatedEnv } from './env.js'
5
- export { runCommand, runPersistent, signalExitCode } from './runner.js'
5
+ export {
6
+ runCommand,
7
+ runPersistent,
8
+ shellQuote,
9
+ signalExitCode,
10
+ type CaptureConfig,
11
+ } from './runner.js'
6
12
  export {
7
13
  initSandbox,
8
14
  probeSandbox,
9
15
  resetSandbox,
10
16
  resolveSandboxConfig,
11
17
  runSandboxed,
18
+ type DeniedCall,
19
+ type ResolvedSandboxConfig,
12
20
  type SandboxViolation,
13
21
  } from './sandbox-runtime.js'
22
+ export {
23
+ selectExecutor,
24
+ type ExecuteRequest,
25
+ type ExecuteResult,
26
+ type ExecuteSandbox,
27
+ type InputFile,
28
+ type TaskExecutor,
29
+ type TaskInputs,
30
+ type TaskPlacement,
31
+ } from './executor.js'
@@ -25,6 +25,20 @@ export interface RunResult {
25
25
  peakRssBytes?: number
26
26
  }
27
27
 
28
+ /**
29
+ * Which streams `runCommand` retains into `RunResult.stdout` / `.stderr`.
30
+ * Both default to true — the primitive's contract is that those fields hold
31
+ * what the child wrote, and a field that silently lies is worse than one
32
+ * that costs. A caller that will not READ a stream opts down here: the live
33
+ * `onStdout` / `onStderr` callbacks still fire for every chunk, so only the
34
+ * retained string is dropped. Retaining a stream nothing reads costs its
35
+ * full byte size in heap, for the whole task.
36
+ */
37
+ export interface CaptureConfig {
38
+ stdout?: boolean
39
+ stderr?: boolean
40
+ }
41
+
28
42
  export interface RunOptions {
29
43
  command: string
30
44
  cwd: string
@@ -34,6 +48,8 @@ export interface RunOptions {
34
48
  /** Called for each chunk of stdout/stderr as it arrives, for live output. */
35
49
  onStdout?: (chunk: string) => void
36
50
  onStderr?: (chunk: string) => void
51
+ /** See `CaptureConfig`. Omitted → both streams retained (today's behaviour). */
52
+ capture?: CaptureConfig
37
53
  /**
38
54
  * Upper bound (ms) on the child's run time. When it elapses the
39
55
  * child is SIGTERMed and the result is flagged `timedOut`. Undefined
@@ -144,19 +160,79 @@ export function signalExitCode(signal: string): number {
144
160
  * on its own. A no-op (never fires, nothing to clear) when `timeoutMs`
145
161
  * is undefined.
146
162
  */
163
+ /**
164
+ * Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
165
+ * ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
166
+ */
167
+ const TIMEOUT_SIGKILL_GRACE_MS = 2000
168
+
147
169
  export function armTimeout(
148
170
  proc: ReturnType<typeof Bun.spawn>,
149
171
  timeoutMs: number | undefined,
150
172
  ): { timedOut: () => boolean; clear: () => void } {
151
173
  if (timeoutMs === undefined) return { timedOut: () => false, clear: () => {} }
152
174
  let fired = false
175
+ let killTimer: ReturnType<typeof setTimeout> | undefined
153
176
  const timer = setTimeout(() => {
154
177
  fired = true
155
178
  proc.kill('SIGTERM')
179
+ // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
180
+ // (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
181
+ // `await proc.exited` until its natural exit — there is no run-level
182
+ // timeout, so a wedged child hangs the whole run forever. Mirrors the
183
+ // end-of-run persistent-shutdown escalation. Unref'd so it never keeps
184
+ // the CLI alive.
185
+ killTimer = setTimeout(() => proc.kill('SIGKILL'), TIMEOUT_SIGKILL_GRACE_MS)
186
+ killTimer.unref?.()
156
187
  }, timeoutMs)
157
- return { timedOut: () => fired, clear: () => clearTimeout(timer) }
188
+ return {
189
+ timedOut: () => fired,
190
+ clear: () => {
191
+ clearTimeout(timer)
192
+ if (killTimer !== undefined) clearTimeout(killTimer)
193
+ },
194
+ }
158
195
  }
159
196
 
197
+ /**
198
+ * Grace after the direct child exits before we abort the stdout/stderr readers.
199
+ * The pipe reaches EOF only when EVERY write-end fd is closed, so a task that
200
+ * backgrounds a process inheriting fd 1/2 (`server & echo up` — `execWrap`
201
+ * leaves compound commands as `sh -c`, so `sh` exits while the grandchild holds
202
+ * the pipe) never EOFs. On a clean exit with no lingering writer the pipe EOFs
203
+ * at once, so the readers win this race immediately and normal tasks pay
204
+ * nothing; only a stuck reader waits out the grace. Residual buffered output is
205
+ * bounded by the OS pipe buffer (streamToString drains continuously DURING the
206
+ * run), so this is far longer than any real drain needs.
207
+ */
208
+ const POST_EXIT_DRAIN_MS = 250
209
+
210
+ /**
211
+ * After the direct child has exited, wait for the stdout/stderr readers to
212
+ * reach EOF — but bound it: an orphaned grandchild holding the pipe open would
213
+ * hang the run forever (there is no default task timeout). If the grace expires
214
+ * first, abort the reader signal so `streamToString` cancels its read and
215
+ * returns whatever it captured. The timer is cleared AND unref'd so it can
216
+ * never keep the CLI alive after the readers settle (the plugin-flush lesson).
217
+ */
218
+ export async function drainOrAbort(streams: Promise<unknown>, ac: AbortController): Promise<void> {
219
+ let timer: ReturnType<typeof setTimeout> | undefined
220
+ const deadline = new Promise<'timeout'>((resolve) => {
221
+ timer = setTimeout(() => resolve('timeout'), POST_EXIT_DRAIN_MS)
222
+ timer.unref?.()
223
+ })
224
+ const winner = await Promise.race([streams.then(() => 'drained' as const), deadline])
225
+ if (timer !== undefined) clearTimeout(timer)
226
+ if (winner === 'timeout') ac.abort()
227
+ }
228
+
229
+ /**
230
+ * Upper bound on the text a `readyWhen` regex is tested against. The matcher
231
+ * normally keeps only the trailing partial line, so this bites only when the
232
+ * stream has no line breaks at all.
233
+ */
234
+ const READY_MATCH_WINDOW_CHARS = 64 * 1024
235
+
160
236
  export interface PersistentSpawn {
161
237
  /** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
162
238
  child: ReturnType<typeof Bun.spawn>
@@ -167,14 +243,15 @@ export interface PersistentSpawn {
167
243
  * Rejects with the spawn error if the child fails to start.
168
244
  */
169
245
  ready: Promise<void>
170
- /** Captured stdout/stderr up to the moment ready resolved. */
171
- bufferedStdout: () => string
172
- bufferedStderr: () => string
173
246
  /** ms elapsed from spawn to ready (or to current time if not yet ready). */
174
247
  readyMs: () => number
175
248
  }
176
249
 
177
- export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> {
250
+ // `capture` is a `runCommand` concept: a persistent task never returns a
251
+ // RunResult (it is handed back at READY, still running), so there is no
252
+ // retained string to opt out of. Its output reaches the caller only through
253
+ // the live callbacks.
254
+ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'capture'> {
178
255
  /**
179
256
  * String regex. The first stdout/stderr line that matches signals
180
257
  * "ready". Undefined → ready immediately on spawn.
@@ -198,8 +275,6 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> {
198
275
  */
199
276
  export function runPersistent(opts: PersistentOptions): PersistentSpawn {
200
277
  const start = Date.now()
201
- let bufferedStdout = ''
202
- let bufferedStderr = ''
203
278
  let readyAt: number | undefined
204
279
 
205
280
  // Pattern compiled once; thrown errors surface synchronously so the
@@ -220,8 +295,6 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
220
295
  return {
221
296
  child: undefined as unknown as ReturnType<typeof Bun.spawn>,
222
297
  ready: Promise.reject(new Error(`failed to spawn persistent task: ${message}`)),
223
- bufferedStdout: () => '',
224
- bufferedStderr: () => `\n[vx] failed to spawn: ${message}\n`,
225
298
  readyMs: () => Date.now() - start,
226
299
  }
227
300
  }
@@ -255,16 +328,13 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
255
328
  let fragment = ''
256
329
  const handleChunk = (chunk: string): void => {
257
330
  if (chunk.length === 0) return
258
- // Buffers capture "up to the moment ready resolved" (their
259
- // documented contract) — a dev server kept alive for hours must
260
- // not accrete its whole log history into vx's heap.
261
- if (isStderr) {
262
- if (readyAt === undefined) bufferedStderr += chunk
263
- opts.onStderr?.(chunk)
264
- } else {
265
- if (readyAt === undefined) bufferedStdout += chunk
266
- opts.onStdout?.(chunk)
267
- }
331
+ // Chunks are forwarded, never retained. The one consumer of a
332
+ // persistent task's text is the logger, which keeps its OWN bounded
333
+ // tail fed from these same callbacks (registered at taskStart, so it
334
+ // covers the pre-ready window too) — a second copy here was read by
335
+ // nobody.
336
+ if (isStderr) opts.onStderr?.(chunk)
337
+ else opts.onStdout?.(chunk)
268
338
  if (readyRe && readyAt === undefined) {
269
339
  fragment += chunk
270
340
  if (readyRe.test(fragment)) {
@@ -273,6 +343,17 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
273
343
  } else {
274
344
  const lastNl = fragment.lastIndexOf('\n')
275
345
  if (lastNl >= 0) fragment = fragment.slice(lastNl + 1)
346
+ // Dropping complete lines assumes the stream HAS line breaks.
347
+ // `\r`-only output — a progress bar, a spinner — is one endless
348
+ // line, so the trim above never fires and the fragment becomes a
349
+ // third unbounded accumulator (measured 464 MiB in 6 s, and the
350
+ // per-chunk `test` over an ever-growing string makes it quadratic
351
+ // in CPU too). Keep the most RECENT window: a match may span the
352
+ // chunk boundary, and no readiness marker is anywhere near this
353
+ // long.
354
+ if (fragment.length > READY_MATCH_WINDOW_CHARS) {
355
+ fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
356
+ }
276
357
  }
277
358
  }
278
359
  }
@@ -340,8 +421,6 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
340
421
  return {
341
422
  child,
342
423
  ready,
343
- bufferedStdout: () => bufferedStdout,
344
- bufferedStderr: () => bufferedStderr,
345
424
  readyMs: () => (readyAt ?? Date.now()) - start,
346
425
  }
347
426
  }
@@ -376,15 +455,19 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
376
455
  const timeout = armTimeout(proc, opts.timeoutMs)
377
456
  const ac = new AbortController()
378
457
  const streams = Promise.all([
379
- streamToString(proc.stdout, opts.onStdout, ac.signal),
380
- streamToString(proc.stderr, opts.onStderr, ac.signal),
458
+ streamToString(proc.stdout, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
459
+ streamToString(proc.stderr, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
381
460
  ])
382
- // Gate on the child's own exit, not on stream EOF: a timeout SIGTERM
383
- // kills `sh` but an orphaned grandchild can keep the pipe open, so we
384
- // abort the readers once the child is gone rather than wait forever.
461
+ // Gate on the child's own exit, not on stream EOF: an orphaned grandchild
462
+ // can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
463
+ // grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
464
+ // never arrive. A timeout aborts the readers at once; otherwise drainOrAbort
465
+ // lets a clean exit EOF immediately and only cuts off a stuck reader after a
466
+ // brief grace — without this the run hangs forever.
385
467
  await proc.exited
386
468
  timeout.clear()
387
469
  if (timeout.timedOut()) ac.abort()
470
+ else await drainOrAbort(streams, ac)
388
471
  const [stdout, stderr] = await streams
389
472
  opts.liveChildren?.delete(proc)
390
473
  const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
@@ -401,12 +484,16 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
401
484
 
402
485
  /**
403
486
  * Drain a `Bun.spawn` stdout/stderr stream while invoking the live
404
- * callback per UTF-8 chunk. Returns the full accumulated string.
487
+ * callback per UTF-8 chunk. Returns the accumulated string, or `''` when
488
+ * `retain` is false — the stream is still fully drained and every chunk
489
+ * still reaches `onChunk`; only the retained copy is dropped, so a caller
490
+ * that will not read it does not pay its byte size in heap.
405
491
  */
406
492
  export async function streamToString(
407
493
  stream: ReadableStream<Uint8Array> | number | undefined,
408
494
  onChunk?: (s: string) => void,
409
495
  signal?: AbortSignal,
496
+ retain = true,
410
497
  ): Promise<string> {
411
498
  // Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`
412
499
  // — the `number` is for inheritance modes, only present when the caller
@@ -430,12 +517,12 @@ export async function streamToString(
430
517
  const { value, done } = await reader.read()
431
518
  if (done) break
432
519
  const chunk = decoder.decode(value, { stream: true })
433
- full += chunk
520
+ if (retain) full += chunk
434
521
  onChunk?.(chunk)
435
522
  }
436
523
  const tail = decoder.decode()
437
524
  if (tail.length > 0) {
438
- full += tail
525
+ if (retain) full += tail
439
526
  onChunk?.(tail)
440
527
  }
441
528
  } finally {