@vzn/vx 0.0.15 → 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.
Files changed (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,171 @@
1
+ // `--download` policy: which tasks may leave their outputs in the remote
2
+ // store instead of landing them on this machine, and which must not.
3
+ //
4
+ // See docs/design/download-policy-cas-cache-2026-08.md. Two decisions live
5
+ // here, both made ONCE per run at plan time (never per attempt), so
6
+ // `--dry` can show them and the scheduler never re-derives them.
7
+
8
+ import { isGroupTask, type TaskNode } from '../graph/index.js'
9
+ import { staticPrefix } from '../util/index.js'
10
+
11
+ /** Where a task's outputs go. `never` is `exec.remote: 'only'`. */
12
+ export type DownloadMode = 'eager' | 'deferred' | 'never'
13
+
14
+ /**
15
+ * Deferral eligibility: the tasks whose outputs NO key in this run could
16
+ * observe on disk. Returns the INELIGIBLE ids mapped to the reason, so a
17
+ * downgrade can name itself in `--dry`.
18
+ *
19
+ * The channel this closes is narrow and specific. A dependent's key folds
20
+ * an upstream's KEY, never its output content (pure-input transitive
21
+ * hashing), so deferral cannot move a dependent's key that way. The one
22
+ * real channel is a task whose `cache.inputs` globs can MATCH a producer's
23
+ * declared outputs on disk: then its key differs by whether the bytes
24
+ * arrived.
25
+ *
26
+ * DELIBERATELY TIGHTER than "any task sharing the producer's project"
27
+ * (the shape the design doc first sketched): that rule fires whenever a
28
+ * sibling reads the project at all, which is every ordinary workspace —
29
+ * `test` reads `src/**` while `build` writes `dist/**` — and would leave
30
+ * `--download=none` with nothing to defer. Comparing the globs' static
31
+ * prefixes answers the question actually being asked, and stays
32
+ * conservative in every direction that matters: a leading wildcard yields
33
+ * `.` and reaches everything, a cacheable task with no declared `files`
34
+ * is treated as reading its whole project, and `workspaceFiles` on either
35
+ * side ignores project boundaries — all three force ineligible.
36
+ */
37
+ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, string> {
38
+ const ineligible = new Map<string, string>()
39
+
40
+ const readersByProject = new Map<string, { taskId: string; prefixes: string[] }[]>()
41
+ let workspaceReader: string | undefined
42
+ let runtimeReader: string | undefined
43
+ for (const n of nodes.values()) {
44
+ const cache = n.config.cache
45
+ if (cache === undefined) continue
46
+ if ((cache.inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
47
+ // A `runtime` input is a SHELL COMMAND whose reads are unknowable —
48
+ // the same reason vx refuses to infer inputs by tracing. It can `cat` a
49
+ // producer's output (or path-escape its project to do it), and its
50
+ // stdout is folded into the key, so its answer would differ by whether
51
+ // the bytes were fetched. Deferral makes that sharper than it already
52
+ // was: it deliberately skips the output clean, so a stale prior build
53
+ // is exactly what such a command would sample. Nothing defers in a run
54
+ // that declares one — worse than nothing would be a key that moves with
55
+ // a transfer flag.
56
+ if (
57
+ (cache.inputs?.runtime?.length ?? 0) > 0 ||
58
+ (cache.inputs?.workspaceRuntime?.length ?? 0) > 0
59
+ ) {
60
+ runtimeReader ??= n.id
61
+ }
62
+ const files = cache.inputs?.files
63
+ const prefixes = files === undefined || files.length === 0 ? ['.'] : files.map(staticPrefix)
64
+ const list = readersByProject.get(n.projectName)
65
+ if (list) list.push({ taskId: n.id, prefixes })
66
+ else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes }])
67
+ }
68
+
69
+ for (const n of nodes.values()) {
70
+ const outputs = n.config.cache?.outputs
71
+ if ((outputs?.workspaceFiles?.length ?? 0) > 0) {
72
+ ineligible.set(
73
+ n.id,
74
+ 'declares cache.outputs.workspaceFiles — root-anchored outputs can land where any project-relative input reads',
75
+ )
76
+ continue
77
+ }
78
+ const files = outputs?.files ?? []
79
+ if (files.length === 0) continue
80
+ if (runtimeReader !== undefined) {
81
+ ineligible.set(
82
+ n.id,
83
+ `${runtimeReader} declares a cache.inputs.runtime command, whose reads cannot be bounded`,
84
+ )
85
+ continue
86
+ }
87
+ if (workspaceReader !== undefined) {
88
+ ineligible.set(
89
+ n.id,
90
+ `${workspaceReader} declares cache.inputs.workspaceFiles, which can read any project`,
91
+ )
92
+ continue
93
+ }
94
+ const outPrefixes = files.map(staticPrefix)
95
+ // Project boundaries are hard, so only a SAME-project reader can reach a
96
+ // project-relative output. A task never observes its own outputs — they
97
+ // are excluded from its own key by construction.
98
+ for (const reader of readersByProject.get(n.projectName) ?? []) {
99
+ if (reader.taskId === n.id) continue
100
+ const clash = reader.prefixes.find((ip) => outPrefixes.some((op) => prefixesOverlap(ip, op)))
101
+ if (clash !== undefined) {
102
+ ineligible.set(
103
+ n.id,
104
+ `${reader.taskId} reads ${clash === '.' ? 'the whole project' : clash} with cache.inputs.files`,
105
+ )
106
+ break
107
+ }
108
+ }
109
+ }
110
+ return ineligible
111
+ }
112
+
113
+ /** Could two glob prefixes, anchored at the same dir, name overlapping paths? */
114
+ function prefixesOverlap(a: string, b: string): boolean {
115
+ if (a === '.' || b === '.' || a === '' || b === '' || a === '/' || b === '/') return true
116
+ if (a === b) return true
117
+ return a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
118
+ }
119
+
120
+ /**
121
+ * The effective per-task download mode. `all` (the default) reproduces
122
+ * today's behaviour exactly: every task eager.
123
+ */
124
+ export function resolveDownloadModes(args: {
125
+ nodes: Map<string, TaskNode>
126
+ policy: 'all' | 'toplevel' | 'none'
127
+ /** Task ids placed on a LOCAL executor — they write in place. */
128
+ localPlaced: ReadonlySet<string>
129
+ /** Task ids with `exec.remote: 'only'`. */
130
+ remoteOnly: ReadonlySet<string>
131
+ }): { modeOf: Map<string, DownloadMode>; downgrades: Map<string, string> } {
132
+ const modeOf = new Map<string, DownloadMode>()
133
+ const downgrades = new Map<string, string>()
134
+ const ineligible = args.policy === 'all' ? new Map() : deferralEligibility(args.nodes)
135
+
136
+ for (const n of args.nodes.values()) {
137
+ if (isGroupTask(n)) continue
138
+ if (args.remoteOnly.has(n.id)) {
139
+ modeOf.set(n.id, 'never')
140
+ continue
141
+ }
142
+ if (args.policy === 'all' || args.localPlaced.has(n.id)) {
143
+ modeOf.set(n.id, 'eager')
144
+ continue
145
+ }
146
+ // `toplevel` = the outputs you ASKED for come home; intermediates stay
147
+ // remote. Decided here rather than by materialising at run end: eager
148
+ // materialisation rides each task's own completion, overlapped with the
149
+ // rest of the run, where a run-end batch would serialise every download
150
+ // after the last task finishes.
151
+ //
152
+ // SURFACED counts as asked-for. A requested GROUP has no outputs of its
153
+ // own — `markSurfacedDeps` marks the real tasks it chains — so keying on
154
+ // `requested` alone made `vx run ci --download=toplevel` (ci being a
155
+ // group) bring home NOTHING, which is the one thing the mode exists to
156
+ // avoid. run.ts already pairs the two flags for the same reason when it
157
+ // decides which persistent children to keep alive.
158
+ if (args.policy === 'toplevel' && (n.requested === true || n.surfaced === true)) {
159
+ modeOf.set(n.id, 'eager')
160
+ continue
161
+ }
162
+ const reason = ineligible.get(n.id)
163
+ if (reason !== undefined) {
164
+ modeOf.set(n.id, 'eager')
165
+ downgrades.set(n.id, reason)
166
+ continue
167
+ }
168
+ modeOf.set(n.id, 'deferred')
169
+ }
170
+ return { modeOf, downgrades }
171
+ }
@@ -18,6 +18,11 @@ export interface RunStartInfo {
18
18
  requestedCount?: number
19
19
  /** Run banner context so a live footer can match the final summary. */
20
20
  context?: RunContext
21
+ /** The run's canonical start (epoch ms) — the SAME value the end-of-run
22
+ * summary reports as `startedAt`. A telemetry sink needs it during the run
23
+ * (e.g. to derive per-task started_at consistently with the batch summary
24
+ * for idempotent incremental ingest). */
25
+ startedAtMs?: number
21
26
  }
