@vzn/vx 0.0.0 → 0.0.12

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 +332 -1
  3. package/launcher.mjs +84 -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 +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.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 +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,142 @@
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
+ /** Cache-correctness verification (`--verify`); `allow` as an array (Sets
32
+ * don't serialize). */
33
+ verify?: { determinism: boolean; inputs: boolean; allow: readonly string[] }
34
+ flow?: 'focused' | 'broad'
35
+ outputLogs?: 'full' | 'errors-only' | 'none'
36
+ excludeDependencies?: 'all' | readonly string[]
37
+ forwardArgs?: readonly string[]
38
+ summarize?: string
39
+ profile?: string
40
+ /** Free-form key/value labels from `--tag`, persisted on the invocation row. */
41
+ tags?: Record<string, string>
42
+ /** The raw `vx run …` command string, recorded on the invocation row. */
43
+ command?: string
44
+ }
45
+
46
+ export interface RunResult {
47
+ ok: boolean
48
+ outcomes: OutcomeView[]
49
+ }
50
+
51
+ /**
52
+ * Where a `vx run` executes. The client submits a serializable
53
+ * `RunRequest` and gets back a `RunResult`; it neither knows nor cares
54
+ * whether the work happened in-process or was delegated to a service.
55
+ * This is the currency of the `backend` plugin capability — part of the
56
+ * public wire contract (it lives here, not in `cli`, so a plugin can
57
+ * reference it without a `cli` import).
58
+ */
59
+ export interface RunBackend {
60
+ run(request: RunRequest): Promise<RunResult>
61
+ }
62
+
63
+ /**
64
+ * service → client. The submitter contract: an event stream, a final
65
+ * result, or an error. The distributed-execution variants
66
+ * (`task:assign`/`agent:refused`/`coord:drain`) live in a distribution
67
+ * plugin's protocol module — its WS handler unions this with those.
68
+ */
69
+ export type ServerMessage =
70
+ | { t: 'event'; event: WireEvent }
71
+ | { t: 'result'; result: RunResult }
72
+ | { t: 'error'; message: string }
73
+
74
+ /**
75
+ * client → service. The submitter contract: a run submission. The
76
+ * agent-side `agent:*` family lives in a distribution plugin's protocol
77
+ * module.
78
+ */
79
+ export type ClientMessage = { t: 'run'; request: RunRequest }
80
+
81
+ /** Project resolved `RunOptions` down to the serializable request. */
82
+ export function optionsToRequest(options: RunOptions): RunRequest {
83
+ const req: RunRequest = { tasks: options.tasks, cwd: options.cwd }
84
+ if (options.projects !== undefined) req.projects = options.projects
85
+ if (options.concurrency !== undefined) req.concurrency = options.concurrency
86
+ if (options.cache !== undefined) req.cache = options.cache
87
+ if (options.cacheDir !== undefined) req.cacheDir = options.cacheDir
88
+ if (options.continueMode !== undefined) req.continueMode = options.continueMode
89
+ if (options.frozen !== undefined) req.frozen = options.frozen
90
+ if (options.retries !== undefined) req.retries = options.retries
91
+ if (options.timeout !== undefined) req.timeout = options.timeout
92
+ if (options.verify !== undefined)
93
+ req.verify = {
94
+ determinism: options.verify.determinism,
95
+ inputs: options.verify.inputs,
96
+ allow: [...options.verify.allow],
97
+ }
98
+ if (options.flow !== undefined) req.flow = options.flow
99
+ if (options.outputLogs !== undefined) req.outputLogs = options.outputLogs
100
+ if (options.excludeDependencies !== undefined)
101
+ req.excludeDependencies = options.excludeDependencies
102
+ if (options.forwardArgs !== undefined) req.forwardArgs = options.forwardArgs
103
+ if (options.summarize !== undefined) req.summarize = options.summarize
104
+ if (options.profile !== undefined) req.profile = options.profile
105
+ if (options.tags !== undefined) req.tags = options.tags
106
+ if (options.command !== undefined) req.command = options.command
107
+ return req
108
+ }
109
+
110
+ /**
111
+ * Rebuild `RunOptions` from a request, for the side that actually executes
112
+ * (the local backend, or the service). The caller adds the host-side
113
+ * fields it needs (`bus`, `log`, `handleSignals`).
114
+ */
115
+ export function requestToOptions(request: RunRequest): RunOptions {
116
+ const options: RunOptions = { cwd: request.cwd, tasks: [...request.tasks] }
117
+ if (request.projects !== undefined) options.projects = [...request.projects]
118
+ if (request.concurrency !== undefined) options.concurrency = request.concurrency
119
+ if (request.cache !== undefined) options.cache = request.cache
120
+ if (request.cacheDir !== undefined) options.cacheDir = request.cacheDir
121
+ if (request.continueMode !== undefined) options.continueMode = request.continueMode
122
+ if (request.frozen !== undefined) options.frozen = request.frozen
123
+ if (request.retries !== undefined) options.retries = request.retries
124
+ if (request.timeout !== undefined) options.timeout = request.timeout
125
+ if (request.verify !== undefined)
126
+ options.verify = {
127
+ determinism: request.verify.determinism,
128
+ inputs: request.verify.inputs,
129
+ allow: new Set(request.verify.allow),
130
+ }
131
+ if (request.flow !== undefined) options.flow = request.flow
132
+ if (request.outputLogs !== undefined) options.outputLogs = request.outputLogs
133
+ if (request.excludeDependencies !== undefined) {
134
+ options.excludeDependencies = request.excludeDependencies
135
+ }
136
+ if (request.forwardArgs !== undefined) options.forwardArgs = request.forwardArgs
137
+ if (request.summarize !== undefined) options.summarize = request.summarize
138
+ if (request.profile !== undefined) options.profile = request.profile
139
+ if (request.tags !== undefined) options.tags = request.tags
140
+ if (request.command !== undefined) options.command = request.command
141
+ return options
142
+ }
@@ -0,0 +1,60 @@
1
+ import {
2
+ Cache,
3
+ type CacheLayer,
4
+ type CachePolicy,
5
+ FULL_CACHE_POLICY,
6
+ LayeredCache,
7
+ RemoteCache,
8
+ } from '../cache/index.js'
9
+ import type { Logger } from './logger.js'
10
+ import { resolveCacheScope } from './run-context.js'
11
+
12
+ /**
13
+ * Wrap the local cache in a `LayeredCache` when a remote cache is configured
14
+ * via `VX_REMOTE_CACHE_URL` + a token. This is the escape hatch for a
15
+ * THIRD-PARTY, Turbo-compatible cache server. A `cache` plugin can provide a
16
+ * remote cache INTERNALLY instead, in which case this env path isn't
17
+ * needed.
18
+ *
19
+ * **Trust tier follows the token you present** — the server derives
20
+ * trusted/untrusted from the bearer, so the client just presents whichever
21
+ * token it has: `VX_REMOTE_CACHE_TOKEN` for a trusted context, or
22
+ * `VX_REMOTE_CACHE_PR_TOKEN` for a fork PR (reads the trusted cache, writes
23
+ * only the untrusted scope). A fork-PR CI job simply doesn't hold the trusted
24
+ * secret, so "which token you have" IS the tier — no separate trust flag.
25
+ *
26
+ * Optional env: `VX_REMOTE_CACHE_TEAM_ID`, `VX_REMOTE_CACHE_SLUG`
27
+ * (Turbo tenancy query params), `VX_REMOTE_CACHE_TIMEOUT_MS`,
28
+ * `VX_REMOTE_CACHE_SIGNATURE_KEY` (HMAC artifact signing).
29
+ */
30
+ export function wrapWithRemoteCache(
31
+ local: Cache,
32
+ log: Logger,
33
+ policy: CachePolicy = FULL_CACHE_POLICY,
34
+ ): CacheLayer {
35
+ const url = process.env.VX_REMOTE_CACHE_URL
36
+ const token = process.env.VX_REMOTE_CACHE_TOKEN ?? process.env.VX_REMOTE_CACHE_PR_TOKEN
37
+ if (!url || !token) return local
38
+
39
+ const config: ConstructorParameters<typeof RemoteCache>[0] = { baseUrl: url, token }
40
+ const teamId = process.env.VX_REMOTE_CACHE_TEAM_ID
41
+ if (teamId) config.teamId = teamId
42
+ const slug = process.env.VX_REMOTE_CACHE_SLUG
43
+ if (slug) config.slug = slug
44
+ const timeoutMs = process.env.VX_REMOTE_CACHE_TIMEOUT_MS
45
+ if (timeoutMs) {
46
+ const n = Number(timeoutMs)
47
+ if (Number.isFinite(n) && n > 0) config.timeoutMs = n
48
+ }
49
+ const signatureKey = process.env.VX_REMOTE_CACHE_SIGNATURE_KEY
50
+ if (signatureKey) config.signatureKey = signatureKey
51
+ // Untrusted per-PR isolation: a fork PR's writes land in its own sub-scope.
52
+ const cacheScope = resolveCacheScope(process.env)
53
+ if (cacheScope) config.cacheScope = cacheScope
54
+
55
+ log.status(`remote cache: ${url}`)
56
+ return new LayeredCache(local, new RemoteCache(config), {
57
+ onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
58
+ policy,
59
+ })
60
+ }
@@ -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,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
+ }