@vzn/vx 0.0.16 → 0.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,224 @@
|
|
|
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's own
|
|
4
|
+
// `local-executor.ts` is the in-process spawn, and it sits at the TAIL of
|
|
5
|
+
// every executor list — what a plugin executor declines runs here.
|
|
6
|
+
// Persistent 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
|
+
/**
|
|
23
|
+
* Only denials on a path under this directory are reported. A denial
|
|
24
|
+
* outside it is still ENFORCED — the task cannot leave its project — but
|
|
25
|
+
* that is the wall doing its job, not a finding to fail a run over.
|
|
26
|
+
*/
|
|
27
|
+
readonly reportWithin: string
|
|
28
|
+
readonly config: ResolvedSandboxConfig
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One declared input file, as the cache key saw it. */
|
|
32
|
+
export interface InputFile {
|
|
33
|
+
/** Workspace-relative POSIX path. */
|
|
34
|
+
readonly path: string
|
|
35
|
+
/** Git blob OID of the WORKTREE bytes — for a symlink, of its target string — the same digest the key folds. */
|
|
36
|
+
readonly digest: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Everything the cache key folds for one task, with values — the set a
|
|
41
|
+
* remote executor needs to reproduce the task somewhere else. Built on the
|
|
42
|
+
* miss path only, from the same resolution that produced the key, so it can
|
|
43
|
+
* never drift from what a hit would have matched. Held in memory for the
|
|
44
|
+
* attempt and never persisted: `env` values and `runtime` output may be
|
|
45
|
+
* secrets (cache.db stores only their digests).
|
|
46
|
+
*/
|
|
47
|
+
export interface TaskInputs {
|
|
48
|
+
/** Declared `cache.inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
|
|
49
|
+
readonly files: readonly InputFile[]
|
|
50
|
+
/** Declared `cache.inputs.env` names with their resolved values. */
|
|
51
|
+
readonly env: ReadonlyArray<{ readonly name: string; readonly value: string }>
|
|
52
|
+
/** `cache.inputs.runtime` commands and the output that was folded — a toolchain expectation. */
|
|
53
|
+
readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
|
|
54
|
+
/** `cache.inputs.workspaceRuntime`, same shape. */
|
|
55
|
+
readonly workspaceRuntime: ReadonlyArray<{ readonly command: string; readonly output: string }>
|
|
56
|
+
/**
|
|
57
|
+
* Dependencies whose cache keys are folded. `outputs` lists each one's
|
|
58
|
+
* declared output files (workspace-relative POSIX, as stored with its cache
|
|
59
|
+
* entry) — restored on disk before this task runs, so an input-shipping
|
|
60
|
+
* executor can include them in the input root. Empty when the upstream's
|
|
61
|
+
* entry is not in the local index (a non-cacheable dependency).
|
|
62
|
+
*/
|
|
63
|
+
readonly upstream: ReadonlyArray<{
|
|
64
|
+
readonly taskId: string
|
|
65
|
+
readonly hash: string
|
|
66
|
+
readonly outputs: readonly string[]
|
|
67
|
+
}>
|
|
68
|
+
/** Digest of the project's own `package.json` (folded even when no glob lists it). */
|
|
69
|
+
readonly packageJsonDigest: string
|
|
70
|
+
/** Digest of the resolved task config. */
|
|
71
|
+
readonly configDigest: string
|
|
72
|
+
/** The workspace fingerprint (root manifests + lockfile). */
|
|
73
|
+
readonly workspaceFingerprint: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ExecuteRequest {
|
|
77
|
+
/** `${project}#${task}` — for executors that route or log by task. */
|
|
78
|
+
readonly taskId: string
|
|
79
|
+
readonly workspaceRoot: string
|
|
80
|
+
/** Present for cacheable tasks (the miss path); absent when the task declares no `cache`. */
|
|
81
|
+
readonly inputs?: TaskInputs
|
|
82
|
+
/**
|
|
83
|
+
* The task's cache key (present when the task is cacheable). An executor
|
|
84
|
+
* that keeps its own remote record of executions — so a dependent's input
|
|
85
|
+
* tree can reference this task's outputs without the bytes ever landing on
|
|
86
|
+
* this machine — needs a stable address, and the key is that address.
|
|
87
|
+
*/
|
|
88
|
+
readonly cacheKey?: string
|
|
89
|
+
/**
|
|
90
|
+
* Cache READS are disabled for this run (`--force` / `--no-cache`). An
|
|
91
|
+
* executor that keeps its OWN remote record of executions must not serve
|
|
92
|
+
* this request from it — the user asked for re-execution, and a private
|
|
93
|
+
* cache that ignores the flag is still a cache.
|
|
94
|
+
*/
|
|
95
|
+
readonly refresh?: boolean
|
|
96
|
+
/**
|
|
97
|
+
* `exec.remote: 'only'`: the task produces a REMOTE input tree. The
|
|
98
|
+
* executor should not materialise outputs onto this machine's disk, and
|
|
99
|
+
* may satisfy the request from its own remote record of a previous
|
|
100
|
+
* execution under the same key.
|
|
101
|
+
*/
|
|
102
|
+
readonly remoteOnly?: boolean
|
|
103
|
+
/**
|
|
104
|
+
* Whether this task's outputs must land on the submitter's disk now.
|
|
105
|
+
* 'eager' (or absent) = materialise as today. 'deferred' = leave them
|
|
106
|
+
* in the remote store and return `outputs: {kind:'deferred'}` with a
|
|
107
|
+
* closure core can call if a local consumer turns out to need them.
|
|
108
|
+
* A local executor ignores this — it writes in place by construction.
|
|
109
|
+
*/
|
|
110
|
+
readonly download?: 'eager' | 'deferred'
|
|
111
|
+
/** The declared output globs — project-relative `files`, root-relative `workspaceFiles`. */
|
|
112
|
+
readonly outputs: {
|
|
113
|
+
readonly files: readonly string[]
|
|
114
|
+
readonly workspaceFiles: readonly string[]
|
|
115
|
+
}
|
|
116
|
+
readonly command: string
|
|
117
|
+
/** Appended to `command`, shell-quoted, by the executor (forwarded CLI args). */
|
|
118
|
+
readonly forwardArgs: readonly string[]
|
|
119
|
+
readonly cwd: string
|
|
120
|
+
readonly env: NodeJS.ProcessEnv
|
|
121
|
+
/**
|
|
122
|
+
* `exec.env.define` verbatim — literal name=value pairs from the task
|
|
123
|
+
* config. `env` above is the fully RESOLVED child environment and is
|
|
124
|
+
* host-specific (it carries this machine's PATH, HOME, TMPDIR…), so an
|
|
125
|
+
* executor that ships the environment somewhere else cannot use it: those
|
|
126
|
+
* values would enter the action identity and split every machine from
|
|
127
|
+
* every other. These are declared in the config, so they are the same
|
|
128
|
+
* everywhere and safe to forward.
|
|
129
|
+
*/
|
|
130
|
+
readonly envDefine: Readonly<Record<string, string>>
|
|
131
|
+
readonly capture: CaptureConfig
|
|
132
|
+
readonly timeoutMs?: number
|
|
133
|
+
readonly onStdout: (chunk: string) => void
|
|
134
|
+
readonly onStderr: (chunk: string) => void
|
|
135
|
+
/** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
|
|
136
|
+
readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
137
|
+
readonly sandbox?: ExecuteSandbox
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface ExecuteResult extends RunResult {
|
|
141
|
+
/** Sandbox violations (empty when unsandboxed). */
|
|
142
|
+
readonly violations: readonly SandboxViolation[]
|
|
143
|
+
/**
|
|
144
|
+
* Where this task's outputs are. Absent = `{kind:'disk'}` — in place
|
|
145
|
+
* under `cwd`, which is what every executor did before deferral
|
|
146
|
+
* existed. `deferred` means they are NOT on disk and `materialize()`
|
|
147
|
+
* fetches them; core calls it lazily, at most once, and then saves an
|
|
148
|
+
* ordinary cache entry, so a deferred task leaves no permanent third
|
|
149
|
+
* state behind.
|
|
150
|
+
*/
|
|
151
|
+
readonly outputs?: { kind: 'disk' } | { kind: 'deferred'; materialize: () => Promise<void> }
|
|
152
|
+
/**
|
|
153
|
+
* Executor-reported placement label — which machine ran the command
|
|
154
|
+
* (a REAPI worker id, a pool member). Absent = this host. Rides
|
|
155
|
+
* `TaskOutcome.where` into telemetry so a task can be attributed to
|
|
156
|
+
* its worker; never persisted to the analytics store.
|
|
157
|
+
*/
|
|
158
|
+
readonly where?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What an executor sees when a task is PLACED — once per task, before scheduling. */
|
|
162
|
+
export interface TaskPlacement {
|
|
163
|
+
readonly taskId: string
|
|
164
|
+
readonly projectName: string
|
|
165
|
+
readonly projectDir: string
|
|
166
|
+
readonly command: string
|
|
167
|
+
/**
|
|
168
|
+
* Must run on this machine: the task is persistent, depends (transitively)
|
|
169
|
+
* on a persistent task, or declares `exec.remote: false`. A `remote`
|
|
170
|
+
* executor is never offered such a task.
|
|
171
|
+
*/
|
|
172
|
+
readonly pinnedLocal: boolean
|
|
173
|
+
/** Declares `cache` — the only tasks whose input set is described, and so the only ones that can ship. */
|
|
174
|
+
readonly cacheable: boolean
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface TaskExecutor {
|
|
178
|
+
/** Shown in errors; `'local'` for core's own. */
|
|
179
|
+
readonly name: string
|
|
180
|
+
/** Runs the command somewhere else. Never offered a `pinnedLocal` task. */
|
|
181
|
+
readonly remote?: boolean
|
|
182
|
+
/**
|
|
183
|
+
* How many tasks this executor runs at once. Its tasks then occupy a pool
|
|
184
|
+
* of this size instead of the local worker slots, so a remote pool is not
|
|
185
|
+
* throttled by the local CPU count. Absent = the local pool.
|
|
186
|
+
*/
|
|
187
|
+
readonly capacity?: number
|
|
188
|
+
/** Per-task opt-out at placement time. Absent = accepts every task it is offered. */
|
|
189
|
+
accepts?(task: TaskPlacement): boolean
|
|
190
|
+
/**
|
|
191
|
+
* The tasks placed here that have NOT finished yet — called once after
|
|
192
|
+
* placement with the whole set, then again after each completion (any
|
|
193
|
+
* outcome: a cache hit and a failure both remove one).
|
|
194
|
+
*
|
|
195
|
+
* An executor that provisions something per task — a container, an
|
|
196
|
+
* allocation, a pod — otherwise has to guess. `capacity` says how much it
|
|
197
|
+
* MAY run at once; this says how much is actually left, so it can size
|
|
198
|
+
* what it creates to the work that remains and give capacity back the
|
|
199
|
+
* moment the run can no longer use it, rather than holding it until
|
|
200
|
+
* teardown. Absent = the executor does not care and nothing is computed
|
|
201
|
+
* for it.
|
|
202
|
+
*/
|
|
203
|
+
demand?(remaining: ReadonlySet<string>): void
|
|
204
|
+
execute(req: ExecuteRequest): Promise<ExecuteResult>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Place one task: the first executor, in declaration order, that may take
|
|
209
|
+
* it — a `remote` executor is skipped for a `pinnedLocal` task, then
|
|
210
|
+
* `accepts()` decides. Decided once per task before scheduling. The local
|
|
211
|
+
* executor accepts everything, so with it declared this cannot throw.
|
|
212
|
+
*/
|
|
213
|
+
export function selectExecutor(
|
|
214
|
+
executors: readonly TaskExecutor[],
|
|
215
|
+
task: TaskPlacement,
|
|
216
|
+
): TaskExecutor {
|
|
217
|
+
for (const executor of executors) {
|
|
218
|
+
if (task.pinnedLocal && executor.remote === true) continue
|
|
219
|
+
if (executor.accepts === undefined || executor.accepts(task)) return executor
|
|
220
|
+
}
|
|
221
|
+
throw new Error(
|
|
222
|
+
`no executor accepted ${task.taskId} (declared: ${executors.map((e) => e.name).join(', ')}). Core's local executor accepts every task, so this means it is missing from the list.`,
|
|
223
|
+
)
|
|
224
|
+
}
|
package/src/exec/index.ts
CHANGED
|
@@ -1,13 +1,35 @@
|
|
|
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 { buildIsolatedEnv } from './env.js'
|
|
5
|
-
export {
|
|
4
|
+
export { buildIsolatedEnv, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from './env.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,
|
|
15
|
+
unavailableReason,
|
|
9
16
|
resetSandbox,
|
|
10
17
|
resolveSandboxConfig,
|
|
11
18
|
runSandboxed,
|
|
19
|
+
releaseBridges,
|
|
20
|
+
wrapSandboxedCommand,
|
|
21
|
+
type ResolvedSandboxConfig,
|
|
12
22
|
type SandboxViolation,
|
|
13
23
|
} from './sandbox-runtime.js'
|
|
24
|
+
export { type DeniedCall } from './sandbox-violations.js'
|
|
25
|
+
export { localExecutor } from './local-executor.js'
|
|
26
|
+
export {
|
|
27
|
+
selectExecutor,
|
|
28
|
+
type ExecuteRequest,
|
|
29
|
+
type ExecuteResult,
|
|
30
|
+
type ExecuteSandbox,
|
|
31
|
+
type InputFile,
|
|
32
|
+
type TaskExecutor,
|
|
33
|
+
type TaskInputs,
|
|
34
|
+
type TaskPlacement,
|
|
35
|
+
} from './executor.js'
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Core's own local executor: spawn the task's command here, in this
|
|
2
|
+
// process. Not a plugin — "run it on this machine" is not a capability
|
|
3
|
+
// someone else supplies differently, it is what running MEANS when no
|
|
4
|
+
// plugin claims the task, so it sits at the TAIL of every executor list
|
|
5
|
+
// (owner, 2026-09-05). An executor plugin places work ELSEWHERE; its
|
|
6
|
+
// absence is "here".
|
|
7
|
+
import { runCommand } from './runner.js'
|
|
8
|
+
import { runSandboxed } from './sandbox-runtime.js'
|
|
9
|
+
import type { TaskExecutor } from './executor.js'
|
|
10
|
+
|
|
11
|
+
/** The local executor. Accepts every task. */
|
|
12
|
+
export function localExecutor(): TaskExecutor {
|
|
13
|
+
return {
|
|
14
|
+
name: 'local',
|
|
15
|
+
async execute(req) {
|
|
16
|
+
const common = {
|
|
17
|
+
command: req.command,
|
|
18
|
+
cwd: req.cwd,
|
|
19
|
+
env: req.env,
|
|
20
|
+
forwardArgs: req.forwardArgs,
|
|
21
|
+
onStdout: req.onStdout,
|
|
22
|
+
onStderr: req.onStderr,
|
|
23
|
+
capture: req.capture,
|
|
24
|
+
...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
|
|
25
|
+
...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
|
|
26
|
+
}
|
|
27
|
+
if (req.sandbox === undefined) {
|
|
28
|
+
const res = await runCommand(common)
|
|
29
|
+
return { ...res, violations: [] }
|
|
30
|
+
}
|
|
31
|
+
return runSandboxed({
|
|
32
|
+
...common,
|
|
33
|
+
baseAllowRead: req.sandbox.baseAllowRead,
|
|
34
|
+
baseAllowWrite: req.sandbox.baseAllowWrite,
|
|
35
|
+
baseDenyRead: req.sandbox.baseDenyRead,
|
|
36
|
+
reportWithin: req.sandbox.reportWithin,
|
|
37
|
+
config: req.sandbox.config,
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
|
|
5
5
|
// folded into the v11 `runs` table by the orchestrator.
|
|
6
6
|
|
|
7
|
+
import { readFileSync } from 'node:fs'
|
|
7
8
|
import { constants as osConstants } from 'node:os'
|
|
9
|
+
import { killGraceMs } from '../util/index.js'
|
|
8
10
|
|
|
9
11
|
export interface RunResult {
|
|
10
12
|
exitCode: number
|
|
@@ -25,6 +27,20 @@ export interface RunResult {
|
|
|
25
27
|
peakRssBytes?: number
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Which streams `runCommand` retains into `RunResult.stdout` / `.stderr`.
|
|
32
|
+
* Both default to true — the primitive's contract is that those fields hold
|
|
33
|
+
* what the child wrote, and a field that silently lies is worse than one
|
|
34
|
+
* that costs. A caller that will not READ a stream opts down here: the live
|
|
35
|
+
* `onStdout` / `onStderr` callbacks still fire for every chunk, so only the
|
|
36
|
+
* retained string is dropped. Retaining a stream nothing reads costs its
|
|
37
|
+
* full byte size in heap, for the whole task.
|
|
38
|
+
*/
|
|
39
|
+
export interface CaptureConfig {
|
|
40
|
+
stdout?: boolean
|
|
41
|
+
stderr?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
export interface RunOptions {
|
|
29
45
|
command: string
|
|
30
46
|
cwd: string
|
|
@@ -34,6 +50,8 @@ export interface RunOptions {
|
|
|
34
50
|
/** Called for each chunk of stdout/stderr as it arrives, for live output. */
|
|
35
51
|
onStdout?: (chunk: string) => void
|
|
36
52
|
onStderr?: (chunk: string) => void
|
|
53
|
+
/** See `CaptureConfig`. Omitted → both streams retained (today's behaviour). */
|
|
54
|
+
capture?: CaptureConfig
|
|
37
55
|
/**
|
|
38
56
|
* Upper bound (ms) on the child's run time. When it elapses the
|
|
39
57
|
* child is SIGTERMed and the result is flagged `timedOut`. Undefined
|
|
@@ -136,6 +154,12 @@ export function signalExitCode(signal: string): number {
|
|
|
136
154
|
return num === undefined ? 130 : 128 + num
|
|
137
155
|
}
|
|
138
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
|
|
159
|
+
* ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
|
|
160
|
+
*/
|
|
161
|
+
const TIMEOUT_SIGKILL_GRACE_MS = 2000
|
|
162
|
+
|
|
139
163
|
/**
|
|
140
164
|
* Arm a SIGTERM timeout on a spawned child. Returns a handle whose
|
|
141
165
|
* `timedOut()` reports whether the timer fired — so the caller can
|
|
@@ -150,13 +174,67 @@ export function armTimeout(
|
|
|
150
174
|
): { timedOut: () => boolean; clear: () => void } {
|
|
151
175
|
if (timeoutMs === undefined) return { timedOut: () => false, clear: () => {} }
|
|
152
176
|
let fired = false
|
|
177
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined
|
|
153
178
|
const timer = setTimeout(() => {
|
|
154
179
|
fired = true
|
|
155
180
|
proc.kill('SIGTERM')
|
|
181
|
+
// Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
|
|
182
|
+
// (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
|
|
183
|
+
// `await proc.exited` until its natural exit — there is no run-level
|
|
184
|
+
// timeout, so a wedged child hangs the whole run forever. Mirrors the
|
|
185
|
+
// end-of-run persistent-shutdown escalation. Unref'd so it never keeps
|
|
186
|
+
// the CLI alive.
|
|
187
|
+
killTimer = setTimeout(() => proc.kill('SIGKILL'), killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
|
|
188
|
+
killTimer.unref?.()
|
|
156
189
|
}, timeoutMs)
|
|
157
|
-
return {
|
|
190
|
+
return {
|
|
191
|
+
timedOut: () => fired,
|
|
192
|
+
clear: () => {
|
|
193
|
+
clearTimeout(timer)
|
|
194
|
+
if (killTimer !== undefined) clearTimeout(killTimer)
|
|
195
|
+
},
|
|
196
|
+
}
|
|
158
197
|
}
|
|
159
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Grace after the direct child exits before we abort the stdout/stderr readers.
|
|
201
|
+
* The pipe reaches EOF only when EVERY write-end fd is closed, so a task that
|
|
202
|
+
* backgrounds a process inheriting fd 1/2 (`server & echo up` — `execWrap`
|
|
203
|
+
* leaves compound commands as `sh -c`, so `sh` exits while the grandchild holds
|
|
204
|
+
* the pipe) never EOFs. On a clean exit with no lingering writer the pipe EOFs
|
|
205
|
+
* at once, so the readers win this race immediately and normal tasks pay
|
|
206
|
+
* nothing; only a stuck reader waits out the grace. Residual buffered output is
|
|
207
|
+
* bounded by the OS pipe buffer (streamToString drains continuously DURING the
|
|
208
|
+
* run), so this is far longer than any real drain needs.
|
|
209
|
+
*/
|
|
210
|
+
const POST_EXIT_DRAIN_MS = 250
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* After the direct child has exited, wait for the stdout/stderr readers to
|
|
214
|
+
* reach EOF — but bound it: an orphaned grandchild holding the pipe open would
|
|
215
|
+
* hang the run forever (there is no default task timeout). If the grace expires
|
|
216
|
+
* first, abort the reader signal so `streamToString` cancels its read and
|
|
217
|
+
* returns whatever it captured. The timer is cleared AND unref'd so it can
|
|
218
|
+
* never keep the CLI alive after the readers settle (the plugin-flush lesson).
|
|
219
|
+
*/
|
|
220
|
+
export async function drainOrAbort(streams: Promise<unknown>, ac: AbortController): Promise<void> {
|
|
221
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
222
|
+
const deadline = new Promise<'timeout'>((resolve) => {
|
|
223
|
+
timer = setTimeout(() => resolve('timeout'), POST_EXIT_DRAIN_MS)
|
|
224
|
+
timer.unref?.()
|
|
225
|
+
})
|
|
226
|
+
const winner = await Promise.race([streams.then(() => 'drained' as const), deadline])
|
|
227
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
228
|
+
if (winner === 'timeout') ac.abort()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Upper bound on the text a `readyWhen` regex is tested against. The matcher
|
|
233
|
+
* normally keeps only the trailing partial line, so this bites only when the
|
|
234
|
+
* stream has no line breaks at all.
|
|
235
|
+
*/
|
|
236
|
+
const READY_MATCH_WINDOW_CHARS = 64 * 1024
|
|
237
|
+
|
|
160
238
|
export interface PersistentSpawn {
|
|
161
239
|
/** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
|
|
162
240
|
child: ReturnType<typeof Bun.spawn>
|
|
@@ -167,14 +245,15 @@ export interface PersistentSpawn {
|
|
|
167
245
|
* Rejects with the spawn error if the child fails to start.
|
|
168
246
|
*/
|
|
169
247
|
ready: Promise<void>
|
|
170
|
-
/** Captured stdout/stderr up to the moment ready resolved. */
|
|
171
|
-
bufferedStdout: () => string
|
|
172
|
-
bufferedStderr: () => string
|
|
173
248
|
/** ms elapsed from spawn to ready (or to current time if not yet ready). */
|
|
174
249
|
readyMs: () => number
|
|
175
250
|
}
|
|
176
251
|
|
|
177
|
-
|
|
252
|
+
// `capture` is a `runCommand` concept: a persistent task never returns a
|
|
253
|
+
// RunResult (it is handed back at READY, still running), so there is no
|
|
254
|
+
// retained string to opt out of. Its output reaches the caller only through
|
|
255
|
+
// the live callbacks.
|
|
256
|
+
export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'capture'> {
|
|
178
257
|
/**
|
|
179
258
|
* String regex. The first stdout/stderr line that matches signals
|
|
180
259
|
* "ready". Undefined → ready immediately on spawn.
|
|
@@ -198,8 +277,6 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> {
|
|
|
198
277
|
*/
|
|
199
278
|
export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
200
279
|
const start = Date.now()
|
|
201
|
-
let bufferedStdout = ''
|
|
202
|
-
let bufferedStderr = ''
|
|
203
280
|
let readyAt: number | undefined
|
|
204
281
|
|
|
205
282
|
// Pattern compiled once; thrown errors surface synchronously so the
|
|
@@ -220,8 +297,6 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
220
297
|
return {
|
|
221
298
|
child: undefined as unknown as ReturnType<typeof Bun.spawn>,
|
|
222
299
|
ready: Promise.reject(new Error(`failed to spawn persistent task: ${message}`)),
|
|
223
|
-
bufferedStdout: () => '',
|
|
224
|
-
bufferedStderr: () => `\n[vx] failed to spawn: ${message}\n`,
|
|
225
300
|
readyMs: () => Date.now() - start,
|
|
226
301
|
}
|
|
227
302
|
}
|
|
@@ -255,16 +330,13 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
255
330
|
let fragment = ''
|
|
256
331
|
const handleChunk = (chunk: string): void => {
|
|
257
332
|
if (chunk.length === 0) return
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (readyAt === undefined) bufferedStdout += chunk
|
|
266
|
-
opts.onStdout?.(chunk)
|
|
267
|
-
}
|
|
333
|
+
// Chunks are forwarded, never retained. The one consumer of a
|
|
334
|
+
// persistent task's text is the logger, which keeps its OWN bounded
|
|
335
|
+
// tail fed from these same callbacks (registered at taskStart, so it
|
|
336
|
+
// covers the pre-ready window too) — a second copy here was read by
|
|
337
|
+
// nobody.
|
|
338
|
+
if (isStderr) opts.onStderr?.(chunk)
|
|
339
|
+
else opts.onStdout?.(chunk)
|
|
268
340
|
if (readyRe && readyAt === undefined) {
|
|
269
341
|
fragment += chunk
|
|
270
342
|
if (readyRe.test(fragment)) {
|
|
@@ -273,6 +345,17 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
273
345
|
} else {
|
|
274
346
|
const lastNl = fragment.lastIndexOf('\n')
|
|
275
347
|
if (lastNl >= 0) fragment = fragment.slice(lastNl + 1)
|
|
348
|
+
// Dropping complete lines assumes the stream HAS line breaks.
|
|
349
|
+
// `\r`-only output — a progress bar, a spinner — is one endless
|
|
350
|
+
// line, so the trim above never fires and the fragment becomes a
|
|
351
|
+
// third unbounded accumulator (measured 464 MiB in 6 s, and the
|
|
352
|
+
// per-chunk `test` over an ever-growing string makes it quadratic
|
|
353
|
+
// in CPU too). Keep the most RECENT window: a match may span the
|
|
354
|
+
// chunk boundary, and no readiness marker is anywhere near this
|
|
355
|
+
// long.
|
|
356
|
+
if (fragment.length > READY_MATCH_WINDOW_CHARS) {
|
|
357
|
+
fragment = fragment.slice(fragment.length - READY_MATCH_WINDOW_CHARS)
|
|
358
|
+
}
|
|
276
359
|
}
|
|
277
360
|
}
|
|
278
361
|
}
|
|
@@ -314,6 +397,14 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
314
397
|
),
|
|
315
398
|
)
|
|
316
399
|
child.kill('SIGTERM')
|
|
400
|
+
// Same escalation as `armTimeout`: a server that traps TERM and
|
|
401
|
+
// never became ready is not in the persistent registry, so nothing
|
|
402
|
+
// else would ever kill it — it outlived the run under init.
|
|
403
|
+
const killTimer = setTimeout(
|
|
404
|
+
() => child.kill('SIGKILL'),
|
|
405
|
+
killGraceMs(TIMEOUT_SIGKILL_GRACE_MS),
|
|
406
|
+
)
|
|
407
|
+
killTimer.unref?.()
|
|
317
408
|
}
|
|
318
409
|
}, opts.timeoutMs)
|
|
319
410
|
}
|
|
@@ -340,8 +431,6 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
340
431
|
return {
|
|
341
432
|
child,
|
|
342
433
|
ready,
|
|
343
|
-
bufferedStdout: () => bufferedStdout,
|
|
344
|
-
bufferedStderr: () => bufferedStderr,
|
|
345
434
|
readyMs: () => (readyAt ?? Date.now()) - start,
|
|
346
435
|
}
|
|
347
436
|
}
|
|
@@ -376,15 +465,19 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
376
465
|
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
377
466
|
const ac = new AbortController()
|
|
378
467
|
const streams = Promise.all([
|
|
379
|
-
streamToString(proc.stdout, opts.onStdout, ac.signal),
|
|
380
|
-
streamToString(proc.stderr, opts.onStderr, ac.signal),
|
|
468
|
+
streamToString(proc.stdout, opts.onStdout, ac.signal, opts.capture?.stdout ?? true),
|
|
469
|
+
streamToString(proc.stderr, opts.onStderr, ac.signal, opts.capture?.stderr ?? true),
|
|
381
470
|
])
|
|
382
|
-
// Gate on the child's own exit, not on stream EOF:
|
|
383
|
-
//
|
|
384
|
-
//
|
|
471
|
+
// Gate on the child's own exit, not on stream EOF: an orphaned grandchild
|
|
472
|
+
// can keep the pipe open past the child's exit (a timeout SIGTERM leaves the
|
|
473
|
+
// grandchild alive; a NORMAL exit of `server & echo up` does too), so EOF may
|
|
474
|
+
// never arrive. A timeout aborts the readers at once; otherwise drainOrAbort
|
|
475
|
+
// lets a clean exit EOF immediately and only cuts off a stuck reader after a
|
|
476
|
+
// brief grace — without this the run hangs forever.
|
|
385
477
|
await proc.exited
|
|
386
478
|
timeout.clear()
|
|
387
479
|
if (timeout.timedOut()) ac.abort()
|
|
480
|
+
else await drainOrAbort(streams, ac)
|
|
388
481
|
const [stdout, stderr] = await streams
|
|
389
482
|
opts.liveChildren?.delete(proc)
|
|
390
483
|
const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
|
|
@@ -395,18 +488,46 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
395
488
|
stderr,
|
|
396
489
|
...(proc.signalCode ? { signal: proc.signalCode } : {}),
|
|
397
490
|
...(timeout.timedOut() ? { timedOut: true } : {}),
|
|
398
|
-
...resourceUsageToCpuRss(proc.resourceUsage()),
|
|
491
|
+
...resourceUsageToCpuRss(proc.resourceUsage(), ownRssHighWater()),
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* This process's own RSS high-water mark, in bytes: the floor under which
|
|
497
|
+
* a child's `ru_maxrss` says nothing about the child. Linux folds the
|
|
498
|
+
* forking parent's high-water mark into the child's figure at exec (a
|
|
499
|
+
* forked child starts with its parent's pages; `exec_mmap` keeps the old
|
|
500
|
+
* mm's peak), so a task lighter than vx itself reads vx's footprint —
|
|
501
|
+
* `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
|
|
502
|
+
* 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
|
|
503
|
+
* Read after the child exits so it covers the whole task's span (the mark
|
|
504
|
+
* is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
|
|
505
|
+
* bound in hand.
|
|
506
|
+
*/
|
|
507
|
+
export function ownRssHighWater(): number {
|
|
508
|
+
if (process.platform === 'linux') {
|
|
509
|
+
try {
|
|
510
|
+
const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
|
|
511
|
+
if (m !== null) return Number(m[1]) * 1024
|
|
512
|
+
} catch {
|
|
513
|
+
// /proc unreadable: fall through to the current RSS.
|
|
514
|
+
}
|
|
399
515
|
}
|
|
516
|
+
return process.memoryUsage.rss()
|
|
400
517
|
}
|
|
401
518
|
|
|
402
519
|
/**
|
|
403
520
|
* Drain a `Bun.spawn` stdout/stderr stream while invoking the live
|
|
404
|
-
* callback per UTF-8 chunk. Returns the
|
|
521
|
+
* callback per UTF-8 chunk. Returns the accumulated string, or `''` when
|
|
522
|
+
* `retain` is false — the stream is still fully drained and every chunk
|
|
523
|
+
* still reaches `onChunk`; only the retained copy is dropped, so a caller
|
|
524
|
+
* that will not read it does not pay its byte size in heap.
|
|
405
525
|
*/
|
|
406
526
|
export async function streamToString(
|
|
407
527
|
stream: ReadableStream<Uint8Array> | number | undefined,
|
|
408
528
|
onChunk?: (s: string) => void,
|
|
409
529
|
signal?: AbortSignal,
|
|
530
|
+
retain = true,
|
|
410
531
|
): Promise<string> {
|
|
411
532
|
// Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`
|
|
412
533
|
// — the `number` is for inheritance modes, only present when the caller
|
|
@@ -430,12 +551,12 @@ export async function streamToString(
|
|
|
430
551
|
const { value, done } = await reader.read()
|
|
431
552
|
if (done) break
|
|
432
553
|
const chunk = decoder.decode(value, { stream: true })
|
|
433
|
-
full += chunk
|
|
554
|
+
if (retain) full += chunk
|
|
434
555
|
onChunk?.(chunk)
|
|
435
556
|
}
|
|
436
557
|
const tail = decoder.decode()
|
|
437
558
|
if (tail.length > 0) {
|
|
438
|
-
full += tail
|
|
559
|
+
if (retain) full += tail
|
|
439
560
|
onChunk?.(tail)
|
|
440
561
|
}
|
|
441
562
|
} finally {
|
|
@@ -453,18 +574,23 @@ export async function streamToString(
|
|
|
453
574
|
*/
|
|
454
575
|
export function resourceUsageToCpuRss(
|
|
455
576
|
usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,
|
|
456
|
-
|
|
577
|
+
/** The parent's own high-water mark (`ownRssHighWater`); a peak at or under it is inherited, not the child's, and is not reported. */
|
|
578
|
+
floorBytes = 0,
|
|
457
579
|
): { cpuMs?: number; peakRssBytes?: number } {
|
|
458
580
|
if (!usage) return {}
|
|
459
581
|
// cpuTime.total is microseconds as a bigint → ms.
|
|
460
582
|
const cpuMs = Number(usage.cpuTime.total) / 1000
|
|
461
|
-
// maxRSS is
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
|
|
469
|
-
|
|
583
|
+
// `maxRSS` is BYTES on every platform: Bun normalizes the kernel's
|
|
584
|
+
// `ru_maxrss` (kilobytes on Linux, bytes on macOS) before handing it
|
|
585
|
+
// over, as its typing says. This used to multiply by 1024 on Linux on
|
|
586
|
+
// the belief that Bun passed the raw value through, and every Linux
|
|
587
|
+
// peak was 1024× too big — a 64 MB `bun test` recorded as 64 GB in
|
|
588
|
+
// telemetry, `--summarize` and the run history, and once reservations
|
|
589
|
+
// were learned from that history (2026-09-12) every task with a
|
|
590
|
+
// recorded peak was over any budget and ran alone. Measured, not
|
|
591
|
+
// assumed: `tests/runner.test.ts` allocates a known number of bytes
|
|
592
|
+
// and reads the peak back within a bounded factor of it.
|
|
593
|
+
// A reading at or under the parent's own mark is the parent's (see
|
|
594
|
+
// `ownRssHighWater`): the child's peak is unknown, bounded by it.
|
|
595
|
+
return usage.maxRSS > floorBytes ? { cpuMs, peakRssBytes: usage.maxRSS } : { cpuMs }
|
|
470
596
|
}
|