@vzn/vx 0.0.25 → 0.0.27
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.27",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.27",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.27",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.27",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.27"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
|
@@ -3,6 +3,13 @@
|
|
|
3
3
|
// layer owns the run index (history, stats, prune) so a run is recorded
|
|
4
4
|
// once. Restore goes to the layer that produced the hit — remembered per
|
|
5
5
|
// hash — because an entry's artifact lives wherever it was found.
|
|
6
|
+
//
|
|
7
|
+
// A layer that throws in a lookup is a miss there and the walk goes on; one
|
|
8
|
+
// that throws in a save is skipped and the rest still save. Unisolated, a
|
|
9
|
+
// plugin layer's throw ended the walk before the local floor under it: its
|
|
10
|
+
// `get` failed the task, its `save` kept the entry from every later layer
|
|
11
|
+
// (item 1020). A save that fails in EVERY layer still throws, so the
|
|
12
|
+
// caller's own "cache save failed" line stays the one word on it.
|
|
6
13
|
|
|
7
14
|
import type {
|
|
8
15
|
Cache,
|
|
@@ -21,15 +28,31 @@ import type {
|
|
|
21
28
|
RunRecord,
|
|
22
29
|
} from './cache.js'
|
|
23
30
|
|
|
31
|
+
/** Told of a layer's failure the chain went past: its index, the method, the error. */
|
|
32
|
+
export type LayerErrorReport = (layer: number, method: string, err: unknown) => void
|
|
33
|
+
|
|
24
34
|
export class ChainedCache implements CacheLayer {
|
|
25
35
|
readonly hasRemote: boolean
|
|
26
36
|
private readonly hitLayer = new Map<string, CacheLayer>()
|
|
27
37
|
|
|
28
|
-
constructor(
|
|
38
|
+
constructor(
|
|
39
|
+
readonly layers: readonly CacheLayer[],
|
|
40
|
+
private readonly onLayerError: LayerErrorReport = () => undefined,
|
|
41
|
+
) {
|
|
29
42
|
if (layers.length < 2) throw new Error('ChainedCache needs at least two layers')
|
|
30
43
|
this.hasRemote = layers.some((l) => l.hasRemote === true)
|
|
31
44
|
}
|
|
32
45
|
|
|
46
|
+
/** `layer`'s answer, or `fallback` once its failure is reported. */
|
|
47
|
+
private async ask<T>(layer: CacheLayer, method: string, call: () => Promise<T>, fallback: T) {
|
|
48
|
+
try {
|
|
49
|
+
return await call()
|
|
50
|
+
} catch (err) {
|
|
51
|
+
this.onLayerError(this.layers.indexOf(layer), method, err)
|
|
52
|
+
return fallback
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
33
56
|
get local(): Cache | undefined {
|
|
34
57
|
return this.layers[0]!.local
|
|
35
58
|
}
|
|
@@ -49,11 +72,11 @@ export class ChainedCache implements CacheLayer {
|
|
|
49
72
|
// saved locally, in the outcome, the summary and telemetry (item 889).
|
|
50
73
|
const owner = this.hitLayer.get(hash)
|
|
51
74
|
if (owner !== undefined) {
|
|
52
|
-
const entry = await owner.get(hash, ctx)
|
|
75
|
+
const entry = await this.ask(owner, 'get', () => owner.get(hash, ctx), null)
|
|
53
76
|
if (entry !== null) return entry
|
|
54
77
|
}
|
|
55
78
|
for (const layer of this.layers) {
|
|
56
|
-
const entry = await layer.get(hash, ctx)
|
|
79
|
+
const entry = await this.ask(layer, 'get', () => layer.get(hash, ctx), null)
|
|
57
80
|
if (entry !== null) {
|
|
58
81
|
this.hitLayer.set(hash, layer)
|
|
59
82
|
return entry
|
|
@@ -64,7 +87,7 @@ export class ChainedCache implements CacheLayer {
|
|
|
64
87
|
|
|
65
88
|
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
66
89
|
for (const layer of this.layers) {
|
|
67
|
-
const where = await layer.has(hash)
|
|
90
|
+
const where = await this.ask(layer, 'has', () => layer.has(hash), null)
|
|
68
91
|
if (where !== null) {
|
|
69
92
|
this.hitLayer.set(hash, layer)
|
|
70
93
|
return where
|
|
@@ -75,7 +98,7 @@ export class ChainedCache implements CacheLayer {
|
|
|
75
98
|
|
|
76
99
|
async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
77
100
|
for (const layer of this.layers) {
|
|
78
|
-
if (await layer.prefetch(hash, ctx)) {
|
|
101
|
+
if (await this.ask(layer, 'prefetch', () => layer.prefetch(hash, ctx), false)) {
|
|
79
102
|
this.hitLayer.set(hash, layer)
|
|
80
103
|
return true
|
|
81
104
|
}
|
|
@@ -96,7 +119,12 @@ export class ChainedCache implements CacheLayer {
|
|
|
96
119
|
let complete = true
|
|
97
120
|
for (const layer of this.layers) {
|
|
98
121
|
if (layer.hasRemote !== true) continue
|
|
99
|
-
const found =
|
|
122
|
+
const found = await this.ask(
|
|
123
|
+
layer,
|
|
124
|
+
'remoteHasMany',
|
|
125
|
+
async () => (await layer.remoteHasMany?.(hashes)) ?? null,
|
|
126
|
+
null,
|
|
127
|
+
)
|
|
100
128
|
if (found === null) {
|
|
101
129
|
complete = false
|
|
102
130
|
continue
|
|
@@ -159,12 +187,20 @@ export class ChainedCache implements CacheLayer {
|
|
|
159
187
|
// `skipLocalWrite` and go straight to their remote upload (which reads
|
|
160
188
|
// the artifact the first layer just wrote — same handle, same path).
|
|
161
189
|
const seenLocals = new Set<NonNullable<CacheLayer['local']>>()
|
|
162
|
-
|
|
190
|
+
const failures: Array<[layer: number, err: unknown]> = []
|
|
191
|
+
for (const [i, layer] of this.layers.entries()) {
|
|
163
192
|
const local = layer.local
|
|
164
193
|
const skip = local !== undefined && seenLocals.has(local)
|
|
165
|
-
|
|
194
|
+
try {
|
|
195
|
+
await layer.save(skip ? { ...args, skipLocalWrite: true } : args)
|
|
196
|
+
} catch (err) {
|
|
197
|
+
failures.push([i, err])
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
166
200
|
if (local !== undefined) seenLocals.add(local)
|
|
167
201
|
}
|
|
202
|
+
if (failures.length === this.layers.length) throw failures[0]![1]
|
|
203
|
+
for (const [i, err] of failures) this.onLayerError(i, 'save', err)
|
|
168
204
|
}
|
|
169
205
|
|
|
170
206
|
ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
@@ -326,6 +326,7 @@ export async function resolveCache(
|
|
|
326
326
|
ctx: CacheContext,
|
|
327
327
|
): Promise<CacheLayer> {
|
|
328
328
|
const layers: CacheLayer[] = []
|
|
329
|
+
const owners = new Map<CacheLayer, string>()
|
|
329
330
|
for (const plugin of plugins) {
|
|
330
331
|
if (plugin.cache === undefined) continue
|
|
331
332
|
const layer = await safe(plugin, 'cache', () => plugin.cache!(ctx))
|
|
@@ -337,6 +338,7 @@ export async function resolveCache(
|
|
|
337
338
|
// passed and died at its first hit inside restoreOutputs.
|
|
338
339
|
assertShape(plugin, 'cache', layer, CACHE_LAYER_METHODS, 'a cache layer')
|
|
339
340
|
layers.push(layer)
|
|
341
|
+
if (!owners.has(layer)) owners.set(layer, `plugin '${plugin.name}'`)
|
|
340
342
|
}
|
|
341
343
|
// Core's own store is the TAIL of the chain — the floor under every
|
|
342
344
|
// lookup, not a plugin a workspace has to declare. A layer that WRAPS
|
|
@@ -345,7 +347,21 @@ export async function resolveCache(
|
|
|
345
347
|
if (!layers.includes(ctx.localCache)) layers.push(ctx.localCache)
|
|
346
348
|
const wrapsLocal = layers.some((l) => l !== ctx.localCache && l.local === ctx.localCache)
|
|
347
349
|
const distinct = wrapsLocal ? layers.filter((l) => l !== ctx.localCache) : layers
|
|
348
|
-
|
|
350
|
+
if (distinct.length === 1) return distinct[0]!
|
|
351
|
+
// One line per layer and method: a layer that throws on every lookup
|
|
352
|
+
// would otherwise print once per task.
|
|
353
|
+
const told = new Set<string>()
|
|
354
|
+
return new ChainedCache(distinct, (i, method, err) => {
|
|
355
|
+
const layer = distinct[i]!
|
|
356
|
+
const who =
|
|
357
|
+
layer === ctx.localCache ? 'the local cache' : (owners.get(layer) ?? 'a cache layer')
|
|
358
|
+
if (told.has(`${i}\0${method}`)) return
|
|
359
|
+
told.add(`${i}\0${method}`)
|
|
360
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
361
|
+
const then =
|
|
362
|
+
method === 'save' ? 'the other layers still save' : 'a miss there; the next layer answers'
|
|
363
|
+
ctx.warn(`[vx] ${who} failed in cache ${method}: ${m}; ${then}`)
|
|
364
|
+
})
|
|
349
365
|
}
|
|
350
366
|
|
|
351
367
|
/**
|
|
@@ -380,9 +396,10 @@ export async function resolveExecutors(
|
|
|
380
396
|
* End-of-run plugin lifecycle: each plugin's optional `teardown()`, in
|
|
381
397
|
* declaration order. Crash-isolated — a throwing teardown is logged and
|
|
382
398
|
* skipped, never propagated — and each call is time-bounded by
|
|
383
|
-
* {@link teardownTimeoutMs}. Runs on
|
|
384
|
-
*
|
|
385
|
-
*
|
|
399
|
+
* {@link teardownTimeoutMs}. Runs on every exit of a run once its plugins'
|
|
400
|
+
* factories ran, and of a plan (item 1021); the finally-path disposers
|
|
401
|
+
* just unsubscribe. (Telemetry sinks flush before this, in
|
|
402
|
+
* telemetry-host.ts, with the same reporting rule.)
|
|
386
403
|
*
|
|
387
404
|
* The bound is PER CALL and the calls are sequential, so the worst case
|
|
388
405
|
* composes: measured at the 3s default, 1/2/3 simultaneously-hung plugins
|
|
@@ -442,6 +442,20 @@ export interface InstallPluginsArgs {
|
|
|
442
442
|
warn?: (message: string) => void
|
|
443
443
|
}
|
|
444
444
|
|
|
445
|
+
/**
|
|
446
|
+
* A plugin's `setup` threw. It names the plugin so the run can tear down
|
|
447
|
+
* every other one: their capability factories and setups ran, and the
|
|
448
|
+
* one that failed set up nothing to release.
|
|
449
|
+
*/
|
|
450
|
+
export class PluginSetupError extends UserError {
|
|
451
|
+
constructor(
|
|
452
|
+
readonly plugin: Plugin,
|
|
453
|
+
message: string,
|
|
454
|
+
) {
|
|
455
|
+
super(message)
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
445
459
|
/**
|
|
446
460
|
* Install every plugin's `setup` hook against a shared bus + context.
|
|
447
461
|
* Synchronous loop; setup() promises are awaited in order so a plugin's
|
|
@@ -534,7 +548,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
534
548
|
try {
|
|
535
549
|
await plugin.setup(ctx)
|
|
536
550
|
} catch (err) {
|
|
537
|
-
throw new
|
|
551
|
+
throw new PluginSetupError(
|
|
552
|
+
plugin,
|
|
538
553
|
`plugin '${plugin.name}' failed to load: ${err instanceof Error ? err.message : String(err)}`,
|
|
539
554
|
)
|
|
540
555
|
}
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -39,7 +39,7 @@ import { admitTasks, taintTracker } from './admission.js'
|
|
|
39
39
|
import { excludedTaint } from './excluded-keys.js'
|
|
40
40
|
import { keyUpstream } from './upstream.js'
|
|
41
41
|
import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
|
|
42
|
-
import { installPlugins } from './plugin.js'
|
|
42
|
+
import { installPlugins, PluginSetupError } from './plugin.js'
|
|
43
43
|
import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
44
44
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
45
45
|
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
@@ -217,6 +217,19 @@ async function runOnBus(
|
|
|
217
217
|
|
|
218
218
|
const prepared = await prepareRun(options, log)
|
|
219
219
|
mark('plugin stages')
|
|
220
|
+
// Every exit from here tears the plugins down: their capability
|
|
221
|
+
// factories ran in prepareRun, and teardown is what releases what those
|
|
222
|
+
// opened (a client, a channel). Until item 1021 only the normal end did,
|
|
223
|
+
// so an early return, a refused setup or a throw left them held — once
|
|
224
|
+
// per cycle under `vx watch`. A plugin whose own setup threw set up
|
|
225
|
+
// nothing, and is the one left out.
|
|
226
|
+
let tornDown = false
|
|
227
|
+
const teardown = async (except?: unknown): Promise<void> => {
|
|
228
|
+
if (tornDown) return
|
|
229
|
+
tornDown = true
|
|
230
|
+
const plugins = prepared.plugins.filter((p) => p !== except)
|
|
231
|
+
await teardownPlugins(plugins, (m) => log.status(m))
|
|
232
|
+
}
|
|
220
233
|
// A task whose command re-enters `vx run` in the workspace running it
|
|
221
234
|
// is refused. When the inner run reaches this task again it forks a run
|
|
222
235
|
// per run until the machine gives out; when it does not (`ci` shelling
|
|
@@ -226,6 +239,7 @@ async function runOnBus(
|
|
|
226
239
|
// name the task; the check is on the root so both shapes are caught.
|
|
227
240
|
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
|
|
228
241
|
if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
|
|
242
|
+
await teardown()
|
|
229
243
|
prepared.cache.close()
|
|
230
244
|
throw new UserError(
|
|
231
245
|
`task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} runs \`vx run\` inside its own workspace: a nested run is invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key) and a loop back to this task forks without bound. Declare what it needs with dependsOn instead.`,
|
|
@@ -241,6 +255,7 @@ async function runOnBus(
|
|
|
241
255
|
log.status(
|
|
242
256
|
`No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${await initHint(prepared)}`,
|
|
243
257
|
)
|
|
258
|
+
await teardown()
|
|
244
259
|
prepared.cache.close()
|
|
245
260
|
return { ok: false, outcomes: [] }
|
|
246
261
|
}
|
|
@@ -254,6 +269,7 @@ async function runOnBus(
|
|
|
254
269
|
? `No projects declare task(s): ${options.tasks.join(', ')}.${await initHint(prepared)}`
|
|
255
270
|
: 'No tasks to run.'
|
|
256
271
|
log.status(msg)
|
|
272
|
+
await teardown()
|
|
257
273
|
prepared.cache.close()
|
|
258
274
|
return { ok: false, outcomes: [] }
|
|
259
275
|
}
|
|
@@ -273,9 +289,21 @@ async function runOnBus(
|
|
|
273
289
|
})
|
|
274
290
|
} catch (err) {
|
|
275
291
|
disposePlugins?.()
|
|
292
|
+
await teardown(err instanceof PluginSetupError ? err.plugin : undefined)
|
|
276
293
|
prepared.cache.close()
|
|
277
294
|
throw err
|
|
278
295
|
}
|
|
296
|
+
// Until the run's own try below, a throw leaves through here.
|
|
297
|
+
const abandoning = async <T>(step: () => T | Promise<T>): Promise<T> => {
|
|
298
|
+
try {
|
|
299
|
+
return await step()
|
|
300
|
+
} catch (err) {
|
|
301
|
+
disposePlugins?.()
|
|
302
|
+
await teardown()
|
|
303
|
+
prepared.cache.close()
|
|
304
|
+
throw err
|
|
305
|
+
}
|
|
306
|
+
}
|
|
279
307
|
const {
|
|
280
308
|
workspaceRoot,
|
|
281
309
|
workspaceConfig,
|
|
@@ -293,24 +321,19 @@ async function runOnBus(
|
|
|
293
321
|
|
|
294
322
|
// Resolved ONCE per run, in declaration order, the local executor last.
|
|
295
323
|
// A broken factory aborts here, before any task starts.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
executors = await resolveExecutors(prepared.plugins, {
|
|
324
|
+
const executors: readonly TaskExecutor[] = await abandoning(() =>
|
|
325
|
+
resolveExecutors(prepared.plugins, {
|
|
299
326
|
workspaceRoot: prepared.workspaceRoot,
|
|
300
327
|
cacheDir: prepared.cacheDir,
|
|
301
328
|
warn: (m: string) => log.status(m),
|
|
302
329
|
concurrency,
|
|
303
|
-
})
|
|
304
|
-
|
|
305
|
-
disposePlugins?.()
|
|
306
|
-
prepared.cache.close()
|
|
307
|
-
throw err
|
|
308
|
-
}
|
|
330
|
+
}),
|
|
331
|
+
)
|
|
309
332
|
// Placement: decided ONCE per task, before scheduling, so the scheduler
|
|
310
333
|
// can admit a remote-pooled task against its pool instead of a local
|
|
311
334
|
// worker slot. Group tasks run nothing; persistent tasks never reach an
|
|
312
335
|
// executor (local by construction) — both stay off the map.
|
|
313
|
-
const placements = placeTasks(nodes, executors)
|
|
336
|
+
const placements = await abandoning(() => placeTasks(nodes, executors))
|
|
314
337
|
// A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
|
|
315
338
|
// deliberate — on a machine with no remote pool the ambient state already
|
|
316
339
|
// is what the task would have produced — but it must not be SILENT: a task
|
|
@@ -342,7 +365,9 @@ async function runOnBus(
|
|
|
342
365
|
}
|
|
343
366
|
// The SAME set is handed back every time, narrowed in place: a consumer
|
|
344
367
|
// reading it later sees the truth rather than a stale snapshot.
|
|
345
|
-
|
|
368
|
+
await abandoning(() => {
|
|
369
|
+
for (const [executor, remaining] of demandOf) executor.demand!(remaining)
|
|
370
|
+
})
|
|
346
371
|
const narrowDemand =
|
|
347
372
|
demandOf.size === 0
|
|
348
373
|
? (): void => {}
|
|
@@ -457,10 +482,12 @@ async function runOnBus(
|
|
|
457
482
|
}
|
|
458
483
|
// Taken after the early exits above (nothing they do touches a tree) and
|
|
459
484
|
// before the schedule: from here on tasks clean, restore and write.
|
|
460
|
-
const releaseRunLock = await
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
485
|
+
const releaseRunLock = await abandoning(() =>
|
|
486
|
+
acquireRunLock(prepared.workspaceRoot, {
|
|
487
|
+
log: (m) => log.status(m),
|
|
488
|
+
signal: stopRun.signal,
|
|
489
|
+
}),
|
|
490
|
+
)
|
|
464
491
|
try {
|
|
465
492
|
// One run-id per `vx run` invocation. Every task in the resulting
|
|
466
493
|
// graph carries it so analytics queries can group by invocation.
|
|
@@ -900,8 +927,8 @@ async function runOnBus(
|
|
|
900
927
|
}
|
|
901
928
|
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
902
929
|
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
903
|
-
// fail nor hang the run.
|
|
904
|
-
// below
|
|
930
|
+
// fail nor hang the run. Here on the normal path, after the drains
|
|
931
|
+
// below; every other exit takes it through `teardown` too.
|
|
905
932
|
// Drain any still-in-flight background prefetches before closing the
|
|
906
933
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
907
934
|
// throw. Tasks that resolved as local hits never awaited their
|
|
@@ -939,7 +966,7 @@ async function runOnBus(
|
|
|
939
966
|
// Not on a stopped run: one stopped while it waited on another run's
|
|
940
967
|
// lock never held it, and its prune evicted under that run (item 858).
|
|
941
968
|
if (!stopRun.signal.aborted) await applyCacheRetention(prepared, log)
|
|
942
|
-
await
|
|
969
|
+
await teardown()
|
|
943
970
|
await closeCache()
|
|
944
971
|
mark('close')
|
|
945
972
|
printTimings()
|
|
@@ -1019,6 +1046,8 @@ async function runOnBus(
|
|
|
1019
1046
|
// released here. Idempotent; safe even if installPlugins threw.
|
|
1020
1047
|
disposePlugins?.()
|
|
1021
1048
|
telemetry?.dispose()
|
|
1049
|
+
// No-op after the normal path's teardown; on a throw, the only one.
|
|
1050
|
+
await teardown()
|
|
1022
1051
|
// No-op after the normal path's close. On a throw this is the only
|
|
1023
1052
|
// close there is, and it must not itself throw — that would replace
|
|
1024
1053
|
// the run's real error with a teardown one. Background uploads are
|
|
@@ -1123,6 +1152,9 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1123
1152
|
mark('plan')
|
|
1124
1153
|
return planned
|
|
1125
1154
|
} finally {
|
|
1155
|
+
// The plan called the cache and executor factories; teardown releases
|
|
1156
|
+
// what they opened, as a run's end does (item 1021).
|
|
1157
|
+
await teardownPlugins(prepared.plugins, (m) => log.status(m))
|
|
1126
1158
|
prepared.cache.close()
|
|
1127
1159
|
// The same table a run prints: a dry run is how the prepare stages
|
|
1128
1160
|
// (discovery, config load, the git enumeration) get profiled on a
|