22
27
 
23
28
  /**
@@ -149,6 +154,14 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
149
154
  // lines, so a consumer that renders them (the serve client) gets the
150
155
  // footer. A consumer that ignores run:status (the dev hub) is unaffected.
151
156
  let endForwarded = false
157
+ // Which tasks the consumer has been told about. A skipped task never
158
+ // reaches the scheduler's onStart, so its completion arrives with no
159
+ // preceding task:start — and `createWireRenderer` resolves a completion's
160
+ // node from the start it recorded, so it would drop the task entirely
161
+ // while the forwarded footer still counted it. Synthesize the start here,
162
+ // where the live TaskNode is in hand, so the projection stays full
163
+ // fidelity (real requested/surfaced/command) instead of a stand-in.
164
+ const started = new Set<string>()
152
165
  return (event) => {
153
166
  if (event.kind === 'run:end') {
154
167
  if (endForwarded) return
@@ -158,6 +171,11 @@ export function wireForwarder(send: (event: WireEvent) => void): RunEventSubscri
158
171
  isGroupTask(event.node)
159
172
  ) {
160
173
  return
174
+ } else if (event.kind === 'task:start') {
175
+ started.add(event.node.id)
176
+ } else if (event.kind === 'task:complete' && !started.has(event.node.id)) {
177
+ started.add(event.node.id)
178
+ send({ kind: 'task:start', task: projectNode(event.node) })
161
179
  }
162
180
  send(toWireEvent(event))
163
181
  }
@@ -195,6 +213,16 @@ export interface OutcomeView {
195
213
  status: TaskOutcome['status']
196
214
  exitCode: number
197
215
  durationMs: number
216
+ /**
217
+ * Group node (no `exec`) — organizational, not work. `TaskView` carries
218
+ * this too, but a consumer that only holds outcomes (the `--report`
219
+ * writer) has no node to ask, and counting groups as executed tasks is
220
+ * exactly how it drifted from the terminal and `--summarize`.
221
+ */
222
+ isGroup?: boolean
223
+ /** Cache hits only: the exec time the entry was stored with — the work
224
+ * this hit skipped, as opposed to `durationMs`, the restore it cost. */
225
+ storedDurationMs?: number
198
226
  hash?: string
199
227
  cpuMs?: number
200
228
  peakRssBytes?: number
@@ -227,6 +255,8 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
227
255
  exitCode: outcome.exitCode,
228
256
  durationMs: outcome.durationMs,
229
257
  }
258
+ if (isGroupTask(outcome.node)) view.isGroup = true
259
+ if (outcome.storedDurationMs !== undefined) view.storedDurationMs = outcome.storedDurationMs
230
260
  if (outcome.hash !== undefined) view.hash = outcome.hash
231
261
  if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
232
262
  if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes