@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,169 @@
|
|
|
1
|
+
// What a MISS leaves behind once the command exited 0 and the task will
|
|
2
|
+
// save: the declared outputs resolved, the artifact and its rows saved in
|
|
3
|
+
// one transaction, the whole-subtree output prefixes recorded for the
|
|
4
|
+
// next hit's skip-restore, and the exact written paths marked against the
|
|
5
|
+
// git snapshot so a same-project consumer re-spawns git only when its
|
|
6
|
+
// globs can see them. Stale-hit-critical: a line moved here changes what
|
|
7
|
+
// a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
|
|
8
|
+
// code motion.
|
|
9
|
+
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import {
|
|
12
|
+
type CacheLayer,
|
|
13
|
+
type GitFilesCache,
|
|
14
|
+
resolveOutputs,
|
|
15
|
+
resolveWorkspaceOutputs,
|
|
16
|
+
} from '../cache/index.js'
|
|
17
|
+
import type { TaskNode } from '../graph/index.js'
|
|
18
|
+
import { span, wholeSubtreePrefixes } from '../util/index.js'
|
|
19
|
+
import type { Logger } from './logger.js'
|
|
20
|
+
import type { TaskInputComponent } from './task-hash.js'
|
|
21
|
+
|
|
22
|
+
/** A snapshot request for `recordOutputDirs`, taken at run end. */
|
|
23
|
+
export interface OutputDirSnapshot {
|
|
24
|
+
hash: string
|
|
25
|
+
projectDir: string
|
|
26
|
+
prefixes: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface SaveMissArgs {
|
|
30
|
+
node: TaskNode
|
|
31
|
+
hash: string
|
|
32
|
+
cache: CacheLayer
|
|
33
|
+
log: Logger
|
|
34
|
+
workspaceRoot: string
|
|
35
|
+
nestedProjectDirs: string[]
|
|
36
|
+
gitFilesCache?: GitFilesCache | undefined
|
|
37
|
+
/** Declared `cache.outputs.files` / `.workspaceFiles`. */
|
|
38
|
+
outputs: string[]
|
|
39
|
+
wsOutputs: string[]
|
|
40
|
+
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
41
|
+
captured: readonly TaskInputComponent[]
|
|
42
|
+
command: string
|
|
43
|
+
durationMs: number
|
|
44
|
+
stdout: string
|
|
45
|
+
/** The execution's usage, when the runner reported it — rides the artifact's sidecar. */
|
|
46
|
+
cpuMs?: number | undefined
|
|
47
|
+
peakRssBytes?: number | undefined
|
|
48
|
+
/** When present, the directory snapshot is queued here instead of taken now. */
|
|
49
|
+
outputDirSnapshots?: OutputDirSnapshot[] | undefined
|
|
50
|
+
/**
|
|
51
|
+
* When present, the cache save itself runs off the execution slot: the
|
|
52
|
+
* outputs are resolved and the git snapshot marked here, in the slot
|
|
53
|
+
* (a same-project downstream task reads both), and the pack + write +
|
|
54
|
+
* index go to the run's save lane (save-lane.ts).
|
|
55
|
+
*/
|
|
56
|
+
deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `landed` settles when the entry is in the cache — at once without a lane. */
|
|
60
|
+
export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void> }> {
|
|
61
|
+
const { node, cache, log } = a
|
|
62
|
+
const endResolve = span('miss: resolve outputs')
|
|
63
|
+
const outputFiles = await resolveOutputs({
|
|
64
|
+
projectDir: node.projectDir,
|
|
65
|
+
outputs: a.outputs,
|
|
66
|
+
nestedProjectDirs: a.nestedProjectDirs,
|
|
67
|
+
})
|
|
68
|
+
const wsOutputFiles = await resolveWorkspaceOutputs({
|
|
69
|
+
workspaceRoot: a.workspaceRoot,
|
|
70
|
+
outputs: a.wsOutputs,
|
|
71
|
+
})
|
|
72
|
+
endResolve()
|
|
73
|
+
// The mirror of the input warning: declared outputs that resolve to
|
|
74
|
+
// nothing save an empty artifact, and the next hit "restores" it —
|
|
75
|
+
// the build that ran nowhere looks like a build that ran. `files: []`
|
|
76
|
+
// is a deliberate cached no-op and says nothing; a glob that matched
|
|
77
|
+
// nothing is a glob against the wrong directory.
|
|
78
|
+
if (
|
|
79
|
+
a.outputs.length + a.wsOutputs.length > 0 &&
|
|
80
|
+
outputFiles.length + wsOutputFiles.length === 0
|
|
81
|
+
) {
|
|
82
|
+
log.status(
|
|
83
|
+
`[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
84
|
+
`an empty artifact is saved; a later hit restores nothing`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
const save = async (): Promise<void> => {
|
|
88
|
+
const endSave = span('miss: save')
|
|
89
|
+
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
90
|
+
// describe above, persisted with the entry inside `cache.save`'s
|
|
91
|
+
// transaction. Miss path only — the warm/hit path never reaches here.
|
|
92
|
+
await cache.save({
|
|
93
|
+
hash: a.hash,
|
|
94
|
+
projectDir: node.projectDir,
|
|
95
|
+
outputFiles,
|
|
96
|
+
...(wsOutputFiles.length > 0
|
|
97
|
+
? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
|
|
98
|
+
: {}),
|
|
99
|
+
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
100
|
+
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
101
|
+
// the contract no longer accepts one — so the invariant is enforced by
|
|
102
|
+
// the type rather than by every call site remembering the gate.
|
|
103
|
+
entry: {
|
|
104
|
+
taskId: node.id,
|
|
105
|
+
command: a.command,
|
|
106
|
+
durationMs: a.durationMs,
|
|
107
|
+
stdout: a.stdout,
|
|
108
|
+
...(a.cpuMs !== undefined ? { cpuMs: a.cpuMs } : {}),
|
|
109
|
+
...(a.peakRssBytes !== undefined ? { peakRssBytes: a.peakRssBytes } : {}),
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
endSave()
|
|
113
|
+
// The directory snapshot behind the next hit's skip-restore. Taken at
|
|
114
|
+
// run end when the run keeps a list: the task wrote these directories
|
|
115
|
+
// milliseconds ago, inside the snapshot's racy window, so a snapshot
|
|
116
|
+
// taken here was refused and the next hit walked every output tree —
|
|
117
|
+
// 1,000 walks, 296 ms accumulated, on the first warm run after a cold
|
|
118
|
+
// build of the 1,000-project bench (2026-09-10).
|
|
119
|
+
const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
|
|
120
|
+
if (savedDirPrefixes !== null) {
|
|
121
|
+
if (a.outputDirSnapshots !== undefined) {
|
|
122
|
+
a.outputDirSnapshots.push({
|
|
123
|
+
hash: a.hash,
|
|
124
|
+
projectDir: node.projectDir,
|
|
125
|
+
prefixes: savedDirPrefixes,
|
|
126
|
+
})
|
|
127
|
+
} else {
|
|
128
|
+
await cache.recordOutputDirs?.(a.hash, node.projectDir, savedDirPrefixes)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// This task just wrote outputs to the project's tree. Record the
|
|
133
|
+
// exact declared-output paths as changed (same as the cache-hit
|
|
134
|
+
// restore path) instead of dropping the whole snapshot: a downstream
|
|
135
|
+
// same-project task then re-spawns git ONLY when its input globs can
|
|
136
|
+
// actually see one of these paths. On a 1000-package cold run this
|
|
137
|
+
// removes ~one synchronous `git ls-files` spawn per project (the
|
|
138
|
+
// single largest cold-run cost — 22% of CPU in profiling). Contract:
|
|
139
|
+
// outputs must be declared — an executed task that writes files
|
|
140
|
+
// outside `cache.outputs.files` which a same-project downstream task
|
|
141
|
+
// reads is undeclared behavior (the restore path already assumes it).
|
|
142
|
+
if (outputFiles.length > 0) {
|
|
143
|
+
a.gitFilesCache?.markOutputsChanged(
|
|
144
|
+
node.projectDir,
|
|
145
|
+
outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
// Declared workspace outputs may have landed inside OTHER
|
|
149
|
+
// projects' dirs (no-boundary escape hatch) — mark the exact
|
|
150
|
+
// paths against every partition that can see them.
|
|
151
|
+
if (wsOutputFiles.length > 0) {
|
|
152
|
+
a.gitFilesCache?.markWorkspaceOutputsChanged(
|
|
153
|
+
a.workspaceRoot,
|
|
154
|
+
wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
// The workspace-wide partition (when one exists) spans this
|
|
158
|
+
// project's subtree, so it inherits the same "undeclared writes
|
|
159
|
+
// are only visible to git" rule as the project drop above.
|
|
160
|
+
if (outputFiles.length + wsOutputFiles.length > 0) {
|
|
161
|
+
a.gitFilesCache?.invalidateWorkspacePartition()
|
|
162
|
+
}
|
|
163
|
+
// Off the slot when the run keeps a lane (the save lane bounds and
|
|
164
|
+
// drains it); in the slot otherwise — an embedder without a lane gets
|
|
165
|
+
// the entry before the outcome.
|
|
166
|
+
if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
|
|
167
|
+
await save()
|
|
168
|
+
return { landed: Promise.resolve() }
|
|
169
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
|
|
|
8
8
|
import type { EventBus } from './events.js'
|
|
9
9
|
import type { TelemetrySink } from './telemetry.js'
|
|
10
10
|
import type { Logger } from './logger.js'
|
|
11
|
+
import type { ProjectEntry } from '../workspace/index.js'
|
|
11
12
|
|
|
12
13
|
export interface RunOptions {
|
|
13
14
|
cwd: string
|
|
@@ -19,6 +20,16 @@ export interface RunOptions {
|
|
|
19
20
|
*/
|
|
20
21
|
tasks: readonly string[]
|
|
21
22
|
projects?: string[]
|
|
23
|
+
/**
|
|
24
|
+
* Configs a selection pass in this same process already loaded and
|
|
25
|
+
* staged with the same `cacheDir` and `frozen` (the CLI walks every
|
|
26
|
+
* `pkg#task` edge for `--filter 'app...'` and `--affected`). The run
|
|
27
|
+
* seeds and scopes as it always does and reuses these entries instead
|
|
28
|
+
* of evaluating and staging them again. One run only — never carry a
|
|
29
|
+
* map across runs, a config can change between them (`vx watch` drops
|
|
30
|
+
* it per cycle).
|
|
31
|
+
*/
|
|
32
|
+
staged?: ReadonlyMap<string, ProjectEntry>
|
|
22
33
|
concurrency?: number
|
|
23
34
|
/**
|
|
24
35
|
* Cache directory override (`--cache-dir <path>`). Absolute, or relative
|
|
@@ -34,6 +45,13 @@ export interface RunOptions {
|
|
|
34
45
|
* both reads false (re-execute, still refresh the cache).
|
|
35
46
|
*/
|
|
36
47
|
cache?: CachePolicy
|
|
48
|
+
/**
|
|
49
|
+
* The caller asked for a remote axis by name (`--cache=remote:rw`). With
|
|
50
|
+
* no cache plugin supplying a remote layer the axes are inert, and a CI
|
|
51
|
+
* job that believes it is filling a shared cache deserves one line
|
|
52
|
+
* saying it is not.
|
|
53
|
+
*/
|
|
54
|
+
remoteRequested?: boolean
|
|
37
55
|
/**
|
|
38
56
|
* CI mode: load configs FROM the committed vx-lock.json instead of
|
|
39
57
|
* evaluating them (frozen-env reproducibility). Requires the lock
|
|
@@ -46,11 +64,26 @@ export interface RunOptions {
|
|
|
46
64
|
/**
|
|
47
65
|
* Explicit per-task output volume for the default logger. ALWAYS
|
|
48
66
|
* overrides the flow default. 'full': frames for work + one-liners
|
|
49
|
-
* for quiet hits. 'errors-only': only failed tasks print.
|
|
67
|
+
* for quiet hits. 'errors-only': only failed tasks print.
|
|
68
|
+
* 'hash-only': one line per task — outcome, id, cache key — with no
|
|
69
|
+
* log output (Turbo parity). 'none':
|
|
50
70
|
* no per-task output at all. Status lines (header, summary) always
|
|
51
71
|
* print.
|
|
52
72
|
*/
|
|
53
|
-
outputLogs?: 'full' | 'errors-only' | 'none'
|
|
73
|
+
outputLogs?: 'full' | 'errors-only' | 'none' | 'hash-only'
|
|
74
|
+
/**
|
|
75
|
+
* Where a REMOTELY-executed task's outputs land. 'all' (default):
|
|
76
|
+
* every task's outputs are downloaded to this machine as it completes
|
|
77
|
+
* — today's behaviour, byte for byte. 'toplevel': only the tasks you
|
|
78
|
+
* REQUESTED land here; intermediates stay remote. 'none': outputs stay
|
|
79
|
+
* in the remote CAS and are fetched lazily, only when a locally-placed
|
|
80
|
+
* task in the run actually needs them (Bazel's "build without the
|
|
81
|
+
* bytes").
|
|
82
|
+
* Pure transfer tuning: it can never change what a command produces,
|
|
83
|
+
* so it is NEVER folded into a cache key: it is a RunOption, not config.
|
|
84
|
+
* Locally-placed tasks always write in place and ignore this.
|
|
85
|
+
*/
|
|
86
|
+
download?: 'all' | 'toplevel' | 'none'
|
|
54
87
|
/**
|
|
55
88
|
* Run intent, derived by the CLI from selection flags: 'broad' iff
|
|
56
89
|
* `--all` / `--filter` / `--affected` was passed, else 'focused'.
|
|
@@ -67,24 +100,6 @@ export interface RunOptions {
|
|
|
67
100
|
* the same run with and without `--retry` derives identical keys.
|
|
68
101
|
*/
|
|
69
102
|
retries?: number
|
|
70
|
-
/**
|
|
71
|
-
* Cache-correctness verification (`vx run --verify`). When set, an
|
|
72
|
-
* executed + cacheable task is re-run after its save and its outputs are
|
|
73
|
-
* content-compared: a divergence means the task is non-hermetic and its
|
|
74
|
-
* cache entry is provably unsafe (see docs/design/cache-correctness).
|
|
75
|
-
* `fingerprint` computes + ships an output-tree fingerprint on executed
|
|
76
|
-
* tasks (no re-run — the cross-machine diff feed; `--verify`/`=all` set
|
|
77
|
-
* it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`) exempts
|
|
78
|
-
* known-nondeterministic tasks from failing the run. A pure side-channel —
|
|
79
|
-
* never folded into a cache key; the re-run never saves. Undefined = off
|
|
80
|
-
* (a plain run is byte-identical).
|
|
81
|
-
*/
|
|
82
|
-
verify?: {
|
|
83
|
-
determinism: boolean
|
|
84
|
-
inputs: boolean
|
|
85
|
-
fingerprint: boolean
|
|
86
|
-
allow: ReadonlySet<string>
|
|
87
|
-
}
|
|
88
103
|
/**
|
|
89
104
|
* Run-level default task timeout (ms), for tasks that declare no
|
|
90
105
|
* `exec.timeout`. Highest of the run-level defaults: `--timeout` /
|
|
@@ -94,14 +109,6 @@ export interface RunOptions {
|
|
|
94
109
|
* cached), so a `--timeout` run cache-hits a plain run's entry.
|
|
95
110
|
*/
|
|
96
111
|
timeout?: number
|
|
97
|
-
/**
|
|
98
|
-
* Memory budget (bytes) that per-task `exec.resources.memory`
|
|
99
|
-
* reservations pack against (`--memory <size>`). Defaults to
|
|
100
|
-
* `os.totalmem()` — override it in cgroup-limited containers, where
|
|
101
|
-
* totalmem() reports the HOST's RAM, not the limit. A per-run
|
|
102
|
-
* scheduling knob only — never folded into any cache key.
|
|
103
|
-
*/
|
|
104
|
-
memory?: number
|
|
105
112
|
/**
|
|
106
113
|
* Failure propagation: 'never' stops dispatch on the first failure
|
|
107
114
|
* (in-flight tasks finish; everything queued skips), 'deps-ok'
|
|
@@ -139,18 +146,32 @@ export interface RunOptions {
|
|
|
139
146
|
* exit the process out from under it.
|
|
140
147
|
*/
|
|
141
148
|
handleSignals?: boolean
|
|
149
|
+
/**
|
|
150
|
+
* Abort the run from outside. On `abort` every live and persistent
|
|
151
|
+
* child is SIGTERMed, given the kill grace, then SIGKILLed; nothing
|
|
152
|
+
* further is dispatched; run() returns with the killed tasks and the
|
|
153
|
+
* never-started ones `aborted`. The seam for an embedder that owns its
|
|
154
|
+
* process's lifetime instead of `handleSignals` — the watch loop aborts
|
|
155
|
+
* its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
|
|
156
|
+
* never orphans a cycle's children.
|
|
157
|
+
*/
|
|
158
|
+
signal?: AbortSignal
|
|
142
159
|
log?: Logger
|
|
143
160
|
/**
|
|
144
161
|
* Inject the run's event bus. When provided, the orchestrator emits
|
|
145
162
|
* onto it instead of creating its own — letting a caller subscribe a
|
|
146
|
-
* surface
|
|
147
|
-
*
|
|
163
|
+
* surface BEFORE the run starts emitting. This IS the surface seam:
|
|
164
|
+
* core ships the bus and the serializable `WireEvent`, and whatever
|
|
165
|
+
* renders them lives outside. (It used to name a `--ui` devframe dev
|
|
166
|
+
* server; no such flag exists, and core's devframe adapter was removed
|
|
167
|
+
* on 2026-08-26 — one consumer's adapter is not core's job.) The terminal renderer is still attached as a subscriber, so
|
|
148
168
|
* default output is unchanged. Default: a fresh internal bus.
|
|
149
169
|
*/
|
|
150
170
|
bus?: EventBus
|
|
151
171
|
/**
|
|
152
172
|
* Shared in-flight execution registry, keyed by task hash. Supplied by a
|
|
153
|
-
* long-lived
|
|
173
|
+
* long-lived embedder (a daemon built on the façade; core ships none) so
|
|
174
|
+
* concurrent runs DEDUP work: a task
|
|
154
175
|
* already executing for one run is awaited by another (which then
|
|
155
176
|
* restores the just-saved artifact from cache) instead of re-running.
|
|
156
177
|
* A stateless `vx run` passes none and is byte-identical to before.
|
|
@@ -165,8 +186,8 @@ export interface RunOptions {
|
|
|
165
186
|
/**
|
|
166
187
|
* Additional observe-only telemetry sinks, merged with whatever the
|
|
167
188
|
* workspace plugins contribute. The embedder seam: a host executing
|
|
168
|
-
* runs on behalf of others (a
|
|
169
|
-
* into its
|
|
189
|
+
* runs on behalf of others (a daemon recording delegated runs
|
|
190
|
+
* into its own store) attaches a sink without needing a workspace
|
|
170
191
|
* plugin. Undefined → zero cost, identical to before; the telemetry
|
|
171
192
|
* host's zero-sink invariant still applies when both sources are
|
|
172
193
|
* empty.
|
|
@@ -182,7 +203,7 @@ export interface RunOptions {
|
|
|
182
203
|
/**
|
|
183
204
|
* An injected remote cache layer — the embedder seam mirroring
|
|
184
205
|
* `telemetrySinks`: a host that already holds a wire client (a
|
|
185
|
-
* distribution agent, a
|
|
206
|
+
* distribution agent, a daemon executing on behalf of a submitter)
|
|
186
207
|
* passes it here and run() composes `LayeredCache(local, injected)`.
|
|
187
208
|
* Explicit injection WINS over the plugin `cache` capability (the
|
|
188
209
|
* host knows best; prevents double-wrapping when the workspace also
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// End-of-run disposition of persistent children (dev servers, watchers).
|
|
2
|
+
// `executeTask` spawns them and returns at "ready" without awaiting exit;
|
|
3
|
+
// ownership moves to the run's registry, and this is the one place that
|
|
4
|
+
// decides which of them outlive the graph and how the rest go down.
|
|
5
|
+
|
|
6
|
+
import type { TaskNode } from '../graph/index.js'
|
|
7
|
+
import { killGraceMs } from '../util/index.js'
|
|
8
|
+
|
|
9
|
+
type Child = ReturnType<typeof Bun.spawn>
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Grace after SIGTERMing the dependency-only persistent tasks before
|
|
13
|
+
* force-killing any that trap or ignore it — so a wedged mock server can't
|
|
14
|
+
* hang a normal run at completion. Well-behaved servers exit far under
|
|
15
|
+
* this, so the happy path never waits it out.
|
|
16
|
+
*/
|
|
17
|
+
export const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
|
|
18
|
+
|
|
19
|
+
export interface KeepAlive {
|
|
20
|
+
nodes: TaskNode[]
|
|
21
|
+
children: Child[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A persistent task the user REQUESTED (or one surfaced for display) is the
|
|
26
|
+
* run's whole purpose — it is left running and blocked on at the very end,
|
|
27
|
+
* after the summary. Only in the real CLI foreground: a custom logger or
|
|
28
|
+
* `handleSignals: false` (watch mode, embedders) expects `run()` to return,
|
|
29
|
+
* not to sit on a server.
|
|
30
|
+
*/
|
|
31
|
+
export function selectKeepAlive(
|
|
32
|
+
registry: ReadonlyMap<string, Child>,
|
|
33
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
34
|
+
foreground: boolean,
|
|
35
|
+
): KeepAlive {
|
|
36
|
+
const out: KeepAlive = { nodes: [], children: [] }
|
|
37
|
+
if (!foreground) return out
|
|
38
|
+
for (const [id, child] of registry) {
|
|
39
|
+
const n = nodes.get(id)
|
|
40
|
+
if (n !== undefined && (n.requested || n.surfaced === true)) {
|
|
41
|
+
out.nodes.push(n)
|
|
42
|
+
out.children.push(child)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* SIGTERM every persistent child not kept alive and wait for them, bounded:
|
|
50
|
+
* SIGTERM gives well-behaved servers (vite, next, esbuild --watch) a moment
|
|
51
|
+
* to clean up; past the grace the stragglers are SIGKILLed so the run's
|
|
52
|
+
* normal completion cannot hang on one that traps the signal. Bun's
|
|
53
|
+
* `kill` is idempotent on an exited child; the timer is cleared and
|
|
54
|
+
* unref'd so a fast shutdown never delays CLI exit.
|
|
55
|
+
*/
|
|
56
|
+
export async function shutdownPersistent(
|
|
57
|
+
registry: ReadonlyMap<string, Child>,
|
|
58
|
+
keepAlive: readonly Child[],
|
|
59
|
+
graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
const kept = new Set(keepAlive)
|
|
62
|
+
const dying = [...registry.values()].filter((c) => !kept.has(c))
|
|
63
|
+
if (dying.length === 0) return
|
|
64
|
+
for (const child of dying) child.kill('SIGTERM')
|
|
65
|
+
const allExited = Promise.allSettled(dying.map((c) => c.exited))
|
|
66
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
67
|
+
const winner = await Promise.race([
|
|
68
|
+
allExited.then(() => 'exited' as const),
|
|
69
|
+
new Promise<'grace'>((resolve) => {
|
|
70
|
+
graceTimer = setTimeout(() => resolve('grace'), graceMs)
|
|
71
|
+
graceTimer.unref?.()
|
|
72
|
+
}),
|
|
73
|
+
])
|
|
74
|
+
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
75
|
+
if (winner === 'grace') {
|
|
76
|
+
for (const child of dying) child.kill('SIGKILL')
|
|
77
|
+
await allExited
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Where each task runs: the placement of a graph over the resolved
|
|
2
|
+
// executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
|
|
3
|
+
// (pure motion). A task is pinned to this machine when it is persistent,
|
|
4
|
+
// depends on a persistent task, or says `exec.remote: false`; everything
|
|
5
|
+
// else asks the executors in declaration order, and the local floor takes
|
|
6
|
+
// what nothing claimed.
|
|
7
|
+
|
|
8
|
+
import { machineParallelism } from '../util/index.js'
|
|
9
|
+
import { selectExecutor, type TaskExecutor } from '../exec/index.js'
|
|
10
|
+
import { isGroupTask, type TaskNode } from '../graph/index.js'
|
|
11
|
+
import { resolveDownloadModes } from './download-policy.js'
|
|
12
|
+
import type { Logger } from './logger.js'
|
|
13
|
+
import { resolveExecutors } from './plugin-host.js'
|
|
14
|
+
import type { prepareRun } from './prepare.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A task is pinned to this machine when it is persistent, transitively
|
|
18
|
+
* depends on a persistent task (a worker cannot reach a port on the
|
|
19
|
+
* submitter), or declares `exec.remote: false`.
|
|
20
|
+
*/
|
|
21
|
+
export function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
22
|
+
const pinned = new Set<string>()
|
|
23
|
+
const memo = new Map<string, boolean>()
|
|
24
|
+
const visit = (id: string): boolean => {
|
|
25
|
+
const known = memo.get(id)
|
|
26
|
+
if (known !== undefined) return known
|
|
27
|
+
const node = nodes.get(id)
|
|
28
|
+
if (node === undefined) return false
|
|
29
|
+
memo.set(id, false) // cycle guard; the graph builder already rejects cycles
|
|
30
|
+
const result =
|
|
31
|
+
node.config.exec?.persistent !== undefined ||
|
|
32
|
+
node.config.exec?.remote === false ||
|
|
33
|
+
node.deps.some((d) => visit(d))
|
|
34
|
+
memo.set(id, result)
|
|
35
|
+
if (result) pinned.add(id)
|
|
36
|
+
return result
|
|
37
|
+
}
|
|
38
|
+
for (const id of nodes.keys()) visit(id)
|
|
39
|
+
return pinned
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Placements {
|
|
43
|
+
executors: Map<string, TaskExecutor>
|
|
44
|
+
/**
|
|
45
|
+
* `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
|
|
46
|
+
* this machine: never executed, declared outputs never cleaned or
|
|
47
|
+
* restored. The task exists to produce a remote input tree; without a
|
|
48
|
+
* remote pool, dependents use the machine's ambient state exactly as they
|
|
49
|
+
* did before the field existed.
|
|
50
|
+
*/
|
|
51
|
+
remoteOnlyNoop: Set<string>
|
|
52
|
+
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
53
|
+
remoteOnly: Set<string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function placeTasks(
|
|
57
|
+
nodes: Map<string, TaskNode>,
|
|
58
|
+
executors: readonly TaskExecutor[],
|
|
59
|
+
pinAllLocal = false,
|
|
60
|
+
): Placements {
|
|
61
|
+
const pinned = pinnedLocalSet(nodes)
|
|
62
|
+
const placements: Placements = {
|
|
63
|
+
executors: new Map(),
|
|
64
|
+
remoteOnlyNoop: new Set(),
|
|
65
|
+
remoteOnly: new Set(),
|
|
66
|
+
}
|
|
67
|
+
for (const node of nodes.values()) {
|
|
68
|
+
if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
|
|
69
|
+
const executor = selectExecutor(executors, {
|
|
70
|
+
taskId: node.id,
|
|
71
|
+
projectName: node.projectName,
|
|
72
|
+
projectDir: node.projectDir,
|
|
73
|
+
command: node.config.exec!.command,
|
|
74
|
+
pinnedLocal: pinAllLocal || pinned.has(node.id),
|
|
75
|
+
cacheable: node.config.cache !== undefined,
|
|
76
|
+
})
|
|
77
|
+
placements.executors.set(node.id, executor)
|
|
78
|
+
if (node.config.exec?.remote === 'only') {
|
|
79
|
+
// A pinned 'only' task (it transitively depends on a persistent one)
|
|
80
|
+
// lands here too: pinning wins, so it noops rather than shipping.
|
|
81
|
+
if (executor.remote === true) placements.remoteOnly.add(node.id)
|
|
82
|
+
else placements.remoteOnlyNoop.add(node.id)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return placements
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* `executorOf` for `planRun`, or nothing. Declining plugins, a single
|
|
90
|
+
* executor, or a resolution error all yield nothing: `--dry` is an
|
|
91
|
+
* inspection command and must not fail over a label. The error is still
|
|
92
|
+
* said, on the status line, in the plugin's name: the run this plan
|
|
93
|
+
* previews would refuse on it, and a plan that hid that would read as
|
|
94
|
+
* "everything lands locally".
|
|
95
|
+
*/
|
|
96
|
+
export async function planExecutorOf(
|
|
97
|
+
prepared: Awaited<ReturnType<typeof prepareRun>>,
|
|
98
|
+
log: Logger,
|
|
99
|
+
policy: 'all' | 'toplevel' | 'none',
|
|
100
|
+
): Promise<{
|
|
101
|
+
executorOf?: (id: string) => string | undefined
|
|
102
|
+
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
103
|
+
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
104
|
+
}> {
|
|
105
|
+
let executors: readonly TaskExecutor[]
|
|
106
|
+
try {
|
|
107
|
+
executors = await resolveExecutors(prepared.plugins, {
|
|
108
|
+
workspaceRoot: prepared.workspaceRoot,
|
|
109
|
+
cacheDir: prepared.cacheDir,
|
|
110
|
+
warn: (m: string) => log.status(m),
|
|
111
|
+
concurrency: machineParallelism(),
|
|
112
|
+
})
|
|
113
|
+
} catch (err) {
|
|
114
|
+
log.status(
|
|
115
|
+
`[vx] placement not shown — ${err instanceof Error ? err.message : String(err)} (the run would refuse on it)`,
|
|
116
|
+
)
|
|
117
|
+
return {}
|
|
118
|
+
}
|
|
119
|
+
const placements = placeTasks(prepared.nodes, executors)
|
|
120
|
+
// Download modes need placement regardless of how many executors there
|
|
121
|
+
// are (a single REMOTE one still defers); executor LABELS only earn their
|
|
122
|
+
// column when there is a choice to report.
|
|
123
|
+
const download =
|
|
124
|
+
policy === 'all'
|
|
125
|
+
? undefined
|
|
126
|
+
: resolveDownloadModes({
|
|
127
|
+
nodes: prepared.nodes,
|
|
128
|
+
policy,
|
|
129
|
+
localPlaced: new Set(
|
|
130
|
+
[...prepared.nodes.keys()].filter(
|
|
131
|
+
(id) => placements.executors.get(id)?.remote !== true,
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
remoteOnly: placements.remoteOnly,
|
|
135
|
+
})
|
|
136
|
+
return {
|
|
137
|
+
...(executors.length < 2
|
|
138
|
+
? {}
|
|
139
|
+
: {
|
|
140
|
+
executorOf: (id: string) =>
|
|
141
|
+
placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
|
|
142
|
+
}),
|
|
143
|
+
...(download === undefined
|
|
144
|
+
? {}
|
|
145
|
+
: {
|
|
146
|
+
downloadOf: (id: string) => download.modeOf.get(id),
|
|
147
|
+
downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
|
|
148
|
+
taskId,
|
|
149
|
+
reason,
|
|
150
|
+
})),
|
|
151
|
+
}),
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Stands in for the executor of a task that was never placed — a group task
|
|
157
|
+
* (runs nothing) or a persistent one (`executePersistentTask` owns it and
|
|
158
|
+
* never reads this field). It THROWS rather than silently picking some
|
|
159
|
+
* executor from the list, so a refactor that routes such a task through the
|
|
160
|
+
* exec path fails loudly instead of shipping a localhost server to a worker.
|
|
161
|
+
*/
|
|
162
|
+
export const UNPLACED_EXECUTOR: TaskExecutor = {
|
|
163
|
+
name: 'unplaced',
|
|
164
|
+
execute: (req) => {
|
|
165
|
+
throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
|
|
170
|
+
return executors.some((e) => e.capacity !== undefined)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function poolOfPlacement(
|
|
174
|
+
placements: Placements,
|
|
175
|
+
): (id: string) => { name: string; capacity: number } | undefined {
|
|
176
|
+
return (id) => {
|
|
177
|
+
const executor = placements.executors.get(id)
|
|
178
|
+
return executor?.capacity === undefined
|
|
179
|
+
? undefined
|
|
180
|
+
: { name: executor.name, capacity: executor.capacity }
|
|
181
|
+
}
|
|
182
|
+
}
|