@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
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
// Predictive scheduling — use HistoryTable to compute expected
|
|
2
|
-
// remaining critical-path duration per node. Replaces the scheduler's
|
|
3
|
-
// graph-counter heuristic priority for runs that opt in via
|
|
4
|
-
// defineWorkspace({ predictive: true }) (predictive-execution-2026-06.md
|
|
5
|
-
// Phase B + architecture-review-2026-06.md §8.4).
|
|
6
|
-
//
|
|
7
|
-
// Pure functions; no side effects; tested standalone. The scheduler
|
|
8
|
-
// reads the priorities at queue time, picks the max.
|
|
9
|
-
|
|
10
|
-
import type { TaskNode } from '../graph/index.js'
|
|
11
|
-
import type { HistoryTable } from './history.js'
|
|
12
|
-
|
|
13
|
-
/** Default duration when neither task history nor workspace median exists. */
|
|
14
|
-
const DEFAULT_DURATION_MS = 1000
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* For each node, the expected remaining critical-path duration (its own
|
|
18
|
-
* p50 + the max over its dependents). Cache hits are NOT modeled as
|
|
19
|
-
* zero-cost here because predicting cache state requires knowing the
|
|
20
|
-
* key + probing the layer — the run-time scheduler can flip the
|
|
21
|
-
* priority once a hit is observed; the upfront estimate is "what if
|
|
22
|
-
* everything ran."
|
|
23
|
-
*/
|
|
24
|
-
export function computePredictedPriorities(
|
|
25
|
-
nodes: readonly TaskNode[],
|
|
26
|
-
history: HistoryTable,
|
|
27
|
-
): ReadonlyMap<string, number> {
|
|
28
|
-
// Workspace-median fallback: across every history entry that has a
|
|
29
|
-
// p50, pick the median of those p50s. If history is sparse, fall
|
|
30
|
-
// back to DEFAULT_DURATION_MS.
|
|
31
|
-
const p50s: number[] = []
|
|
32
|
-
for (const h of history.values()) {
|
|
33
|
-
if (h.p50DurationMs !== undefined) p50s.push(h.p50DurationMs)
|
|
34
|
-
}
|
|
35
|
-
p50s.sort((a, b) => a - b)
|
|
36
|
-
const workspaceMedian =
|
|
37
|
-
p50s.length > 0
|
|
38
|
-
? (p50s[Math.floor(p50s.length / 2)] ?? DEFAULT_DURATION_MS)
|
|
39
|
-
: DEFAULT_DURATION_MS
|
|
40
|
-
|
|
41
|
-
// Build a reverse-adjacency map so we can resolve dependents per
|
|
42
|
-
// node in O(1). The TaskNode graph carries dependsOn (upstream); we
|
|
43
|
-
// invert.
|
|
44
|
-
const dependentsOf = buildDependentsIndex(nodes)
|
|
45
|
-
const ownDuration = (n: TaskNode): number => {
|
|
46
|
-
const h = history.get(n.id)
|
|
47
|
-
return h?.p50DurationMs ?? workspaceMedian
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Memoized topo-DP. visit[id] = the expected critical path FROM this
|
|
51
|
-
// node down to a leaf. We compute bottom-up using a stack-based
|
|
52
|
-
// walker — iterative to avoid V8 stack-frame ceilings on deep graphs.
|
|
53
|
-
const memo = new Map<string, number>()
|
|
54
|
-
const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
|
|
55
|
-
|
|
56
|
-
const stack: TaskNode[] = nodes.slice()
|
|
57
|
-
// First pass: ensure deepest-first ordering via post-order traversal.
|
|
58
|
-
const order: TaskNode[] = []
|
|
59
|
-
const visited = new Set<string>()
|
|
60
|
-
while (stack.length > 0) {
|
|
61
|
-
const top = stack[stack.length - 1]!
|
|
62
|
-
if (visited.has(top.id)) {
|
|
63
|
-
stack.pop()
|
|
64
|
-
continue
|
|
65
|
-
}
|
|
66
|
-
const deps = dependentsOf.get(top.id) ?? []
|
|
67
|
-
let pushed = false
|
|
68
|
-
for (const d of deps) {
|
|
69
|
-
const n = nodeById.get(d)
|
|
70
|
-
if (n && !visited.has(n.id) && !stack.includes(n)) {
|
|
71
|
-
stack.push(n)
|
|
72
|
-
pushed = true
|
|
73
|
-
break
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
if (!pushed) {
|
|
77
|
-
visited.add(top.id)
|
|
78
|
-
order.push(top)
|
|
79
|
-
stack.pop()
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
for (const n of order) {
|
|
84
|
-
const own = ownDuration(n)
|
|
85
|
-
let downstream = 0
|
|
86
|
-
for (const dep of dependentsOf.get(n.id) ?? []) {
|
|
87
|
-
const d = memo.get(dep) ?? 0
|
|
88
|
-
if (d > downstream) downstream = d
|
|
89
|
-
}
|
|
90
|
-
memo.set(n.id, own + downstream)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return memo
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function buildDependentsIndex(nodes: readonly TaskNode[]): Map<string, string[]> {
|
|
97
|
-
const out = new Map<string, string[]>()
|
|
98
|
-
for (const n of nodes) {
|
|
99
|
-
for (const upstreamId of n.deps) {
|
|
100
|
-
const list = out.get(upstreamId)
|
|
101
|
-
if (list) list.push(n.id)
|
|
102
|
-
else out.set(upstreamId, [n.id])
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return out
|
|
106
|
-
}
|
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
// The client↔service wire contract. A `vx run` client submits a
|
|
2
|
-
// `RunRequest`; the service streams `WireEvent`s and returns a
|
|
3
|
-
// `RunResult`. Transport-agnostic by design — the same messages travel a
|
|
4
|
-
// local unix/ws hop today or a hosted `wss://` link tomorrow. Pure types +
|
|
5
|
-
// the RunOptions⇄RunRequest mapping; no transport, no execution here.
|
|
6
|
-
|
|
7
|
-
import type { CachePolicy } from '../cache/index.js'
|
|
8
|
-
import type { ContinueMode } from '../graph/index.js'
|
|
9
|
-
import type { OutcomeView, WireEvent } from './events.js'
|
|
10
|
-
import type { RunOptions } from './options.js'
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* The serializable subset of `RunOptions` a client sends to the service.
|
|
14
|
-
* Omits the non-serializable / host-side fields (`log`, `bus`,
|
|
15
|
-
* `handleSignals`) — those are the service's concern.
|
|
16
|
-
*/
|
|
17
|
-
export interface RunRequest {
|
|
18
|
-
tasks: readonly string[]
|
|
19
|
-
cwd: string
|
|
20
|
-
projects?: readonly string[]
|
|
21
|
-
concurrency?: number
|
|
22
|
-
cache?: CachePolicy
|
|
23
|
-
/** `--cache-dir <path>` override (cwd-relative). */
|
|
24
|
-
cacheDir?: string
|
|
25
|
-
continueMode?: ContinueMode
|
|
26
|
-
frozen?: boolean
|
|
27
|
-
/** Run-level retry default (`--retry <n>`); explicit `exec.retries` wins. */
|
|
28
|
-
retries?: number
|
|
29
|
-
/** Run-level task-timeout default (ms); per-task `exec.timeout` wins. */
|
|
30
|
-
timeout?: number
|
|
31
|
-
/** Explicit `--memory` budget (bytes) for `exec.resources` packing; the
|
|
32
|
-
* default (executing side's os.totalmem()) resolves where tasks run. */
|
|
33
|
-
memory?: number
|
|
34
|
-
/** Cache-correctness verification (`--verify`); `allow` as an array (Sets
|
|
35
|
-
* don't serialize). `fingerprint` is additive-optional — an old serve
|
|
36
|
-
* ignores it, so a delegated `=fingerprint` run against an old serve
|
|
37
|
-
* degrades to a harmless no-op verify. */
|
|
38
|
-
verify?: {
|
|
39
|
-
determinism: boolean
|
|
40
|
-
inputs: boolean
|
|
41
|
-
fingerprint?: boolean
|
|
42
|
-
allow: readonly string[]
|
|
43
|
-
}
|
|
44
|
-
flow?: 'focused' | 'broad'
|
|
45
|
-
outputLogs?: 'full' | 'errors-only' | 'none'
|
|
46
|
-
excludeDependencies?: 'all' | readonly string[]
|
|
47
|
-
forwardArgs?: readonly string[]
|
|
48
|
-
summarize?: string
|
|
49
|
-
profile?: string
|
|
50
|
-
/** Free-form key/value labels from `--tag`, persisted on the invocation row. */
|
|
51
|
-
tags?: Record<string, string>
|
|
52
|
-
/** The raw `vx run …` command string, recorded on the invocation row. */
|
|
53
|
-
command?: string
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface RunResult {
|
|
57
|
-
ok: boolean
|
|
58
|
-
outcomes: OutcomeView[]
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Where a `vx run` executes. The client submits a serializable
|
|
63
|
-
* `RunRequest` and gets back a `RunResult`; it neither knows nor cares
|
|
64
|
-
* whether the work happened in-process or was delegated to a service.
|
|
65
|
-
* This is the currency of the `backend` plugin capability — part of the
|
|
66
|
-
* public wire contract (it lives here, not in `cli`, so a plugin can
|
|
67
|
-
* reference it without a `cli` import).
|
|
68
|
-
*/
|
|
69
|
-
export interface RunBackend {
|
|
70
|
-
run(request: RunRequest): Promise<RunResult>
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* service → client. The submitter contract: an event stream, a final
|
|
75
|
-
* result, or an error. The distributed-execution variants
|
|
76
|
-
* (`task:assign`/`agent:refused`/`coord:drain`) live in a distribution
|
|
77
|
-
* plugin's protocol module — its WS handler unions this with those.
|
|
78
|
-
*/
|
|
79
|
-
export type ServerMessage =
|
|
80
|
-
| { t: 'event'; event: WireEvent }
|
|
81
|
-
| { t: 'result'; result: RunResult }
|
|
82
|
-
| { t: 'error'; message: string }
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* client → service. The submitter contract: a run submission. The
|
|
86
|
-
* agent-side `agent:*` family lives in a distribution plugin's protocol
|
|
87
|
-
* module.
|
|
88
|
-
*/
|
|
89
|
-
export type ClientMessage = { t: 'run'; request: RunRequest }
|
|
90
|
-
|
|
91
|
-
/** Project resolved `RunOptions` down to the serializable request. */
|
|
92
|
-
export function optionsToRequest(options: RunOptions): RunRequest {
|
|
93
|
-
const req: RunRequest = { tasks: options.tasks, cwd: options.cwd }
|
|
94
|
-
if (options.projects !== undefined) req.projects = options.projects
|
|
95
|
-
if (options.concurrency !== undefined) req.concurrency = options.concurrency
|
|
96
|
-
if (options.cache !== undefined) req.cache = options.cache
|
|
97
|
-
if (options.cacheDir !== undefined) req.cacheDir = options.cacheDir
|
|
98
|
-
if (options.continueMode !== undefined) req.continueMode = options.continueMode
|
|
99
|
-
if (options.frozen !== undefined) req.frozen = options.frozen
|
|
100
|
-
if (options.retries !== undefined) req.retries = options.retries
|
|
101
|
-
if (options.timeout !== undefined) req.timeout = options.timeout
|
|
102
|
-
if (options.memory !== undefined) req.memory = options.memory
|
|
103
|
-
if (options.verify !== undefined)
|
|
104
|
-
req.verify = {
|
|
105
|
-
determinism: options.verify.determinism,
|
|
106
|
-
inputs: options.verify.inputs,
|
|
107
|
-
fingerprint: options.verify.fingerprint,
|
|
108
|
-
allow: [...options.verify.allow],
|
|
109
|
-
}
|
|
110
|
-
if (options.flow !== undefined) req.flow = options.flow
|
|
111
|
-
if (options.outputLogs !== undefined) req.outputLogs = options.outputLogs
|
|
112
|
-
if (options.excludeDependencies !== undefined)
|
|
113
|
-
req.excludeDependencies = options.excludeDependencies
|
|
114
|
-
if (options.forwardArgs !== undefined) req.forwardArgs = options.forwardArgs
|
|
115
|
-
if (options.summarize !== undefined) req.summarize = options.summarize
|
|
116
|
-
if (options.profile !== undefined) req.profile = options.profile
|
|
117
|
-
if (options.tags !== undefined) req.tags = options.tags
|
|
118
|
-
if (options.command !== undefined) req.command = options.command
|
|
119
|
-
return req
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Rebuild `RunOptions` from a request, for the side that actually executes
|
|
124
|
-
* (the local backend, or the service). The caller adds the host-side
|
|
125
|
-
* fields it needs (`bus`, `log`, `handleSignals`).
|
|
126
|
-
*/
|
|
127
|
-
export function requestToOptions(request: RunRequest): RunOptions {
|
|
128
|
-
const options: RunOptions = { cwd: request.cwd, tasks: [...request.tasks] }
|
|
129
|
-
if (request.projects !== undefined) options.projects = [...request.projects]
|
|
130
|
-
if (request.concurrency !== undefined) options.concurrency = request.concurrency
|
|
131
|
-
if (request.cache !== undefined) options.cache = request.cache
|
|
132
|
-
if (request.cacheDir !== undefined) options.cacheDir = request.cacheDir
|
|
133
|
-
if (request.continueMode !== undefined) options.continueMode = request.continueMode
|
|
134
|
-
if (request.frozen !== undefined) options.frozen = request.frozen
|
|
135
|
-
if (request.retries !== undefined) options.retries = request.retries
|
|
136
|
-
if (request.timeout !== undefined) options.timeout = request.timeout
|
|
137
|
-
if (request.memory !== undefined) options.memory = request.memory
|
|
138
|
-
if (request.verify !== undefined)
|
|
139
|
-
options.verify = {
|
|
140
|
-
determinism: request.verify.determinism,
|
|
141
|
-
inputs: request.verify.inputs,
|
|
142
|
-
fingerprint: request.verify.fingerprint ?? false,
|
|
143
|
-
allow: new Set(request.verify.allow),
|
|
144
|
-
}
|
|
145
|
-
if (request.flow !== undefined) options.flow = request.flow
|
|
146
|
-
if (request.outputLogs !== undefined) options.outputLogs = request.outputLogs
|
|
147
|
-
if (request.excludeDependencies !== undefined) {
|
|
148
|
-
options.excludeDependencies = request.excludeDependencies
|
|
149
|
-
}
|
|
150
|
-
if (request.forwardArgs !== undefined) options.forwardArgs = request.forwardArgs
|
|
151
|
-
if (request.summarize !== undefined) options.summarize = request.summarize
|
|
152
|
-
if (request.profile !== undefined) options.profile = request.profile
|
|
153
|
-
if (request.tags !== undefined) options.tags = request.tags
|
|
154
|
-
if (request.command !== undefined) options.command = request.command
|
|
155
|
-
return options
|
|
156
|
-
}
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
// Resolve `exec.resources` declarations into absolute per-task costs the
|
|
2
|
-
// scheduler's 2-D admission gate packs against. Pure and budget-
|
|
3
|
-
// parameterized — percent forms resolve here, ONCE per run, so the
|
|
4
|
-
// scheduler's inner loop only ever does a Map.get. The loader has already
|
|
5
|
-
// validated every form (this module trusts internal code); an
|
|
6
|
-
// unparseable value can't reach it from a real config.
|
|
7
|
-
|
|
8
|
-
import type { ResourceCost, TaskNode } from '../graph/index.js'
|
|
9
|
-
import { parseSize } from '../util/index.js'
|
|
10
|
-
|
|
11
|
-
const PERCENT_RE = /^(\d+(?:\.\d+)?)%$/
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* `cpus` → CPU units. A number is itself (fractional ok); `"<n>%"` is
|
|
15
|
-
* percent of the CPU budget, kept fractional (rounding is display-only).
|
|
16
|
-
* Absent → 0 = reserve nothing.
|
|
17
|
-
*/
|
|
18
|
-
export function resolveCpu(v: number | string | undefined, cpuBudget: number): number {
|
|
19
|
-
if (v === undefined) return 0
|
|
20
|
-
if (typeof v === 'number') return v
|
|
21
|
-
const m = v.match(PERCENT_RE)
|
|
22
|
-
if (m) return (Number(m[1]) / 100) * cpuBudget
|
|
23
|
-
return 0
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* `memory` → bytes. A number is bytes; a size string goes through
|
|
28
|
-
* `parseSize` (`"2GB"`, `"512MB"`); `"<n>%"` is percent of the memory
|
|
29
|
-
* budget. Absent → 0 = reserve nothing.
|
|
30
|
-
*/
|
|
31
|
-
export function resolveMem(v: number | string | undefined, memBudget: number): number {
|
|
32
|
-
if (v === undefined) return 0
|
|
33
|
-
if (typeof v === 'number') return v
|
|
34
|
-
const m = v.match(PERCENT_RE)
|
|
35
|
-
if (m) return (Number(m[1]) / 100) * memBudget
|
|
36
|
-
return parseSize(v) ?? 0
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Resolve the whole graph's declared reservations, OMITTING zero-cost
|
|
41
|
-
* tasks: an empty map means "no reservations declared", the single gate
|
|
42
|
-
* the scheduler (and run.ts's option threading) keys off to keep every
|
|
43
|
-
* current run byte-identical.
|
|
44
|
-
*/
|
|
45
|
-
export function resolveResourceCosts(
|
|
46
|
-
nodes: ReadonlyMap<string, TaskNode>,
|
|
47
|
-
cpuBudget: number,
|
|
48
|
-
memBudget: number,
|
|
49
|
-
): Map<string, ResourceCost> {
|
|
50
|
-
const out = new Map<string, ResourceCost>()
|
|
51
|
-
for (const node of nodes.values()) {
|
|
52
|
-
const declared = node.config.exec?.resources
|
|
53
|
-
if (declared === undefined) continue
|
|
54
|
-
const cpu = resolveCpu(declared.cpus, cpuBudget)
|
|
55
|
-
const mem = resolveMem(declared.memory, memBudget)
|
|
56
|
-
if (cpu > 0 || mem > 0) out.set(node.id, { cpu, mem })
|
|
57
|
-
}
|
|
58
|
-
return out
|
|
59
|
-
}
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
// The derived run aggregate — the single view-model every surface holds.
|
|
2
|
-
// Raw RunEvents are the source of truth; this reduces them into the same
|
|
3
|
-
// counters/per-task status the terminal summary already renders, so a
|
|
4
|
-
// web/TUI/MCP surface can render reactively from one place instead of
|
|
5
|
-
// re-deriving. A devframe shared-state holds a RunState and re-reduces on
|
|
6
|
-
// each event; the terminal keeps its own inline counters (byte-identical,
|
|
7
|
-
// untouched). See docs/design/event-stream-2026-06.md.
|
|
8
|
-
|
|
9
|
-
import { isGroupTask, type TaskStatus } from '../graph/index.js'
|
|
10
|
-
import type { RunEvent } from './events.js'
|
|
11
|
-
|
|
12
|
-
/** Per-task lifecycle as seen by a surface. */
|
|
13
|
-
export type TaskState = 'running' | TaskStatus
|
|
14
|
-
|
|
15
|
-
export interface TaskRecord {
|
|
16
|
-
id: string
|
|
17
|
-
state: TaskState
|
|
18
|
-
/** Final duration once complete; absent while running. */
|
|
19
|
-
durationMs?: number
|
|
20
|
-
/** True for a persistent task left running after its outcome landed. */
|
|
21
|
-
persistent?: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Cache-miss duration spread — the same numbers the final summary shows. */
|
|
25
|
-
export interface Spread {
|
|
26
|
-
maxMs: number
|
|
27
|
-
minMs: number
|
|
28
|
-
sumMs: number
|
|
29
|
-
count: number
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface RunState {
|
|
33
|
-
total: number
|
|
34
|
-
done: number
|
|
35
|
-
failed: number
|
|
36
|
-
succeeded: number
|
|
37
|
-
upToDate: number
|
|
38
|
-
restoredLocal: number
|
|
39
|
-
restoredRemote: number
|
|
40
|
-
skipped: number
|
|
41
|
-
/** Ids currently executing (a worker holds them). */
|
|
42
|
-
running: string[]
|
|
43
|
-
/** Per-task records, keyed by id. Group tasks are excluded (no work). */
|
|
44
|
-
tasks: Record<string, TaskRecord>
|
|
45
|
-
spread: Spread | null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function initRunState(total = 0): RunState {
|
|
49
|
-
return {
|
|
50
|
-
total,
|
|
51
|
-
done: 0,
|
|
52
|
-
failed: 0,
|
|
53
|
-
succeeded: 0,
|
|
54
|
-
upToDate: 0,
|
|
55
|
-
restoredLocal: 0,
|
|
56
|
-
restoredRemote: 0,
|
|
57
|
-
skipped: 0,
|
|
58
|
-
running: [],
|
|
59
|
-
tasks: {},
|
|
60
|
-
spread: null,
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Fold one event into the aggregate, returning a NEW state (pure — never
|
|
66
|
-
* mutates `prev`, so it's safe to feed a devframe `sharedState.mutate`
|
|
67
|
-
* that diffs old vs new). Mirrors `defaultLogger`'s inline bookkeeping:
|
|
68
|
-
* group tasks (no exec) are ignored, an `aborted` task reverts to pending
|
|
69
|
-
* (freed, never counted), and the spread tracks success+failed only.
|
|
70
|
-
*/
|
|
71
|
-
export function reduce(prev: RunState, event: RunEvent): RunState {
|
|
72
|
-
switch (event.kind) {
|
|
73
|
-
case 'run:start':
|
|
74
|
-
return initRunState(event.info.total)
|
|
75
|
-
|
|
76
|
-
case 'task:start': {
|
|
77
|
-
if (isGroupTask(event.node)) return prev
|
|
78
|
-
const id = event.node.id
|
|
79
|
-
return {
|
|
80
|
-
...prev,
|
|
81
|
-
running: [...prev.running, id],
|
|
82
|
-
tasks: { ...prev.tasks, [id]: { id, state: 'running' } },
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
case 'task:complete': {
|
|
87
|
-
if (isGroupTask(event.node)) return prev
|
|
88
|
-
const id = event.node.id
|
|
89
|
-
const { status, durationMs } = event.outcome
|
|
90
|
-
const running = prev.running.filter((r) => r !== id)
|
|
91
|
-
|
|
92
|
-
// Aborted (killed by a shutdown signal): free the slot, count
|
|
93
|
-
// nothing, drop the record — the run is tearing down.
|
|
94
|
-
if (status === 'aborted') {
|
|
95
|
-
const tasks = { ...prev.tasks }
|
|
96
|
-
delete tasks[id]
|
|
97
|
-
return { ...prev, running, tasks }
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const persistent = event.node.config.exec?.persistent !== undefined && status === 'success'
|
|
101
|
-
const next: RunState = {
|
|
102
|
-
...prev,
|
|
103
|
-
done: prev.done + 1,
|
|
104
|
-
running,
|
|
105
|
-
tasks: {
|
|
106
|
-
...prev.tasks,
|
|
107
|
-
[id]: { id, state: status, durationMs, ...(persistent ? { persistent: true } : {}) },
|
|
108
|
-
},
|
|
109
|
-
}
|
|
110
|
-
switch (status) {
|
|
111
|
-
case 'success':
|
|
112
|
-
next.succeeded = prev.succeeded + 1
|
|
113
|
-
break
|
|
114
|
-
case 'cache-hit':
|
|
115
|
-
if (event.outcome.restored === false) next.upToDate = prev.upToDate + 1
|
|
116
|
-
else next.restoredLocal = prev.restoredLocal + 1
|
|
117
|
-
break
|
|
118
|
-
case 'cache-hit-remote':
|
|
119
|
-
if (event.outcome.restored === false) next.upToDate = prev.upToDate + 1
|
|
120
|
-
else next.restoredRemote = prev.restoredRemote + 1
|
|
121
|
-
break
|
|
122
|
-
case 'failed':
|
|
123
|
-
next.failed = prev.failed + 1
|
|
124
|
-
break
|
|
125
|
-
case 'skipped':
|
|
126
|
-
next.skipped = prev.skipped + 1
|
|
127
|
-
break
|
|
128
|
-
}
|
|
129
|
-
if (status === 'success' || status === 'failed') {
|
|
130
|
-
const s = prev.spread
|
|
131
|
-
next.spread = {
|
|
132
|
-
maxMs: Math.max(s?.maxMs ?? 0, durationMs),
|
|
133
|
-
minMs: Math.min(s?.minMs ?? Infinity, durationMs),
|
|
134
|
-
sumMs: (s?.sumMs ?? 0) + durationMs,
|
|
135
|
-
count: (s?.count ?? 0) + 1,
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
return next
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// stdout/stderr/status/end carry no aggregate signal.
|
|
142
|
-
default:
|
|
143
|
-
return prev
|
|
144
|
-
}
|
|
145
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
// Cache-correctness verification (Phase 1: determinism). Pure helpers for the
|
|
2
|
-
// `vx run --verify` hook in execute-task.ts: content-fingerprint a task's
|
|
3
|
-
// output tree (mtime-independent) and diff two runs' trees, so a task whose
|
|
4
|
-
// outputs are NOT a pure function of its declared inputs — a non-hermetic task
|
|
5
|
-
// whose cache entry would replay arbitrary past bytes — is caught and named.
|
|
6
|
-
// See docs/design/cache-correctness-2026-07.md.
|
|
7
|
-
|
|
8
|
-
import path from 'node:path'
|
|
9
|
-
import type { OutputFingerprint, TaskOutcome, VerifyVerdict } from '../graph/index.js'
|
|
10
|
-
import { xxh3hex } from '../util/index.js'
|
|
11
|
-
|
|
12
|
-
/** An output file to fingerprint: absolute path + a stable, run-independent
|
|
13
|
-
* key (project-relative, or `workspace-outputs/<rel-to-root>` for workspace
|
|
14
|
-
* outputs — matching the cache artifact's namespace so the two never collide). */
|
|
15
|
-
export interface OutputRef {
|
|
16
|
-
abs: string
|
|
17
|
-
key: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Build the fingerprint key set from a task's resolved output paths. */
|
|
21
|
-
export function outputRefs(
|
|
22
|
-
projectDir: string,
|
|
23
|
-
outputFiles: readonly string[],
|
|
24
|
-
workspaceRoot: string,
|
|
25
|
-
workspaceOutputFiles: readonly string[],
|
|
26
|
-
): OutputRef[] {
|
|
27
|
-
const rel = (from: string, abs: string): string =>
|
|
28
|
-
path.relative(from, abs).split(path.sep).join('/')
|
|
29
|
-
return [
|
|
30
|
-
...outputFiles.map((abs) => ({ abs, key: rel(projectDir, abs) })),
|
|
31
|
-
...workspaceOutputFiles.map((abs) => ({
|
|
32
|
-
abs,
|
|
33
|
-
key: `workspace-outputs/${rel(workspaceRoot, abs)}`,
|
|
34
|
-
})),
|
|
35
|
-
]
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Content-fingerprint an output tree: key → xxh3 of the file's BYTES, read
|
|
39
|
-
* unconditionally from disk. Deliberately NOT `Cache.hashFile`: its
|
|
40
|
-
* mtime+size memo would return attempt 1's digest for a re-run output with
|
|
41
|
-
* equal size and mtime (e.g. a build that normalizes mtimes — standard
|
|
42
|
-
* reproducible-build practice), silently proving a divergent task
|
|
43
|
-
* deterministic. fp1/fp2 are only ever compared to each other, so any
|
|
44
|
-
* content hash works — and the proof must not trust a cache. */
|
|
45
|
-
export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<string, string>> {
|
|
46
|
-
const out = new Map<string, string>()
|
|
47
|
-
for (const r of refs) {
|
|
48
|
-
out.set(r.key, xxh3hex(new Uint8Array(await Bun.file(r.abs).arrayBuffer())))
|
|
49
|
-
}
|
|
50
|
-
return out
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
|
|
54
|
-
* digest always folds ALL entries, so detection never depends on the map. */
|
|
55
|
-
export const FP_MAX_FILES = 500
|
|
56
|
-
|
|
57
|
-
/** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
|
|
58
|
-
* payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
|
|
59
|
-
* entries folded `key\0hash\n` (\0 boundaries so parts can't alias), plus
|
|
60
|
-
* the first-`cap` pairs — deterministic truncation, so two machines'
|
|
61
|
-
* truncated maps cover the same key subset and stay diffable. */
|
|
62
|
-
export function foldFingerprint(fp: Map<string, string>, cap = FP_MAX_FILES): OutputFingerprint {
|
|
63
|
-
const keys = [...fp.keys()].sort()
|
|
64
|
-
let folded = ''
|
|
65
|
-
for (const k of keys) folded += `${k}\0${fp.get(k)!}\n`
|
|
66
|
-
return {
|
|
67
|
-
tree: xxh3hex(folded),
|
|
68
|
-
fileCount: keys.length,
|
|
69
|
-
files: keys.slice(0, cap).map((k) => [k, fp.get(k)!] as const),
|
|
70
|
-
...(keys.length > cap ? { truncated: true } : {}),
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The output keys whose content OID differs between two fingerprints
|
|
75
|
-
* (added, removed, or changed), sorted. */
|
|
76
|
-
export function diffOutputTrees(a: Map<string, string>, b: Map<string, string>): string[] {
|
|
77
|
-
const changed: string[] = []
|
|
78
|
-
for (const k of new Set([...a.keys(), ...b.keys()])) {
|
|
79
|
-
if (a.get(k) !== b.get(k)) changed.push(k)
|
|
80
|
-
}
|
|
81
|
-
return changed.sort()
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Turn a determinism diff into a verdict, honoring the `--verify-allow`
|
|
85
|
-
* escape hatch (an allowed divergence is reported but never fails the run). */
|
|
86
|
-
export function classifyDeterminism(changed: readonly string[], allowed: boolean): VerifyVerdict {
|
|
87
|
-
if (changed.length === 0) return { kind: 'proven-deterministic' }
|
|
88
|
-
return allowed
|
|
89
|
-
? { kind: 'allowed-nondeterministic', changed }
|
|
90
|
-
: { kind: 'nondeterministic', changed }
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Extract the workspace-relative paths a task read outside its declared inputs
|
|
94
|
-
* (Phase 2, `--verify=inputs`). A strace-derived violation line carries the
|
|
95
|
-
* offending absolute path in trailing brackets (`… [/abs/path]`); pull it out,
|
|
96
|
-
* make it workspace-relative, dedup, and sort. Lines without a bracketed path
|
|
97
|
-
* (e.g. macOS syscall lines) fall back to the raw line so nothing is lost. */
|
|
98
|
-
export function undeclaredInputPaths(
|
|
99
|
-
violations: readonly { line: string }[],
|
|
100
|
-
workspaceRoot: string,
|
|
101
|
-
): string[] {
|
|
102
|
-
const rels = new Set<string>()
|
|
103
|
-
for (const v of violations) {
|
|
104
|
-
const m = /\[([^\]]+)\]\s*$/.exec(v.line)
|
|
105
|
-
const raw = m ? m[1]! : v.line
|
|
106
|
-
rels.add(
|
|
107
|
-
path.isAbsolute(raw) ? path.relative(workspaceRoot, raw).split(path.sep).join('/') : raw,
|
|
108
|
-
)
|
|
109
|
-
}
|
|
110
|
-
return [...rels].sort()
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** The end-of-run `--verify` summary section: a counts line plus a
|
|
114
|
-
* failures block naming each non-hermetic task and its diverging outputs.
|
|
115
|
-
* Empty when no task carried a verdict. */
|
|
116
|
-
export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[] {
|
|
117
|
-
const verdicts = outcomes.filter((o) => o.verify !== undefined)
|
|
118
|
-
if (verdicts.length === 0) return []
|
|
119
|
-
let proven = 0
|
|
120
|
-
let bad = 0
|
|
121
|
-
let na = 0
|
|
122
|
-
let notVerified = 0
|
|
123
|
-
for (const o of verdicts) {
|
|
124
|
-
switch (o.verify!.kind) {
|
|
125
|
-
case 'proven-deterministic':
|
|
126
|
-
case 'proven-complete':
|
|
127
|
-
proven++
|
|
128
|
-
break
|
|
129
|
-
case 'nondeterministic':
|
|
130
|
-
case 'rerun-failed':
|
|
131
|
-
case 'undeclared-inputs':
|
|
132
|
-
bad++
|
|
133
|
-
break
|
|
134
|
-
case 'allowed-nondeterministic':
|
|
135
|
-
case 'no-outputs':
|
|
136
|
-
na++
|
|
137
|
-
break
|
|
138
|
-
case 'not-verified':
|
|
139
|
-
notVerified++
|
|
140
|
-
break
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
const lines = [
|
|
144
|
-
'',
|
|
145
|
-
` Verify: ${proven} proven · ${bad} unsafe · ${na} n/a · ${notVerified} not-verified`,
|
|
146
|
-
]
|
|
147
|
-
for (const o of outcomes) {
|
|
148
|
-
const v = o.verify
|
|
149
|
-
if (v === undefined) continue
|
|
150
|
-
if (v.kind === 'nondeterministic') {
|
|
151
|
-
lines.push(` ✗ ${o.node.id} — nondeterministic`)
|
|
152
|
-
lines.push(` changed: ${v.changed.join(', ')}`)
|
|
153
|
-
} else if (v.kind === 'undeclared-inputs') {
|
|
154
|
-
lines.push(` ✗ ${o.node.id} — read undeclared inputs`)
|
|
155
|
-
if (v.paths.length > 0) lines.push(` ${v.paths.join(', ')}`)
|
|
156
|
-
lines.push(' add them to cache.inputs.files / workspaceFiles')
|
|
157
|
-
} else if (v.kind === 'rerun-failed') {
|
|
158
|
-
lines.push(` ✗ ${o.node.id} — verify re-run failed (exit ${v.exitCode})`)
|
|
159
|
-
} else if (v.kind === 'allowed-nondeterministic') {
|
|
160
|
-
lines.push(` ⚠ ${o.node.id} — nondeterministic (allowed)`)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
return lines
|
|
164
|
-
}
|