@vzn/vx 0.0.0 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +349 -1
  3. package/launcher.mjs +92 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,156 @@
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
+ }
@@ -0,0 +1,81 @@
1
+ // Remote-cache prefetch (remote-only). When a run is backed by a
2
+ // LayeredCache, the network latency of every remote cache GET would
3
+ // otherwise sit on the critical path of the task that needs it. This
4
+ // module derives every cacheable task's pure-input key UP FRONT
5
+ // (reusing the run's hashCache memo, so execute-task's later
6
+ // computeTaskHash for the same task hits the memo — no double hashing)
7
+ // and fires the remote GETs concurrently, in the background, before
8
+ // execution starts. LayeredCache.prefetch ingests each hit into the
9
+ // LOCAL cache and de-dups against the lazy read-through, so when
10
+ // execute-task calls cache.get it transparently awaits the already
11
+ // in-flight (resolved-or-pending) remote promise — at most ONE remote
12
+ // GET per key.
13
+ //
14
+ // HARD SCOPE: this runs ONLY when a remote layer is configured. It
15
+ // NEVER touches the local cache (no local get / no isOutputsCurrent /
16
+ // no stat pass) — it derives keys and probes REMOTE only. Local-only
17
+ // runs are byte-identical to before; the caller gates on `cache
18
+ // instanceof LayeredCache`.
19
+
20
+ import type { TaskNode } from '../graph/index.js'
21
+ import type { GitFilesCache, LayeredCache } from '../cache/index.js'
22
+ import type { HashCache } from './task-hash.js'
23
+ import { deriveStableKeys } from './stable-keys.js'
24
+
25
+ export interface PrefetchArgs {
26
+ nodes: Map<string, TaskNode>
27
+ cache: LayeredCache
28
+ workspaceRoot: string
29
+ workspaceFingerprint: string
30
+ forwardArgs?: readonly string[] | undefined
31
+ nestedDirsByProject: Map<string, string[]>
32
+ gitFilesCache: GitFilesCache
33
+ hashCache: HashCache
34
+ /** Cap on concurrent in-flight prefetches — the run's concurrency. */
35
+ concurrency: number
36
+ /** When false (remote reads disabled), no prefetch fires. */
37
+ remoteRead: boolean
38
+ }
39
+
40
+ /**
41
+ * Fire remote prefetches for every STABLE cached task in the graph.
42
+ * Fire-and-forget from the caller's perspective: this kicks off an
43
+ * async derivation + bounded prefetch pool and returns immediately so
44
+ * execution starts concurrently. A remote-read-off policy short-circuits.
45
+ *
46
+ * Stability gate: see `deriveStableKeys` — a task's key is "stable"
47
+ * only if its inputs can't be altered by an upstream task's outputs.
48
+ * Unstable tasks fall back to lazy read-through in execute-task, which
49
+ * is always correct.
50
+ */
51
+ export function startRemotePrefetch(args: PrefetchArgs): Promise<void> {
52
+ if (!args.remoteRead) return Promise.resolve()
53
+ // Detached from EXECUTION: the caller does NOT await this before
54
+ // scheduling, so prefetch network latency overlaps the run. But the
55
+ // caller DOES await the returned handle before closing the cache, so
56
+ // a still-in-flight prefetch can never ingest into a closed DB. Errors
57
+ // degrade inside LayeredCache.prefetch; the catch keeps the handle
58
+ // from ever rejecting.
59
+ return runPrefetch(args).catch(() => {})
60
+ }
61
+
62
+ async function runPrefetch(args: PrefetchArgs): Promise<void> {
63
+ const stableKeys = await deriveStableKeys(args)
64
+ if (stableKeys.length === 0) return
65
+
66
+ // Bounded worker pool over the stable keys. Each prefetch is
67
+ // self-contained (LayeredCache owns the in-flight map + ingest); we
68
+ // cap concurrency so a 1000-task run doesn't open 1000 sockets at
69
+ // once. The pumps race alongside execution.
70
+ let next = 0
71
+ const workers = Math.max(1, Math.min(args.concurrency, stableKeys.length))
72
+ const pump = async (): Promise<void> => {
73
+ while (next < stableKeys.length) {
74
+ const { hash, node } = stableKeys[next++]!
75
+ await args.cache
76
+ .prefetch(hash, { taskId: node.id, command: node.config.exec?.command ?? '' })
77
+ .catch(() => false)
78
+ }
79
+ }
80
+ await Promise.all(Array.from({ length: workers }, () => pump()))
81
+ }
@@ -0,0 +1,59 @@
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
+ }
@@ -0,0 +1,111 @@
1
+ // Optional per-run output artifacts:
2
+ //
3
+ // --summarize[=<path>] → JSON describing every task's outcome
4
+ // (id, status, exit code, durations, hash,
5
+ // cpu_ms, peak_rss_bytes, hrtime spans).
6
+ // Default path: <cacheDir>/runs/<run_id>.json.
7
+ //
8
+ // --profile[=<path>] → Chrome-trace JSON of the run. Each task is
9
+ // a single complete event (`ph: 'X'`) with
10
+ // ts / dur in microseconds derived from the
11
+ // hrtime spans the runner already captures.
12
+ // Default path: profile.json (cwd-relative).
13
+ //
14
+ // Both are no-ops if the corresponding RunOptions field is undefined.
15
+
16
+ import path from 'node:path'
17
+ import type { TaskOutcome } from '../graph/index.js'
18
+ import { tallyOutcomes } from './tally.js'
19
+
20
+ export interface SummarizeArgs {
21
+ /** Empty string → default path; otherwise the explicit file path. */
22
+ target: string
23
+ cacheDir: string
24
+ cwd: string
25
+ runId: string
26
+ startedAtMs: number
27
+ endedAtMs: number
28
+ totalMs: number
29
+ outcomes: readonly TaskOutcome[]
30
+ }
31
+
32
+ export async function writeRunSummary(args: SummarizeArgs): Promise<string> {
33
+ const outPath =
34
+ args.target === ''
35
+ ? path.join(args.cacheDir, 'runs', `${args.runId}.json`)
36
+ : path.resolve(args.cwd, args.target)
37
+ const payload = {
38
+ runId: args.runId,
39
+ startedAt: new Date(args.startedAtMs).toISOString(),
40
+ endedAt: new Date(args.endedAtMs).toISOString(),
41
+ totalMs: args.totalMs,
42
+ tasks: args.outcomes.map((o) => ({
43
+ id: o.node.id,
44
+ project: o.node.projectName,
45
+ task: o.node.taskName,
46
+ status: o.status,
47
+ exitCode: o.exitCode,
48
+ durationMs: o.durationMs,
49
+ hash: o.hash ?? null,
50
+ ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
51
+ ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
52
+ // hrtime spans are bigints → emit as strings so JSON.parse on
53
+ // the consumer side doesn't truncate the ns precision.
54
+ ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
55
+ ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: String(o.wallclockEndNs) } : {}),
56
+ })),
57
+ summary: tallyOutcomes(args.outcomes),
58
+ }
59
+ await Bun.write(outPath, JSON.stringify(payload, null, 2))
60
+ return outPath
61
+ }
62
+
63
+ export interface ProfileArgs {
64
+ target: string
65
+ cwd: string
66
+ outcomes: readonly TaskOutcome[]
67
+ }
68
+
69
+ /**
70
+ * Chrome trace JSON. Spec:
71
+ * https://docs.google.com/document/d/1CvAClvFfyA5R-PhYUmn5OOQtYMH4h6I0nSsKchNAySU
72
+ *
73
+ * One complete event (`ph: 'X'`) per task with `ts` and `dur` in
74
+ * microseconds (Chrome's unit; we have ns from hrtime, divide by 1000).
75
+ * Each project gets its own `tid` so concurrent tasks render on
76
+ * distinct rows in chrome://tracing or perfetto.
77
+ */
78
+ export async function writeRunProfile(args: ProfileArgs): Promise<string> {
79
+ const outPath = path.resolve(args.cwd, args.target)
80
+ const tidByProject = new Map<string, number>()
81
+ const traceEvents = args.outcomes
82
+ .filter((o) => o.wallclockStartNs !== undefined && o.wallclockEndNs !== undefined)
83
+ .map((o) => {
84
+ let tid = tidByProject.get(o.node.projectName)
85
+ if (tid === undefined) {
86
+ tid = tidByProject.size + 1
87
+ tidByProject.set(o.node.projectName, tid)
88
+ }
89
+ // ns → us; bigint arithmetic to avoid precision loss before
90
+ // converting to a JSON-safe number.
91
+ const startUs = Number((o.wallclockStartNs ?? 0n) / 1000n)
92
+ const durUs = Number(((o.wallclockEndNs ?? 0n) - (o.wallclockStartNs ?? 0n)) / 1000n)
93
+ return {
94
+ name: o.node.id,
95
+ cat: o.status,
96
+ ph: 'X',
97
+ ts: startUs,
98
+ dur: durUs,
99
+ pid: 1,
100
+ tid,
101
+ args: {
102
+ exitCode: o.exitCode,
103
+ hash: o.hash ?? null,
104
+ ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
105
+ ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
106
+ },
107
+ }
108
+ })
109
+ await Bun.write(outPath, JSON.stringify({ traceEvents }, null, 2))
110
+ return outPath
111
+ }
@@ -0,0 +1,205 @@
1
+ // Per-run context capture for the Tier-3 `invocations` header row:
2
+ // git (commit / branch / dirty), CI provider, and host/os/arch. All
3
+ // probes are best-effort — a missing git binary, a non-repo cwd, or an
4
+ // unset env degrades each field to null and NEVER throws. Telemetry must
5
+ // not be able to fail a build.
6
+
7
+ import fs from 'node:fs'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { ulid, xxh3hex } from '../util/index.js'
11
+
12
+ export interface GitContext {
13
+ /** `git rev-parse HEAD`, or null outside a repo / on failure. */
14
+ commitSha: string | null
15
+ /** `git rev-parse --abbrev-ref HEAD`, or null. */
16
+ branch: string | null
17
+ /** True if `git status --porcelain` was non-empty; null on failure. */
18
+ dirty: boolean | null
19
+ }
20
+
21
+ export interface CiContext {
22
+ ci: boolean
23
+ /** Which CI matched: 'github' | 'gitlab' | 'buildkite' | 'circleci'
24
+ * | 'generic' (bare `CI`), or null when no CI env is present. */
25
+ provider: string | null
26
+ }
27
+
28
+ export interface HostContext {
29
+ host: string | null
30
+ os: string
31
+ arch: string
32
+ }
33
+
34
+ /**
35
+ * One short `git` spawn per run (not per task), behind try/catch.
36
+ * `git rev-parse HEAD --abbrev-ref HEAD` returns the commit on line 1
37
+ * and the branch on line 2 in a SINGLE invocation — half the spawns of
38
+ * two separate `rev-parse` calls. `dirty` is NOT probed here: the run's
39
+ * `GitFilesCache` populate already ran `git status --porcelain` for
40
+ * input enumeration, so the orchestrator passes that aggregate in
41
+ * (`dirty`) rather than paying for a second status spawn. Pass
42
+ * `dirty: null` when unavailable. Each field degrades to null
43
+ * independently — telemetry never fails a run.
44
+ */
45
+ export function captureGitContext(workspaceRoot: string, dirty: boolean | null = null): GitContext {
46
+ let commitSha: string | null = null
47
+ let branch: string | null = null
48
+ try {
49
+ const proc = Bun.spawnSync({
50
+ cmd: ['git', '-C', workspaceRoot, 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'],
51
+ stdout: 'pipe',
52
+ stderr: 'ignore',
53
+ })
54
+ if (proc.exitCode === 0) {
55
+ const lines = new TextDecoder().decode(proc.stdout).trim().split('\n')
56
+ const sha = lines[0]?.trim()
57
+ const br = lines[1]?.trim()
58
+ if (sha) commitSha = sha
59
+ if (br) branch = br
60
+ }
61
+ } catch {
62
+ // git unavailable / non-repo: both fields stay null.
63
+ }
64
+ return { commitSha, branch, dirty }
65
+ }
66
+
67
+ // Recognized CI env vars in match-priority order. The first whose value
68
+ // is truthy (present and not '0'/'false') decides the provider; a bare
69
+ // `CI` is the generic fallback.
70
+ const CI_PROVIDERS: ReadonlyArray<[envVar: string, provider: string]> = [
71
+ ['GITHUB_ACTIONS', 'github'],
72
+ ['GITLAB_CI', 'gitlab'],
73
+ ['BUILDKITE', 'buildkite'],
74
+ ['CIRCLECI', 'circleci'],
75
+ ['CI', 'generic'],
76
+ ]
77
+
78
+ function isTruthy(v: string | undefined): boolean {
79
+ return v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
80
+ }
81
+
82
+ /**
83
+ * Detect a CI environment + which provider. `ci=true` if any recognized
84
+ * var is truthy; `provider` names the most-specific match (a specific
85
+ * provider beats the generic `CI`).
86
+ */
87
+ export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefined>): CiContext {
88
+ for (const [varName, provider] of CI_PROVIDERS) {
89
+ if (isTruthy(env[varName])) return { ci: true, provider }
90
+ }
91
+ return { ci: false, provider: null }
92
+ }
93
+
94
+ /**
95
+ * The untrusted per-PR cache partition — a stable identity for THIS pull
96
+ * request, so one fork PR's untrusted cache writes are isolated from another's
97
+ * (no cross-PR pollution or leakage). `VX_CACHE_SCOPE` overrides; otherwise
98
+ * derived from the CI PR context (GitHub PR number, GitLab MR iid, else the
99
+ * head branch). Returns undefined outside a PR (the serve then falls back to a
100
+ * shared untrusted scope). Never throws — sanitized to one safe path segment.
101
+ */
102
+ export function resolveCacheScope(
103
+ env: NodeJS.ProcessEnv | Record<string, string | undefined>,
104
+ ): string | undefined {
105
+ const safe = (s: string): string => s.replace(/[^a-zA-Z0-9_.-]/g, '-').slice(0, 128)
106
+ const override = env['VX_CACHE_SCOPE']
107
+ if (isTruthy(override)) return safe(override as string)
108
+ // GitHub: a pull_request run has GITHUB_REF = refs/pull/<n>/merge.
109
+ const ref = env['GITHUB_REF']
110
+ const m = typeof ref === 'string' ? /^refs\/pull\/(\d+)\//.exec(ref) : null
111
+ if (m) return `pr-${m[1]}`
112
+ const headRef = env['GITHUB_HEAD_REF']
113
+ if (isTruthy(headRef)) return `gh-${safe(headRef as string)}`
114
+ // GitLab merge request.
115
+ const iid = env['CI_MERGE_REQUEST_IID']
116
+ if (isTruthy(iid)) return `mr-${safe(iid as string)}`
117
+ return undefined
118
+ }
119
+
120
+ /** Host name (null on failure) + platform + arch. */
121
+ export function captureHostContext(): HostContext {
122
+ let host: string | null = null
123
+ try {
124
+ const h = os.hostname()
125
+ host = h.length > 0 ? h : null
126
+ } catch {
127
+ host = null
128
+ }
129
+ return { host, os: process.platform, arch: process.arch }
130
+ }
131
+
132
+ export interface WorkspaceIdentity {
133
+ /** Stable 16-hex id — same for every checkout of the same repo. */
134
+ id: string
135
+ /** Human name for switchers/badges: the repo (or root dir) basename. */
136
+ name: string
137
+ }
138
+
139
+ /**
140
+ * Normalize a git remote URL so every checkout of the same repository
141
+ * derives the SAME workspace id: `git@github.com:o/r.git`,
142
+ * `ssh://git@github.com/o/r`, and `https://github.com/o/r.git` all
143
+ * reduce to `github.com/o/r`.
144
+ */
145
+ export function normalizeRemoteUrl(raw: string): string {
146
+ let s = raw.trim().toLowerCase()
147
+ s = s.replace(/^[a-z+]+:\/\//, '') // protocol
148
+ s = s.replace(/^[^@/]+@/, '') // user[:pass]@
149
+ s = s.replace(/:(\d+\/)/, '/$1') // :port/ → /
150
+ s = s.replace(/:/, '/') // scp-style host:path
151
+ s = s.replace(/\.git$/, '').replace(/\/+$/, '')
152
+ return s
153
+ }
154
+
155
+ /**
156
+ * Stable workspace identity for the multi-workspace server story
157
+ * (telemetry schema v2). Derivation ladder:
158
+ * 1. git remote origin URL, normalized → xxh3 (same id from any
159
+ * machine's checkout of the same repo);
160
+ * 2. no remote → a salt persisted at `<root>/.vx/workspace-id` (the
161
+ * checkout keeps a stable identity across runs; `.vx/` is already
162
+ * gitignored infrastructure);
163
+ * 3. unwritable `.vx/` → the root path itself (stable per machine).
164
+ * One `git` spawn behind try/catch; call sites gate on telemetry being
165
+ * active so a plain run never pays it. Never throws.
166
+ */
167
+ export function captureWorkspaceIdentity(workspaceRoot: string): WorkspaceIdentity {
168
+ const base = workspaceRoot.replace(/\/+$/, '').split('/').pop() || 'workspace'
169
+ try {
170
+ const proc = Bun.spawnSync({
171
+ // `config --get`, NOT `remote get-url`: get-url applies insteadOf
172
+ // rewrites, so two developers mirroring the same repo through
173
+ // different proxies would derive different workspace ids. The raw
174
+ // configured URL is the identity.
175
+ cmd: ['git', '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
176
+ stdout: 'pipe',
177
+ stderr: 'ignore',
178
+ })
179
+ if (proc.exitCode === 0) {
180
+ const url = new TextDecoder().decode(proc.stdout).trim()
181
+ if (url.length > 0) {
182
+ const normalized = normalizeRemoteUrl(url)
183
+ const name = normalized.split('/').pop() || base
184
+ return { id: xxh3hex(normalized), name }
185
+ }
186
+ }
187
+ } catch {
188
+ // git unavailable — fall through to the salt.
189
+ }
190
+ try {
191
+ const saltPath = path.join(workspaceRoot, '.vx', 'workspace-id')
192
+ let salt: string
193
+ try {
194
+ salt = fs.readFileSync(saltPath, 'utf8').trim()
195
+ if (salt.length === 0) throw new Error('empty')
196
+ } catch {
197
+ salt = ulid()
198
+ fs.mkdirSync(path.dirname(saltPath), { recursive: true })
199
+ fs.writeFileSync(saltPath, salt + '\n')
200
+ }
201
+ return { id: xxh3hex(salt), name: base }
202
+ } catch {
203
+ return { id: xxh3hex(workspaceRoot), name: base }
204
+ }
205
+ }