@vzn/vx 0.0.0 → 0.0.14

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,177 @@
1
+ // Stable-key derivation, shared by remote-prefetch and the local
2
+ // short-circuit. Both need to identify the tasks whose cache key is
3
+ // provably independent of any upstream task's OUTPUTS — those are the
4
+ // only tasks safe to probe / restore ahead of the schedule. Factored
5
+ // out so the two callers can never drift on the stability gate.
6
+ //
7
+ // A task's key is "stable" only if its inputs can't be altered by an
8
+ // upstream's outputs (a `cache.inputs.files` glob that could match a
9
+ // sibling's declared output has a PRELIMINARY key until that upstream
10
+ // runs — probing it would target the wrong artifact). Unstable tasks
11
+ // fall back to lazy read-through in execute-task, which is always
12
+ // correct. The check is conservative: when unsure, treat as unstable.
13
+
14
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
15
+ import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
+ import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
17
+
18
+ export interface DeriveStableKeysArgs {
19
+ nodes: Map<string, TaskNode>
20
+ cache: CacheLayer
21
+ workspaceRoot: string
22
+ workspaceFingerprint: string
23
+ forwardArgs?: readonly string[] | undefined
24
+ nestedDirsByProject: Map<string, string[]>
25
+ gitFilesCache: GitFilesCache
26
+ hashCache: HashCache
27
+ }
28
+
29
+ export interface StableKey {
30
+ hash: string
31
+ node: TaskNode
32
+ }
33
+
34
+ /**
35
+ * Walk the graph in topological order, deriving every task's key the
36
+ * SAME way execute-task does (folding upstream keys via the synthetic
37
+ * {node, hash} outcomes filterUpstreamHashes / computeGroupHash read)
38
+ * and classifying each as stable / unstable. Returns the stable,
39
+ * cache-enabled, non-group tasks paired with their keys.
40
+ *
41
+ * Reuses the run's `hashCache`, so execute-task's later computeTaskHash
42
+ * for the same node hits the memo (package.json bytes + task config) —
43
+ * the key bytes are derived here, the heavy per-project hashing is
44
+ * shared. No cache layer is touched: keys only.
45
+ */
46
+ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
47
+ const order = topoOrder(args.nodes)
48
+ const keyById = new Map<string, string>()
49
+ const unstableById = new Set<string>()
50
+ const stableKeys: StableKey[] = []
51
+
52
+ for (const id of order) {
53
+ const node = args.nodes.get(id)!
54
+ const upstream = synthUpstream(node, args.nodes, keyById)
55
+
56
+ if (isGroupTask(node)) {
57
+ // Groups have no exec/cache; they only fold upstream keys so
58
+ // dependents that filter inputs.tasks through the group still
59
+ // cascade. They inherit instability from any unstable member.
60
+ keyById.set(id, computeGroupHash(upstream))
61
+ if (node.deps.some((d) => unstableById.has(d))) unstableById.add(id)
62
+ continue
63
+ }
64
+
65
+ const hash = await computeTaskHash({
66
+ node,
67
+ upstream,
68
+ workspaceRoot: args.workspaceRoot,
69
+ workspaceFingerprint: args.workspaceFingerprint,
70
+ cache: args.cache,
71
+ forwardArgs: args.forwardArgs,
72
+ nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
73
+ gitFilesCache: args.gitFilesCache,
74
+ hashCache: args.hashCache,
75
+ })
76
+ keyById.set(id, hash)
77
+
78
+ const unstable =
79
+ node.deps.some((d) => unstableById.has(d)) || dependsOnSiblingOutputs(node, args.nodes)
80
+ if (unstable) unstableById.add(id)
81
+
82
+ const cacheEnabled = node.config.cache !== undefined
83
+ if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
84
+ }
85
+ return stableKeys
86
+ }
87
+
88
+ /**
89
+ * Synthetic upstream outcomes for key derivation. Only `node` + `hash`
90
+ * are read by filterUpstreamHashes / computeGroupHash; the rest of
91
+ * TaskOutcome is irrelevant here, so we cast a minimal shape.
92
+ */
93
+ export function synthUpstream(
94
+ node: TaskNode,
95
+ nodes: Map<string, TaskNode>,
96
+ keyById: Map<string, string>,
97
+ ): TaskOutcome[] {
98
+ const out: TaskOutcome[] = []
99
+ for (const dep of node.deps) {
100
+ const depNode = nodes.get(dep)
101
+ if (!depNode) continue
102
+ out.push({
103
+ node: depNode,
104
+ status: 'success',
105
+ exitCode: 0,
106
+ durationMs: 0,
107
+ hash: keyById.get(dep),
108
+ } as TaskOutcome)
109
+ }
110
+ return out
111
+ }
112
+
113
+ /**
114
+ * Conservative stability check: does any UPSTREAM task whose outputs
115
+ * could land where this task reads its inputs make this task's key
116
+ * preliminary?
117
+ *
118
+ * - Same-project upstream with declared `cache.outputs.files`: its
119
+ * outputs land inside this project's dir, which this task's input
120
+ * globs (default `**` / anything project-relative) can match —
121
+ * the upstream must run before this key is final. Project
122
+ * boundaries are hard, so only SAME-project outputs can reach a
123
+ * project-relative input.
124
+ * - Any upstream with declared `cache.outputs.workspaceFiles` when
125
+ * this task reads `cache.inputs.workspaceFiles`: root-anchored
126
+ * outputs ignore boundaries and can land anywhere the root-anchored
127
+ * inputs read.
128
+ *
129
+ * Either case → unstable → skip prefetch (lazy read-through in
130
+ * execute-task stays correct). When in doubt, unstable.
131
+ */
132
+ export function dependsOnSiblingOutputs(node: TaskNode, nodes: Map<string, TaskNode>): boolean {
133
+ const inputs = node.config.cache?.inputs
134
+ // A cache-disabled task has no key to prefetch anyway; treat as
135
+ // unstable-irrelevant (caller filters on cacheEnabled).
136
+ if (node.config.cache === undefined) return false
137
+ const readsWorkspaceFiles = (inputs?.workspaceFiles?.length ?? 0) > 0
138
+
139
+ for (const dep of node.deps) {
140
+ const depNode = nodes.get(dep)
141
+ if (!depNode) continue
142
+ const depOutputs = depNode.config.cache?.outputs
143
+ if (depOutputs === undefined) continue
144
+ const sameProject = depNode.projectName === node.projectName
145
+ if (sameProject && (depOutputs.files?.length ?? 0) > 0) return true
146
+ if (readsWorkspaceFiles && (depOutputs.workspaceFiles?.length ?? 0) > 0) return true
147
+ }
148
+ return false
149
+ }
150
+
151
+ export function topoOrder(nodes: Map<string, TaskNode>): string[] {
152
+ const indegree = new Map<string, number>()
153
+ const dependents = new Map<string, string[]>()
154
+ for (const node of nodes.values()) {
155
+ indegree.set(node.id, node.deps.length)
156
+ for (const dep of node.deps) {
157
+ const list = dependents.get(dep)
158
+ if (list) list.push(node.id)
159
+ else dependents.set(dep, [node.id])
160
+ }
161
+ }
162
+ // Kahn's algorithm with a head pointer instead of `queue.shift()` — a shift
163
+ // reindexes the whole array (O(N) each), making the walk O(N²) on a big
164
+ // graph. `out` doubles as the queue (every dequeued id is already in topo
165
+ // order); we only ever append and advance `head`. Same pattern as
166
+ // computeReverseDepCount / package-graph.
167
+ const out: string[] = []
168
+ for (const [id, deg] of indegree) if (deg === 0) out.push(id)
169
+ for (let head = 0; head < out.length; head++) {
170
+ for (const d of dependents.get(out[head]!) ?? []) {
171
+ const rem = (indegree.get(d) ?? 0) - 1
172
+ indegree.set(d, rem)
173
+ if (rem === 0) out.push(d)
174
+ }
175
+ }
176
+ return out
177
+ }
@@ -0,0 +1,287 @@
1
+ // Dynamic status display for interactive runs. NOT a TUI: a
2
+ // fixed-height worker region redrawn in place with clear-line /
3
+ // cursor-up — no alternate screen.
4
+ // The writer is the serialization point for ALL logger stdout: every
5
+ // ordinary write erases the status display first, writes its content,
6
+ // then redraws, so the display can never interleave with task output.
7
+
8
+ import { paint, type ColorSupport } from './colors.js'
9
+ import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
10
+
11
+ const NO_COLOR: ColorSupport = { enabled: false }
12
+ // Same palette as framed-output.ts so glyphs and stats agree.
13
+ const ACCENT = '#06b6d4'
14
+ const ERROR = '#ef4444'
15
+
16
+ /** Erase the current line and return the cursor to column 0. */
17
+ const CLEAR = '\x1b[2K\r'
18
+
19
+ export interface StatusStream {
20
+ write(chunk: string): unknown
21
+ isTTY?: boolean
22
+ }
23
+
24
+ export interface OutputWriter {
25
+ /** True only when the stream is a TTY and the run isn't CI. */
26
+ readonly enabled: boolean
27
+ /** Serialized content write: erase status → content → redraw. */
28
+ write(chunk: string): void
29
+ /**
30
+ * Replace the status line's content. Redraws are throttled to
31
+ * `minRedrawMs` unless `force` (task start/finish) is set.
32
+ */
33
+ setStatus(line: string, opts?: { force?: boolean }): void
34
+ /**
35
+ * Replace the multi-line status region. Same throttle contract as
36
+ * `setStatus`; the two share one display slot.
37
+ */
38
+ setRegion(lines: readonly string[], opts?: { force?: boolean }): void
39
+ /** Permanently remove the status display; later set* is a no-op. */
40
+ clearStatus(): void
41
+ }
42
+
43
+ export interface OutputWriterOptions {
44
+ /** Master switch on top of the TTY check (false in CI). */
45
+ enabled?: boolean
46
+ /** Minimum interval between unforced redraws. */
47
+ minRedrawMs?: number
48
+ /**
49
+ * Floor between FORCED redraws. Task events force a redraw each; a
50
+ * 3,270-task warm run produced 6,540 forced redraws ≈ 6.7 MB of
51
+ * ANSI to the terminal. A forced set inside the floor marks the
52
+ * content dirty and ONE trailing draw lands the final state when
53
+ * the floor expires. 0 disables (tests asserting synchronously).
54
+ */
55
+ forceFloorMs?: number
56
+ /** Injectable clock for tests. */
57
+ now?: () => number
58
+ }
59
+
60
+ export function createOutputWriter(
61
+ stream: StatusStream,
62
+ opts: OutputWriterOptions = {},
63
+ ): OutputWriter {
64
+ const enabled = (opts.enabled ?? true) && stream.isTTY === true
65
+ const minRedrawMs = opts.minRedrawMs ?? 100
66
+ const forceFloorMs = opts.forceFloorMs ?? 30
67
+ const now = opts.now ?? Date.now
68
+
69
+ let current: readonly string[] | null = null
70
+ let shown = false
71
+ let shownHeight = 0
72
+ let dead = false
73
+ let lastDraw = -Infinity
74
+ // Trailing draw scheduled when a forced set lands inside the floor.
75
+ let trailing: ReturnType<typeof setTimeout> | null = null
76
+ // Streamed task output (focused mode) can end mid-line; redrawing
77
+ // the status display would wipe the partial line, so we hold off
78
+ // until a write restores column 0.
79
+ let atLineStart = true
80
+
81
+ const cancelTrailing = (): void => {
82
+ if (trailing !== null) {
83
+ clearTimeout(trailing)
84
+ trailing = null
85
+ }
86
+ }
87
+
88
+ // Erase sequence for whatever is currently shown. Single line keeps
89
+ // the exact legacy bytes (ESC[2K\r); a taller region moves to its
90
+ // top line and clears to end of screen.
91
+ const eraseSeq = (): string => (shownHeight > 1 ? `\r\x1b[${shownHeight - 1}A\x1b[J` : CLEAR)
92
+
93
+ const draw = (): void => {
94
+ // Whatever was pending is now painted — the trailing draw would
95
+ // only repeat these bytes.
96
+ cancelTrailing()
97
+ const erase = shown ? eraseSeq() : CLEAR
98
+ stream.write(erase + current!.join('\n'))
99
+ shown = true
100
+ shownHeight = current!.length
101
+ lastDraw = now()
102
+ }
103
+ const set = (lines: readonly string[], o: { force?: boolean }): void => {
104
+ if (!enabled || dead) return
105
+ current = lines
106
+ if (!atLineStart) return
107
+ const since = now() - lastDraw
108
+ if (o.force) {
109
+ if (since >= forceFloorMs) {
110
+ draw()
111
+ return
112
+ }
113
+ // Coalesce the burst: `current` is already the latest state, so
114
+ // one trailing draw at floor expiry lands it. unref — a stray
115
+ // timer must never hold the process open.
116
+ if (trailing === null) {
117
+ trailing = setTimeout(() => {
118
+ trailing = null
119
+ if (dead || current === null || !atLineStart) return
120
+ draw()
121
+ }, forceFloorMs - since)
122
+ trailing.unref?.()
123
+ }
124
+ return
125
+ }
126
+ if (since >= minRedrawMs) draw()
127
+ }
128
+
129
+ return {
130
+ enabled,
131
+ write(chunk) {
132
+ if (!enabled) {
133
+ stream.write(chunk)
134
+ return
135
+ }
136
+ if (shown) {
137
+ stream.write(eraseSeq())
138
+ shown = false
139
+ shownHeight = 0
140
+ }
141
+ stream.write(chunk)
142
+ if (chunk.length > 0) atLineStart = chunk.endsWith('\n')
143
+ if (!dead && current !== null && atLineStart) draw()
144
+ },
145
+ setStatus(line, o = {}) {
146
+ set([line], o)
147
+ },
148
+ setRegion(lines, o = {}) {
149
+ set(lines, o)
150
+ },
151
+ clearStatus() {
152
+ if (!enabled || dead) return
153
+ dead = true
154
+ current = null
155
+ cancelTrailing()
156
+ if (shown) {
157
+ stream.write(eraseSeq())
158
+ shown = false
159
+ shownHeight = 0
160
+ }
161
+ },
162
+ }
163
+ }
164
+
165
+ const IDLE = '#6b7280'
166
+
167
+ export interface WorkerSlot {
168
+ id: string
169
+ startedMs: number
170
+ }
171
+
172
+ export interface StatusRegionState {
173
+ /**
174
+ * Ready persistent tasks (dev servers …) whose children keep
175
+ * running after their outcome lands. Pinned until runEnd — the
176
+ * orchestrator SIGTERMs them when the graph finishes, so runEnd is
177
+ * the honest end of their life.
178
+ */
179
+ pinnedPersistent: readonly string[]
180
+ /** Fixed-size slot array; null = idle worker. Index = display row. */
181
+ slots: readonly (WorkerSlot | null)[]
182
+ /** Running tasks beyond the displayed slots. */
183
+ overflow: number
184
+ /** Clock value used for per-slot elapsed (injectable for tests). */
185
+ nowMs: number
186
+ /**
187
+ * The live summary section (built by the logger via
188
+ * formatSummarySection) — the SAME section the final summary
189
+ * prints, filling in as the run progresses. Rendered verbatim
190
+ * below the worker rows.
191
+ */
192
+ summaryLines: readonly string[]
193
+ }
194
+
195
+ /** Identity-colored id, full (never truncated) — name is the last column. */
196
+ function paintPinnedId(id: string, colors: ColorSupport): string {
197
+ const sep = id.indexOf('#')
198
+ if (sep < 0) return id
199
+ return paintIdParts(id.slice(0, sep), id.slice(0, sep), id.slice(sep + 1), colors)
200
+ }
201
+
202
+ /**
203
+ * Permanent one-liner logged the moment a task fails (owner design:
204
+ * "log the failure and continue, full frames at the end"). On the grid:
205
+ * red ◼ glyph + exec time + `failed` + `miss` + id. The exit code and
206
+ * output live in the framed block that replays at runEnd, not the line.
207
+ */
208
+ export function formatFailureLine(
209
+ id: string,
210
+ durationMs: number | null,
211
+ colors: ColorSupport = NO_COLOR,
212
+ ): string {
213
+ return formatTaskRow(
214
+ paint(ERROR, '◼\uFE0E', colors),
215
+ durationMs,
216
+ 'failed',
217
+ ERROR,
218
+ 'miss',
219
+ '',
220
+ paintPinnedId(id, colors),
221
+ colors,
222
+ )
223
+ }
224
+
225
+ /**
226
+ * Worker region: pinned ready persistent tasks, one row per worker
227
+ * slot — a task stays in its slot for its whole life so names never
228
+ * jump — then the live summary section. Every row sits on the shared
229
+ * column grid (see formatTaskRow), so nothing shifts with id length:
230
+ * layout shift IS the bug this display exists to fix. Height varies
231
+ * only when pins arrive.
232
+ */
233
+ export function formatStatusRegion(
234
+ s: StatusRegionState,
235
+ colors: ColorSupport = NO_COLOR,
236
+ ): string[] {
237
+ const dim = (t: string) => paint('', t, colors, { dim: true })
238
+ // Leading blank separates the live running region from the completed
239
+ // task list scrolling above it (owner: "separate running from list").
240
+ const lines: string[] = ['']
241
+ // Persistent (dev-server) rows: ▸ glyph, no elapsed, `running`, no
242
+ // cache state (it's still alive).
243
+ for (const id of s.pinnedPersistent) {
244
+ lines.push(
245
+ formatTaskRow(
246
+ paint(ACCENT, '▸', colors),
247
+ null,
248
+ 'running',
249
+ ACCENT,
250
+ '',
251
+ '',
252
+ paintPinnedId(id, colors),
253
+ colors,
254
+ ),
255
+ )
256
+ }
257
+
258
+ // Worker rows: NO glyph — the live elapsed time leads (no spinner;
259
+ // the ticking time IS the motion), then `running`, full id (never
260
+ // truncated — name is the last column). Idle rows hold their slot's
261
+ // place, dim, aligned under the status column.
262
+ for (const slot of s.slots) {
263
+ if (slot === null) {
264
+ lines.push(`${' '.repeat(3 + TIME_COL + 1)}${paint(IDLE, 'idle', colors, { dim: true })}`)
265
+ continue
266
+ }
267
+ lines.push(
268
+ formatTaskRow(
269
+ ' ',
270
+ Math.max(0, s.nowMs - slot.startedMs),
271
+ 'running',
272
+ ACCENT,
273
+ '',
274
+ '',
275
+ paintPinnedId(slot.id, colors),
276
+ colors,
277
+ ),
278
+ )
279
+ }
280
+ if (s.overflow > 0) {
281
+ lines.push(dim(`\u2026 +${s.overflow} more running`))
282
+ }
283
+ // The live summary section — identical shape to the final printout,
284
+ // so the region visually BECOMES the summary when the run ends.
285
+ lines.push(...s.summaryLines)
286
+ return lines
287
+ }