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