@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.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. 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. 'none':
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 (e.g. the `--ui` devframe dev server) BEFORE the run starts
147
- * emitting. The terminal renderer is still attached as a subscriber, so
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 service (`vx serve`) so concurrent runs DEDUP work: a task
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 serve recording delegated runs
169
- * into its ingest store) attaches a sink without needing a workspace
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 serve executing on behalf of a submitter)
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
+ }