@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.
- package/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- 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/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- 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
|
|
33
|
-
* (`
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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'
|
package/src/exec/runner.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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:
|
|
383
|
-
//
|
|
384
|
-
//
|
|
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
|
|
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 {
|