@vzn/vx 0.0.646 → 0.0.648
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/README.md +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// HARD SCOPE: this runs ONLY when a remote layer is configured. It
|
|
15
15
|
// asks the local index only which keys it holds (no local get / no
|
|
16
16
|
// isOutputsCurrent), and probes the remote for the rest: a run the local
|
|
17
|
-
// cache answers makes no remote call (
|
|
17
|
+
// cache answers makes no remote call (2026-10-15). Local-only
|
|
18
18
|
// runs are byte-identical to before; the caller gates on the layer's
|
|
19
19
|
// own `hasRemote`, so a third-party remote layer gets the overlap too.
|
|
20
20
|
// The two batch-probe hooks are OPTIONAL on the contract: a remote layer
|
|
@@ -24,7 +24,7 @@ import type { TaskNode } from '../graph/index.js'
|
|
|
24
24
|
import { maskedCommand } from '../util/index.js'
|
|
25
25
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
26
26
|
import type { HashCache } from './task-hash.js'
|
|
27
|
-
import { deriveStableKeys } from './stable-keys.js'
|
|
27
|
+
import { deriveStableKeys, type StableKey } from './stable-keys.js'
|
|
28
28
|
|
|
29
29
|
export interface PrefetchArgs {
|
|
30
30
|
nodes: Map<string, TaskNode>
|
|
@@ -46,6 +46,27 @@ export interface PrefetchArgs {
|
|
|
46
46
|
* `probeBeforeUpload` instead, so a key a remote holds is not re-sent.
|
|
47
47
|
*/
|
|
48
48
|
remoteWrite: boolean
|
|
49
|
+
/**
|
|
50
|
+
* Told once, after the probe, the stable tasks a store holds, local or
|
|
51
|
+
* remote, and those every store answered it does not: they run.
|
|
52
|
+
*/
|
|
53
|
+
held?: ((ids: readonly string[], missed: readonly string[]) => void) | undefined
|
|
54
|
+
/** `--profile`'s run events: the keys' derivation and the batch probe, `ts` on `performance.now()`. */
|
|
55
|
+
spans?: Record<string, unknown>[] | undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function span(spans: PrefetchArgs['spans'], name: string, start: number, args: object): void {
|
|
59
|
+
const end = performance.now()
|
|
60
|
+
spans?.push({
|
|
61
|
+
name,
|
|
62
|
+
cat: 'cache',
|
|
63
|
+
ph: 'X',
|
|
64
|
+
ts: start * 1000,
|
|
65
|
+
dur: (end - start) * 1000,
|
|
66
|
+
pid: 1,
|
|
67
|
+
tid: 0,
|
|
68
|
+
args,
|
|
69
|
+
})
|
|
49
70
|
}
|
|
50
71
|
|
|
51
72
|
/**
|
|
@@ -75,16 +96,24 @@ export function startRemotePrefetch(args: PrefetchArgs): Promise<void> {
|
|
|
75
96
|
// a still-in-flight prefetch can never ingest into a closed DB. Errors
|
|
76
97
|
// degrade inside LayeredCache.prefetch; the catch keeps the handle
|
|
77
98
|
// from ever rejecting.
|
|
78
|
-
|
|
79
|
-
args.cache.probeFirst?.(started.promise)
|
|
80
|
-
return runPrefetch(args, started.resolve)
|
|
81
|
-
.catch(() => {})
|
|
82
|
-
.finally(started.resolve)
|
|
99
|
+
return runPrefetch(args).catch(() => {})
|
|
83
100
|
}
|
|
84
101
|
|
|
85
|
-
async function runPrefetch(args: PrefetchArgs
|
|
102
|
+
async function runPrefetch(args: PrefetchArgs): Promise<void> {
|
|
103
|
+
const keysAt = performance.now()
|
|
86
104
|
const stableKeys = await deriveStableKeys(args)
|
|
87
|
-
|
|
105
|
+
span(args.spans, 'stable keys', keysAt, { keys: stableKeys.length })
|
|
106
|
+
// `complete`: every store asked answered for every key, so one it does
|
|
107
|
+
// not hold is a miss; else only its hits are known.
|
|
108
|
+
const tell = (holds: (hash: string) => boolean, complete: boolean): void =>
|
|
109
|
+
args.held?.(
|
|
110
|
+
stableKeys.filter((k) => holds(k.hash)).map((k) => k.node.id),
|
|
111
|
+
complete ? stableKeys.filter((k) => !holds(k.hash)).map((k) => k.node.id) : [],
|
|
112
|
+
)
|
|
113
|
+
if (stableKeys.length === 0) {
|
|
114
|
+
tell(() => false, true)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
88
117
|
|
|
89
118
|
// Batch existence probe FIRST (when the remote supports it): one round-trip
|
|
90
119
|
// tells us which of the N stable hashes exist remotely, so we GET only the
|
|
@@ -96,12 +125,58 @@ async function runPrefetch(args: PrefetchArgs, probing: () => void): Promise<voi
|
|
|
96
125
|
const held =
|
|
97
126
|
(await args.cache.localHasMany?.([...new Set(stableKeys.map((k) => k.hash))])) ?? null
|
|
98
127
|
let toPrefetch = held === null ? stableKeys : stableKeys.filter((k) => !held.has(k.hash))
|
|
99
|
-
if (toPrefetch.length === 0)
|
|
128
|
+
if (toPrefetch.length === 0) {
|
|
129
|
+
tell(() => true, true)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
100
132
|
const uniqueHashes = [...new Set(toPrefetch.map((k) => k.hash))]
|
|
133
|
+
const pool = fetchPool(args.concurrency, (k) =>
|
|
134
|
+
args.cache.prefetch(k.hash, getContext(k.node, k.node.config.exec?.command ?? '')),
|
|
135
|
+
)
|
|
136
|
+
// Trusted at once: a key the index says a remote holds is a hit and its
|
|
137
|
+
// pull starts now, before any remote answers (2026-10-23). The probe
|
|
138
|
+
// below still asks for all of them.
|
|
139
|
+
// One that knows none of them says nothing: a fresh CI runner's empty
|
|
140
|
+
// index called every key a miss, and main asked 15 workers for 16 misses
|
|
141
|
+
// (75f58f9, 2026-10-23). The probe answers them.
|
|
142
|
+
const kept = args.cache.remoteKnown?.(uniqueHashes)
|
|
143
|
+
const known = kept === undefined || kept.size === 0 ? undefined : kept
|
|
144
|
+
if (known !== undefined) pool.push(toPrefetch.filter((k) => known.has(k.hash)))
|
|
145
|
+
const probeAt = performance.now()
|
|
101
146
|
const asked = args.cache.remoteHasMany?.(uniqueHashes)
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
147
|
+
// The first keys in graph order are pulled now too, the probe unanswered:
|
|
148
|
+
// on CI it answered ~490 ms in, after a GET did (~250 ms), and a hit's
|
|
149
|
+
// dependant waited on its own GET once its upstream hit landed
|
|
150
|
+
// (2026-10-24). At most one pool's worth of these can 404.
|
|
151
|
+
const eager = toPrefetch.filter((k) => known?.has(k.hash) !== true).slice(0, args.concurrency)
|
|
152
|
+
if (asked !== undefined) pool.push(eager)
|
|
153
|
+
// Demand hears the index only when no probe will answer: a key the
|
|
154
|
+
// index lacks may still be held (a PR's CI stored main's next tree), and
|
|
155
|
+
// calling it a miss asked 14 workers for 71 tasks, 17 of which ran
|
|
156
|
+
// (main 7391c93, 2026-10-23). The probe is one round-trip.
|
|
157
|
+
const early = known !== undefined && asked === undefined
|
|
158
|
+
if (early) tell((h) => held?.has(h) === true || known.has(h), true)
|
|
159
|
+
|
|
160
|
+
// Batch existence probe (when the remote supports it): one round-trip
|
|
161
|
+
// tells us which of the N stable hashes exist remotely, so we GET only the
|
|
162
|
+
// hits and pre-mark the misses — their lazy `get` then short-circuits with
|
|
163
|
+
// no network. This collapses N probe waves into 1 and skips every GET that
|
|
164
|
+
// would 404. When the remote can't batch (`null` — an older remote, reads
|
|
165
|
+
// disabled, or a layer that doesn't implement the hook at all), fall back to
|
|
166
|
+
// prefetching every stable key, exactly as before.
|
|
167
|
+
// A remote that answered no batch query is asked key by key when demand
|
|
168
|
+
// plans on the answer: main f71b377 asked 15 workers for 116 tasks, 107
|
|
169
|
+
// restored, and small#1 joined to nothing (2026-10-23). A run that plans
|
|
170
|
+
// nothing pulls instead, as before.
|
|
171
|
+
let present = (await asked) ?? null
|
|
172
|
+
if (asked !== undefined)
|
|
173
|
+
span(args.spans, 'remote probe', probeAt, {
|
|
174
|
+
keys: uniqueHashes.length,
|
|
175
|
+
held: present?.size ?? null,
|
|
176
|
+
})
|
|
177
|
+
if (present === null && args.held !== undefined && asked !== undefined)
|
|
178
|
+
present = (await args.cache.remoteHasEach?.(uniqueHashes, args.concurrency)) ?? null
|
|
179
|
+
if (!early) tell((h) => held?.has(h) === true || present?.has(h) === true, present !== null)
|
|
105
180
|
if (present !== null) {
|
|
106
181
|
args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
|
|
107
182
|
// The pre-mark above already answers a pull for an absent hash from
|
|
@@ -110,24 +185,43 @@ async function runPrefetch(args: PrefetchArgs, probing: () => void): Promise<voi
|
|
|
110
185
|
// every absent key. What it decides is which hashes the pump asks
|
|
111
186
|
// the layer for, and that is what the row pins (item 643, 645).
|
|
112
187
|
toPrefetch = toPrefetch.filter((k) => present.has(k.hash))
|
|
113
|
-
if (toPrefetch.length === 0) return
|
|
114
188
|
}
|
|
189
|
+
const pulled = new Set(asked === undefined ? [] : eager)
|
|
190
|
+
pool.push(toPrefetch.filter((k) => known?.has(k.hash) !== true && !pulled.has(k)))
|
|
191
|
+
await pool.settled()
|
|
192
|
+
}
|
|
115
193
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Bounded worker pool over the keys to fetch, fed as they are known. Each
|
|
196
|
+
* prefetch is self-contained (LayeredCache owns the in-flight map +
|
|
197
|
+
* ingest); the cap keeps a 1000-task run from opening 1000 sockets at
|
|
198
|
+
* once. The pumps race alongside execution.
|
|
199
|
+
*/
|
|
200
|
+
function fetchPool(
|
|
201
|
+
concurrency: number,
|
|
202
|
+
fetch: (k: StableKey) => Promise<boolean>,
|
|
203
|
+
): { push(keys: readonly StableKey[]): void; settled(): Promise<unknown> } {
|
|
204
|
+
const cap = Math.max(1, concurrency)
|
|
205
|
+
const queue: StableKey[] = []
|
|
206
|
+
const pumps: Promise<void>[] = []
|
|
120
207
|
let next = 0
|
|
121
|
-
|
|
208
|
+
let active = 0
|
|
122
209
|
const pump = async (): Promise<void> => {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await
|
|
126
|
-
|
|
127
|
-
|
|
210
|
+
active++
|
|
211
|
+
try {
|
|
212
|
+
while (next < queue.length) await fetch(queue[next++]!).catch(() => false)
|
|
213
|
+
} finally {
|
|
214
|
+
active--
|
|
128
215
|
}
|
|
129
216
|
}
|
|
130
|
-
|
|
217
|
+
return {
|
|
218
|
+
push(keys) {
|
|
219
|
+
queue.push(...keys)
|
|
220
|
+
const room = Math.min(cap - active, queue.length - next)
|
|
221
|
+
for (let i = 0; i < room; i++) pumps.push(pump())
|
|
222
|
+
},
|
|
223
|
+
settled: () => Promise.all(pumps),
|
|
224
|
+
}
|
|
131
225
|
}
|
|
132
226
|
|
|
133
227
|
/**
|
|
@@ -10,6 +10,42 @@ export interface ProfileArgs {
|
|
|
10
10
|
target: string
|
|
11
11
|
cwd: string
|
|
12
12
|
outcomes: readonly TaskOutcome[]
|
|
13
|
+
/** `process.hrtime.bigint()` at the run's start: a task's span is taken from it. */
|
|
14
|
+
runStartNs: bigint
|
|
15
|
+
/** Each executor's own events (`TaskExecutor.profile`), `ts` on `performance.now()`. */
|
|
16
|
+
executors?: readonly (readonly Record<string, unknown>[])[]
|
|
17
|
+
/** The run's own events beside its tasks (the cache probe), `ts` on `performance.now()`. */
|
|
18
|
+
spans?: readonly Record<string, unknown>[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ns → us; bigint arithmetic to avoid precision loss before converting to
|
|
22
|
+
// a JSON-safe number.
|
|
23
|
+
function spanUs(o: TaskOutcome): { startUs: number; durUs: number } {
|
|
24
|
+
return {
|
|
25
|
+
startUs: Number(o.wallclockStartNs! / 1000n),
|
|
26
|
+
durUs: Number((o.wallclockEndNs! - o.wallclockStartNs!) / 1000n),
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Each task's `tid`: its project's lane, or the project's next lane when it
|
|
32
|
+
* overlaps a task already there. Slices on one thread must nest, and two
|
|
33
|
+
* tasks of one project running side by side do not: Perfetto drops one.
|
|
34
|
+
*/
|
|
35
|
+
function lanes(timed: readonly TaskOutcome[]): Map<TaskOutcome, number> {
|
|
36
|
+
const ends = new Map<string, { tid: number; endUs: number }[]>()
|
|
37
|
+
const tidOf = new Map<TaskOutcome, number>()
|
|
38
|
+
let tids = 0
|
|
39
|
+
const byStart = timed.map((o) => ({ o, ...spanUs(o) })).sort((a, b) => a.startUs - b.startUs)
|
|
40
|
+
for (const { o, startUs, durUs } of byStart) {
|
|
41
|
+
let own = ends.get(o.node.projectName)
|
|
42
|
+
if (own === undefined) ends.set(o.node.projectName, (own = []))
|
|
43
|
+
let lane = own.find((l) => l.endUs <= startUs)
|
|
44
|
+
if (lane === undefined) own.push((lane = { tid: ++tids, endUs: 0 }))
|
|
45
|
+
lane.endUs = startUs + durUs
|
|
46
|
+
tidOf.set(o, lane.tid)
|
|
47
|
+
}
|
|
48
|
+
return tidOf
|
|
13
49
|
}
|
|
14
50
|
|
|
15
51
|
/**
|
|
@@ -18,40 +54,67 @@ export interface ProfileArgs {
|
|
|
18
54
|
*
|
|
19
55
|
* One complete event (`ph: 'X'`) per task with `ts` and `dur` in
|
|
20
56
|
* microseconds (Chrome's unit; we have ns from hrtime, divide by 1000).
|
|
21
|
-
* Each project gets its own `tid
|
|
22
|
-
*
|
|
57
|
+
* Each project gets its own `tid`, and one more for each task it runs
|
|
58
|
+
* beside another, so concurrent tasks render on distinct rows.
|
|
23
59
|
*/
|
|
24
60
|
export async function writeRunProfile(args: ProfileArgs): Promise<string> {
|
|
25
61
|
const outPath = path.resolve(args.cwd, args.target)
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
const timed = args.outcomes.filter(
|
|
63
|
+
(o) => o.wallclockStartNs !== undefined && o.wallclockEndNs !== undefined,
|
|
64
|
+
)
|
|
65
|
+
const tidOf = lanes(timed)
|
|
66
|
+
const traceEvents = timed.map((o) => {
|
|
67
|
+
const tid = tidOf.get(o)!
|
|
68
|
+
const { startUs, durUs } = spanUs(o)
|
|
69
|
+
return {
|
|
70
|
+
name: o.node.id,
|
|
71
|
+
cat: o.status,
|
|
72
|
+
ph: 'X',
|
|
73
|
+
ts: startUs,
|
|
74
|
+
dur: durUs,
|
|
75
|
+
pid: 1,
|
|
76
|
+
tid,
|
|
77
|
+
args: {
|
|
78
|
+
exitCode: o.exitCode,
|
|
79
|
+
hash: o.hash ?? null,
|
|
80
|
+
...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
|
|
81
|
+
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
82
|
+
// A remote hit's download and ingest: the rest of its span is vx's.
|
|
83
|
+
...(o.fetchMs !== undefined ? { fetchMs: o.fetchMs } : {}),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
// An executor's processes follow the run's own, each executor's after
|
|
88
|
+
// the last; its times move onto the tasks' clock, the run's start on
|
|
89
|
+
// hrtime. Without the start, a DTE mark sat ~the run's setup late.
|
|
90
|
+
const shiftUs =
|
|
91
|
+
Number((process.hrtime.bigint() - args.runStartNs) / 1000n) - performance.now() * 1000
|
|
92
|
+
let next = 2
|
|
93
|
+
const theirs: Record<string, unknown>[] = []
|
|
94
|
+
for (const events of args.executors ?? []) {
|
|
95
|
+
let top = 0
|
|
96
|
+
for (const e of events) {
|
|
97
|
+
const pid = typeof e['pid'] === 'number' ? e['pid'] : 1
|
|
98
|
+
top = Math.max(top, pid)
|
|
99
|
+
theirs.push({
|
|
100
|
+
...e,
|
|
101
|
+
pid: next - 1 + pid,
|
|
102
|
+
...(typeof e['ts'] === 'number' ? { ts: Math.round(e['ts'] + shiftUs) } : {}),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
next += top
|
|
106
|
+
}
|
|
107
|
+
const own = (args.spans ?? []).map((e) => ({
|
|
108
|
+
...e,
|
|
109
|
+
...(typeof e['ts'] === 'number' ? { ts: Math.round(e['ts'] + shiftUs) } : {}),
|
|
110
|
+
}))
|
|
111
|
+
const named =
|
|
112
|
+
theirs.length === 0
|
|
113
|
+
? []
|
|
114
|
+
: [{ ph: 'M', name: 'process_name', pid: 1, tid: 0, args: { name: 'run' } }]
|
|
115
|
+
await Bun.write(
|
|
116
|
+
outPath,
|
|
117
|
+
JSON.stringify({ traceEvents: [...named, ...traceEvents, ...own, ...theirs] }, null, 2),
|
|
118
|
+
)
|
|
56
119
|
return outPath
|
|
57
120
|
}
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
envFlagOn,
|
|
18
|
+
executablePath,
|
|
19
|
+
gitEnv,
|
|
20
|
+
ignoreDir,
|
|
21
|
+
relPosix,
|
|
22
|
+
ulid,
|
|
23
|
+
xxh3hex,
|
|
24
|
+
} from '../util/index.js'
|
|
17
25
|
import { resolveSetting } from '../settings.js'
|
|
18
26
|
import { branchCacheScope, cacheScopeEnvError, isCacheScope } from '../workspace/index.js'
|
|
19
27
|
import { VERSION } from '../version.js'
|
|
@@ -234,7 +242,7 @@ function ciBranch(env: NodeJS.ProcessEnv | Record<string, string | undefined>):
|
|
|
234
242
|
* The repository's DEFAULT (trunk) branch — the axis that separates a
|
|
235
243
|
* mainline run from a PR / feature-branch experiment. A run whose `branch`
|
|
236
244
|
* equals this is a trunk run; everything else is branch work whose timings
|
|
237
|
-
* must NOT pollute the shared scheduling baseline (
|
|
245
|
+
* must NOT pollute the shared scheduling baseline (2026-07-14: "I can
|
|
238
246
|
* be experimenting on a branch increasing task time for all later on… don't
|
|
239
247
|
* count their times into main"). Resolution ladder, most-reliable first:
|
|
240
248
|
* 1. GitLab: `CI_DEFAULT_BRANCH` (the project's configured default).
|
|
@@ -312,10 +320,6 @@ const CI_PROVIDERS: ReadonlyArray<[envVar: string, provider: string]> = [
|
|
|
312
320
|
['CI', 'generic'],
|
|
313
321
|
]
|
|
314
322
|
|
|
315
|
-
function isTruthy(v: string | undefined): boolean {
|
|
316
|
-
return v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
|
|
317
|
-
}
|
|
318
|
-
|
|
319
323
|
/**
|
|
320
324
|
* Detect a CI environment + which provider. `ci=true` if any recognized
|
|
321
325
|
* var is truthy; `provider` names the most-specific match (a specific
|
|
@@ -323,7 +327,7 @@ function isTruthy(v: string | undefined): boolean {
|
|
|
323
327
|
*/
|
|
324
328
|
export function detectCi(env: NodeJS.ProcessEnv | Record<string, string | undefined>): CiContext {
|
|
325
329
|
for (const [varName, provider] of CI_PROVIDERS) {
|
|
326
|
-
if (
|
|
330
|
+
if (envFlagOn(env[varName])) return { ci: true, provider, ...ciLinks(provider, env) }
|
|
327
331
|
}
|
|
328
332
|
return { ci: false, provider: null }
|
|
329
333
|
}
|
|
@@ -345,8 +349,12 @@ function ciLinks(provider: string, env: Record<string, string | undefined>): CiL
|
|
|
345
349
|
const id = v('GITHUB_RUN_ID')
|
|
346
350
|
links = {
|
|
347
351
|
runUrl: repo && id ? `${server}/${repo}/actions/runs/${id}` : undefined,
|
|
348
|
-
// `refs/pull/<n>/merge` on a pull_request event
|
|
349
|
-
|
|
352
|
+
// `refs/pull/<n>/merge` on a pull_request event; a merge queue's
|
|
353
|
+
// run, `refs/heads/gh-readonly-queue/<base>/pr-<n>-<sha>`.
|
|
354
|
+
change: /^refs\/(?:pull\/(\d+)\/|heads\/gh-readonly-queue\/.+\/pr-(\d+)-[0-9a-f]+$)/
|
|
355
|
+
.exec(v('GITHUB_REF') ?? '')
|
|
356
|
+
?.slice(1)
|
|
357
|
+
.find((x) => x !== undefined),
|
|
350
358
|
pipeline: v('GITHUB_WORKFLOW'),
|
|
351
359
|
job: v('GITHUB_JOB'),
|
|
352
360
|
attempt: n(v('GITHUB_RUN_ATTEMPT')),
|
|
@@ -505,6 +513,12 @@ export function captureWorkspaceIdentity(
|
|
|
505
513
|
fs.mkdirSync(path.dirname(saltPath), { recursive: true })
|
|
506
514
|
fs.writeFileSync(saltPath, salt + '\n')
|
|
507
515
|
}
|
|
516
|
+
try {
|
|
517
|
+
// On every capture: a salt an older vx wrote may sit unignored.
|
|
518
|
+
ignoreDir(path.dirname(saltPath))
|
|
519
|
+
} catch {
|
|
520
|
+
// read-only: the salt is still the identity
|
|
521
|
+
}
|
|
508
522
|
return { id: xxh3hex(salt), name: base, ...where }
|
|
509
523
|
} catch {
|
|
510
524
|
return { id: xxh3hex(workspaceRoot), name: base, ...where }
|
|
@@ -528,7 +542,7 @@ export function currentBranch(
|
|
|
528
542
|
|
|
529
543
|
/**
|
|
530
544
|
* The cache scope a checkout on a branch reads, from the `.git` files
|
|
531
|
-
* alone (
|
|
545
|
+
* alone (2026-10-17): `<owner>/<repo>:<branch>`, the branch it
|
|
532
546
|
* tracks (`branch.<name>.merge` on `branch.<name>.remote`), else its own
|
|
533
547
|
* name on `origin`, the repository named by that remote's URL. None on
|
|
534
548
|
* the default branch, on a detached HEAD, or with no remote URL. CI writes
|
|
@@ -554,41 +568,25 @@ export function localBranchScope(
|
|
|
554
568
|
}
|
|
555
569
|
|
|
556
570
|
/**
|
|
557
|
-
* The refs a remote reads, in order
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
* branch, then the default (`origin/HEAD`). A remote plugin keys or scopes
|
|
562
|
-
* its entries by them (`scope` on turboCache/nxCache); core keys nothing
|
|
563
|
-
* by them.
|
|
571
|
+
* The refs a remote reads, in order: a plugin's (`setBranch`, `github()`
|
|
572
|
+
* and `gitlab()` on CI, 2026-10-25), else git's: the checked-out branch,
|
|
573
|
+
* then the default (`origin/HEAD`). A remote plugin keys or scopes its
|
|
574
|
+
* entries by them (`scope` on turboCache/nxCache); core keys nothing by them.
|
|
564
575
|
*/
|
|
565
576
|
export function cacheRefs(
|
|
566
577
|
workspaceRoot: string,
|
|
567
578
|
baseBranch: string | undefined,
|
|
579
|
+
pluginRefs: readonly string[] | undefined,
|
|
568
580
|
env: NodeJS.ProcessEnv = process.env,
|
|
569
581
|
): string[] {
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const queue = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-[0-9a-f]+$/.exec(own ?? '')
|
|
579
|
-
if (queue !== null) base = head(queue[1])
|
|
580
|
-
} else if (env['GITLAB_CI'] === 'true') {
|
|
581
|
-
const tag = set(env['CI_COMMIT_TAG'])
|
|
582
|
-
own = tag !== undefined ? `refs/tags/${tag}` : head(set(env['CI_COMMIT_REF_NAME']))
|
|
583
|
-
base = head(set(env['CI_MERGE_REQUEST_TARGET_BRANCH_NAME']))
|
|
584
|
-
} else {
|
|
585
|
-
own = head(
|
|
586
|
-
(readHeadDirect(workspaceRoot) ?? captureGitContext(workspaceRoot, null, env)).branch ??
|
|
587
|
-
undefined,
|
|
588
|
-
)
|
|
589
|
-
}
|
|
590
|
-
const trunk = head(baseBranch ?? captureDefaultBranch(env, workspaceRoot) ?? undefined)
|
|
591
|
-
return [...new Set([own, base, trunk].filter((r): r is string => r !== undefined))]
|
|
582
|
+
if (pluginRefs !== undefined) return [...pluginRefs]
|
|
583
|
+
const head = (b: string | null | undefined) => (b == null ? undefined : `refs/heads/${b}`)
|
|
584
|
+
// `{}`: a detached HEAD names no branch here, not CI's (a plugin's job).
|
|
585
|
+
const own = head(
|
|
586
|
+
(readHeadDirect(workspaceRoot) ?? captureGitContext(workspaceRoot, null, {})).branch,
|
|
587
|
+
)
|
|
588
|
+
const trunk = head(baseBranch ?? captureDefaultBranch(env, workspaceRoot))
|
|
589
|
+
return [...new Set([own, trunk].filter((r): r is string => r !== undefined))]
|
|
592
590
|
}
|
|
593
591
|
|
|
594
592
|
/**
|
|
@@ -626,7 +624,7 @@ function gitConfigSections(workspaceRoot: string): Map<string, Map<string, strin
|
|
|
626
624
|
}
|
|
627
625
|
|
|
628
626
|
/**
|
|
629
|
-
* The run's cache scope (
|
|
627
|
+
* The run's cache scope (2026-10-17): `VX_CACHE_SCOPE` (empty: none),
|
|
630
628
|
* else a plugin's (`setBranch`, `github()` on Actions; null: the plugin
|
|
631
629
|
* named the run trusted), else, with a cache plugin to share it, the
|
|
632
630
|
* branch's from git. Undefined: no scope. It keys plugin entries only.
|
|
@@ -641,7 +639,7 @@ export function resolveCacheScope(
|
|
|
641
639
|
const named = env['VX_CACHE_SCOPE']
|
|
642
640
|
if (named !== undefined) {
|
|
643
641
|
if (named === '') return undefined
|
|
644
|
-
if (!isCacheScope(named)) throw cacheScopeEnvError()
|
|
642
|
+
if (!isCacheScope(named)) throw cacheScopeEnvError(named)
|
|
645
643
|
return named
|
|
646
644
|
}
|
|
647
645
|
if (pluginScope === null) return undefined
|
|
@@ -9,7 +9,7 @@ import path from 'node:path'
|
|
|
9
9
|
import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
10
10
|
import { decodeOutputLog } from './output-log.js'
|
|
11
11
|
import { PASS_STATUSES } from '../history/index.js'
|
|
12
|
-
import { isFsRefusal, splitTaskId } from '../util/index.js'
|
|
12
|
+
import { isFsRefusal, maskedLine, splitTaskId } from '../util/index.js'
|
|
13
13
|
import { fileLocations, fileMemo, type OutputLocation } from '../report/index.js'
|
|
14
14
|
|
|
15
15
|
/** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
|
|
@@ -104,10 +104,24 @@ function keep(cacheDir: string, runId: string, tasks: TaskFailure[]): void {
|
|
|
104
104
|
const tmp = `${file}.${process.pid}.tmp`
|
|
105
105
|
writeFileSync(tmp, JSON.stringify({ runId, tasks }))
|
|
106
106
|
renameSync(tmp, file)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
trimKept(dir, KEPT)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Keep the newest `kept` runs' files in `dir`, and the temp files of runs
|
|
112
|
+
* no older: a writer that crashed between its write and its rename left
|
|
113
|
+
* `<runId>.json.<pid>.tmp`, which the trim of `.json` names never took. A
|
|
114
|
+
* live writer's temp is of a run newer than every kept one.
|
|
115
|
+
*/
|
|
116
|
+
export function trimKept(dir: string, kept: number): void {
|
|
117
|
+
const all = readdirSync(dir)
|
|
118
|
+
const names = all.filter((n) => n.endsWith('.json')).sort()
|
|
119
|
+
for (const n of names.slice(0, -kept)) rmSync(path.join(dir, n), { force: true })
|
|
120
|
+
if (names.length < kept) return
|
|
121
|
+
const oldest = names[names.length - kept]!
|
|
122
|
+
for (const n of all)
|
|
123
|
+
if (n.endsWith('.tmp') && n.slice(0, n.lastIndexOf('.json.') + 5) < oldest)
|
|
124
|
+
rmSync(path.join(dir, n), { force: true })
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
/**
|
|
@@ -124,16 +138,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
124
138
|
.get() as { run_id: string } | null
|
|
125
139
|
)?.run_id
|
|
126
140
|
if (id === undefined) return null
|
|
127
|
-
const kept =
|
|
128
|
-
try {
|
|
129
|
-
const stored = JSON.parse(
|
|
130
|
-
readFileSync(path.join(dirOf(cacheDir), `${id}.json`), 'utf8'),
|
|
131
|
-
) as RunFailures & { tasks: StoredFailure[] }
|
|
132
|
-
for (const t of stored.tasks) kept.set(t.taskId, t)
|
|
133
|
-
} catch (err) {
|
|
134
|
-
// No file: the run kept no output.
|
|
135
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
136
|
-
}
|
|
141
|
+
const kept = storedFailures(cacheDir, id)
|
|
137
142
|
const rows = db
|
|
138
143
|
.query(
|
|
139
144
|
`SELECT r.project, r.task, r.exit_code AS exitCode, r.timed_out AS timedOut,
|
|
@@ -168,6 +173,33 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
/** What `runId`'s failures file holds, by task id; empty when the run kept none. */
|
|
177
|
+
function storedFailures(cacheDir: string, runId: string): Map<string, StoredFailure> {
|
|
178
|
+
const kept = new Map<string, StoredFailure>()
|
|
179
|
+
// A run id the caller typed names a file in failures/ only.
|
|
180
|
+
if (path.basename(runId) !== runId || runId === '..') return kept
|
|
181
|
+
let text: string
|
|
182
|
+
try {
|
|
183
|
+
text = readFileSync(path.join(dirOf(cacheDir), `${runId}.json`), 'utf8')
|
|
184
|
+
} catch (err) {
|
|
185
|
+
// No file: the run kept no output.
|
|
186
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
|
|
187
|
+
return kept
|
|
188
|
+
}
|
|
189
|
+
// A file the disk left torn (a rename that landed before its data, on
|
|
190
|
+
// power loss) is output the run did not keep: it threw on every `vx last`
|
|
191
|
+
// until 50 newer runs rotated it out.
|
|
192
|
+
let stored: unknown
|
|
193
|
+
try {
|
|
194
|
+
stored = JSON.parse(text)
|
|
195
|
+
} catch {
|
|
196
|
+
return kept
|
|
197
|
+
}
|
|
198
|
+
const tasks = (stored as { tasks?: unknown } | null)?.tasks
|
|
199
|
+
if (Array.isArray(tasks)) for (const t of tasks as StoredFailure[]) kept.set(t.taskId, t)
|
|
200
|
+
return kept
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
/** One task's output in a recorded run, as `vx last --log` and getTaskLog read it. */
|
|
172
204
|
export interface TaskLog {
|
|
173
205
|
runId: string
|
|
@@ -209,22 +241,32 @@ export function taskLog(
|
|
|
209
241
|
if (row === null) return null
|
|
210
242
|
const base = { runId: row.runId, taskId, status: row.status }
|
|
211
243
|
if (row.status === 'failed') {
|
|
212
|
-
|
|
213
|
-
if
|
|
214
|
-
|
|
215
|
-
}
|
|
244
|
+
// Only the file: runFailures reads `output: ''` for a task it lacks, as
|
|
245
|
+
// if the task printed nothing, when the run is past the newest 50.
|
|
246
|
+
const kept = storedFailures(cacheDir, row.runId).get(taskId)
|
|
247
|
+
if (kept !== undefined) return { ...base, source: 'failure', output: kept.output }
|
|
216
248
|
} else if (row.hash !== '') {
|
|
217
249
|
const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
|
|
218
250
|
stdout: string
|
|
219
251
|
} | null
|
|
220
252
|
if (stored !== null) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
253
|
+
// An entry saved before masking, or by another machine, can hold a
|
|
254
|
+
// secret-named variable's value: masked as a hit's replay masks it.
|
|
255
|
+
// This process's env only: `vx last` loads no config, so a task's
|
|
256
|
+
// `env.define` / `env.secret` values are not known here.
|
|
257
|
+
const output = maskedLine(
|
|
258
|
+
Bun.stripANSI(
|
|
259
|
+
decodeOutputLog(stored.stdout)
|
|
260
|
+
.map((c) => c.text)
|
|
261
|
+
.join(''),
|
|
262
|
+
),
|
|
225
263
|
)
|
|
226
264
|
return { ...base, source: 'cache', output }
|
|
227
265
|
}
|
|
266
|
+
// An entry that printed nothing stores no stdout row: kept, and empty.
|
|
267
|
+
if (db.query('SELECT 1 FROM entries WHERE hash = ?').get(row.hash) !== null) {
|
|
268
|
+
return { ...base, source: 'cache', output: '' }
|
|
269
|
+
}
|
|
228
270
|
}
|
|
229
271
|
return { ...base, source: null, output: '' }
|
|
230
272
|
}
|
|
@@ -10,18 +10,21 @@ import { UserError } from '../util/index.js'
|
|
|
10
10
|
* is refused with those runs, since picking one would replay the wrong run.
|
|
11
11
|
*/
|
|
12
12
|
export function resolveRunId(db: Database, raw: string, verb: string): string | null {
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
// Run ids are lower-case UUIDs; one typed or pasted in upper case is the same id.
|
|
14
|
+
const id = raw.toLowerCase()
|
|
15
|
+
const exact = db.query('SELECT 1 FROM invocations WHERE run_id = ?').get(id)
|
|
16
|
+
if (exact !== null) return id
|
|
15
17
|
const rows = db
|
|
16
18
|
.query(
|
|
17
19
|
'SELECT run_id FROM invocations WHERE substr(run_id, 1, ?) = ? ORDER BY run_id DESC LIMIT 6',
|
|
18
20
|
)
|
|
19
|
-
.all(
|
|
21
|
+
.all(id.length, id) as Array<{ run_id: string }>
|
|
20
22
|
if (rows.length === 0) return null
|
|
21
23
|
if (rows.length === 1) return rows[0]!.run_id
|
|
22
24
|
const shown = rows.slice(0, 5).map((r) => ` ${r.run_id}`)
|
|
23
25
|
throw new UserError(
|
|
24
26
|
`${verb}: run id ${raw} is the start of ${rows.length > 5 ? 'more than 5' : rows.length} runs — type more of it:\n${shown.join('\n')}`,
|
|
27
|
+
'VX_E_AMBIGUOUS',
|
|
25
28
|
)
|
|
26
29
|
}
|
|
27
30
|
|