@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.
- package/LICENSE +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- 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
|
+
}
|