@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
@@ -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
- }