@vzn/vx 0.0.18 → 0.0.21
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 +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
package/src/orchestrator/run.ts
CHANGED
|
@@ -3,19 +3,13 @@
|
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
5
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
8
|
-
type CacheLayer,
|
|
9
|
-
type CachePolicy,
|
|
10
|
-
FULL_CACHE_POLICY,
|
|
11
|
-
type InvocationRecord,
|
|
12
|
-
type RunRecord,
|
|
13
|
-
} from '../cache/index.js'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
14
8
|
import { VERSION } from '../version.js'
|
|
15
|
-
import {
|
|
9
|
+
import { resetSandbox, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
|
|
16
10
|
import { DeferredOutputs } from './deferred-outputs.js'
|
|
17
11
|
import { resolveDownloadModes } from './download-policy.js'
|
|
18
|
-
import {
|
|
12
|
+
import type { TaskExecutor } from '../exec/index.js'
|
|
19
13
|
import {
|
|
20
14
|
isGroupTask,
|
|
21
15
|
markSurfacedDeps,
|
|
@@ -23,22 +17,38 @@ import {
|
|
|
23
17
|
type TaskNode,
|
|
24
18
|
type TaskOutcome,
|
|
25
19
|
} from '../graph/index.js'
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
import {
|
|
21
|
+
mark,
|
|
22
|
+
MAX_TIMEOUT_MS,
|
|
23
|
+
printTimings,
|
|
24
|
+
ulid,
|
|
25
|
+
nearest,
|
|
26
|
+
UserError,
|
|
27
|
+
machineParallelism,
|
|
28
|
+
} from '../util/index.js'
|
|
29
|
+
import { prepareSandbox } from './sandbox-request.js'
|
|
30
|
+
import type { OutputDirSnapshot } from './miss-save.js'
|
|
31
|
+
import { admitTasks, taintTracker } from './admission.js'
|
|
30
32
|
import { busLogger, createEventBus, terminalSubscriber } from './events.js'
|
|
31
33
|
import { installPlugins } from './plugin.js'
|
|
32
|
-
import { resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
34
|
+
import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
33
35
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
34
|
-
import { assembleRunSummary,
|
|
35
|
-
import type { RunContextRecord
|
|
36
|
-
import { defaultLogger, resolveOutputView
|
|
36
|
+
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
37
|
+
import type { RunContextRecord } from './telemetry.js'
|
|
38
|
+
import { defaultLogger, resolveOutputView } from './logger.js'
|
|
37
39
|
import { detectColors } from './colors.js'
|
|
38
40
|
import { formatPersistentList } from './framed-output.js'
|
|
39
41
|
import { LocalHistoryProvider } from './history.js'
|
|
40
42
|
import { plan, type RunPlan } from './plan.js'
|
|
41
43
|
import { prepareRun } from './prepare.js'
|
|
44
|
+
import { forwardSignals, terminateChildren } from './signals.js'
|
|
45
|
+
import {
|
|
46
|
+
hasPooledExecutor,
|
|
47
|
+
placeTasks,
|
|
48
|
+
planExecutorOf,
|
|
49
|
+
poolOfPlacement,
|
|
50
|
+
UNPLACED_EXECUTOR,
|
|
51
|
+
} from './placement.js'
|
|
42
52
|
import {
|
|
43
53
|
captureDefaultBranch,
|
|
44
54
|
captureGitContext,
|
|
@@ -48,21 +58,19 @@ import {
|
|
|
48
58
|
} from './run-context.js'
|
|
49
59
|
import { startRemotePrefetch } from './remote-prefetch.js'
|
|
50
60
|
import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
|
|
51
|
-
import { formatVerifySection } from './verify.js'
|
|
52
|
-
|
|
53
|
-
const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
|
|
54
61
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* before force-killing any that trap or ignore it — so a wedged mock server
|
|
58
|
-
* can't hang a normal run at completion. Well-behaved servers exit far under
|
|
59
|
-
* this, so the happy path never waits it out.
|
|
60
|
-
*/
|
|
61
|
-
const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
|
|
62
|
+
import { assembleRunRecords } from './run-records.js'
|
|
63
|
+
import { selectKeepAlive, shutdownPersistent } from './persistent.js'
|
|
62
64
|
import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
|
|
63
|
-
import {
|
|
65
|
+
import { createSaveLane } from './save-lane.js'
|
|
66
|
+
import { formatAbortedSection, formatFlakySection, formatRunSummary } from './summary.js'
|
|
67
|
+
import { detectFlaky, type FlakyCandidate } from './failure-mode.js'
|
|
64
68
|
import type { RunOptions, RunSummary } from './options.js'
|
|
65
69
|
|
|
70
|
+
// Per run, never shared: a `vx watch` process runs many, and a shared map
|
|
71
|
+
// is one `preProbed.set` away from leaking a hit across cycles.
|
|
72
|
+
const emptyShortCircuit = (): ShortCircuit => ({ preProbed: new Map(), restoreTier: new Set() })
|
|
73
|
+
|
|
66
74
|
/**
|
|
67
75
|
* Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
|
|
68
76
|
* timeout default. A missing/empty/non-positive-integer value yields
|
|
@@ -167,7 +175,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
167
175
|
const log = busLogger(bus)
|
|
168
176
|
|
|
169
177
|
const prepared = await prepareRun(options, log)
|
|
170
|
-
mark('
|
|
178
|
+
mark('plugin stages')
|
|
179
|
+
// A task whose command re-enters `vx run` in the workspace running it
|
|
180
|
+
// is refused. When the inner run reaches this task again it forks a run
|
|
181
|
+
// per run until the machine gives out; when it does not (`ci` shelling
|
|
182
|
+
// out to `vx run lint`) it is a nested run the outer graph cannot see —
|
|
183
|
+
// its tasks escape the schedule, the concurrency budget and this task's
|
|
184
|
+
// cache key. The markers `taskEnv` sets on every child (exec/env.ts)
|
|
185
|
+
// name the task; the check is on the root so both shapes are caught.
|
|
186
|
+
const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
|
|
187
|
+
if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
|
|
188
|
+
prepared.cache.close()
|
|
189
|
+
throw new UserError(
|
|
190
|
+
`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.`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
171
193
|
// A requested name that matched no project is a typo (or a stray
|
|
172
194
|
// positional from an `=`-only flag written with a space). Failing the
|
|
173
195
|
// whole run — even when OTHER requested tasks resolved — is the point:
|
|
@@ -176,7 +198,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
176
198
|
// case too; the message is identical, so that branch stays below.
|
|
177
199
|
if (prepared.unresolvedTasks.length > 0) {
|
|
178
200
|
log.status(
|
|
179
|
-
`No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}`,
|
|
201
|
+
`No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${initHint(prepared)}`,
|
|
180
202
|
)
|
|
181
203
|
prepared.cache.close()
|
|
182
204
|
return { ok: false, outcomes: [] }
|
|
@@ -188,7 +210,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
188
210
|
// buildTaskGraph semantics but logged just in case.
|
|
189
211
|
const msg =
|
|
190
212
|
prepared.empty === 'no-tasks-declared'
|
|
191
|
-
? `No projects declare task(s): ${options.tasks.join(', ')}
|
|
213
|
+
? `No projects declare task(s): ${options.tasks.join(', ')}.${initHint(prepared)}`
|
|
192
214
|
: 'No tasks to run.'
|
|
193
215
|
log.status(msg)
|
|
194
216
|
prepared.cache.close()
|
|
@@ -197,8 +219,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
197
219
|
// Install user plugins as additional bus subscribers BEFORE the run
|
|
198
220
|
// starts emitting events. `installPlugins` runs each plugin's optional
|
|
199
221
|
// `setup` hook and fails fast on a throw with a clean UserError naming
|
|
200
|
-
// the plugin. A plugin without `setup`
|
|
201
|
-
// subscribes nothing.
|
|
222
|
+
// the plugin. A plugin without `setup` subscribes nothing.
|
|
202
223
|
let disposePlugins: (() => void) | undefined
|
|
203
224
|
let telemetry: TelemetryHandle | undefined
|
|
204
225
|
try {
|
|
@@ -226,14 +247,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
226
247
|
hashCache,
|
|
227
248
|
workspaceProjectCount,
|
|
228
249
|
} = prepared
|
|
229
|
-
const concurrency =
|
|
230
|
-
options.concurrency ??
|
|
231
|
-
workspaceConfig?.concurrency ??
|
|
232
|
-
Math.max(1, navigator.hardwareConcurrency)
|
|
250
|
+
const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
|
|
233
251
|
|
|
234
|
-
// Resolved ONCE per run, in declaration order
|
|
235
|
-
//
|
|
236
|
-
// starts.
|
|
252
|
+
// Resolved ONCE per run, in declaration order, the local executor last.
|
|
253
|
+
// A broken factory aborts here, before any task starts.
|
|
237
254
|
let executors: readonly TaskExecutor[]
|
|
238
255
|
try {
|
|
239
256
|
executors = await resolveExecutors(prepared.plugins, {
|
|
@@ -251,13 +268,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
251
268
|
// can admit a remote-pooled task against its pool instead of a local
|
|
252
269
|
// worker slot. Group tasks run nothing; persistent tasks never reach an
|
|
253
270
|
// executor (local by construction) — both stay off the map.
|
|
254
|
-
|
|
255
|
-
// `--verify=inputs` pins EVERYTHING local: the input-completeness proof
|
|
256
|
-
// is the OS sandbox, which is local machinery a remote executor silently
|
|
257
|
-
// ignores — so a remotely-executed task would pass the verify VACUOUSLY,
|
|
258
|
-
// leaky or not. A verify run is a local proof procedure by definition;
|
|
259
|
-
// determinism/fingerprint modes are unaffected (no sandbox involved).
|
|
260
|
-
const placements = placeTasks(nodes, executors, options.verify?.inputs === true)
|
|
271
|
+
const placements = placeTasks(nodes, executors, false)
|
|
261
272
|
// A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
|
|
262
273
|
// deliberate — on a machine with no remote pool the ambient state already
|
|
263
274
|
// is what the task would have produced — but it must not be SILENT: a task
|
|
@@ -300,24 +311,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
300
311
|
}
|
|
301
312
|
|
|
302
313
|
// `--download` (default `all`) decides ONCE per task, here, whether a
|
|
303
|
-
// remote execution's outputs come home.
|
|
304
|
-
|
|
305
|
-
// byte for byte.
|
|
306
|
-
// A proof must observe what it proves. `--verify=inputs` already pins
|
|
307
|
-
// every task LOCAL (so nothing could defer), but determinism and
|
|
308
|
-
// fingerprint modes do NOT — and a deferred task's outputs are absent
|
|
309
|
-
// when the verifier looks, which reported `no-outputs` for a task that
|
|
310
|
-
// declares outputs: an n/a verdict for work the proof never examined.
|
|
311
|
-
// Deferral is transfer tuning; a verify run is a rare, deliberate
|
|
312
|
-
// correctness run. Eager wins, and says so when it overrides.
|
|
313
|
-
const downloadPolicy = options.verify !== undefined ? 'all' : (options.download ?? 'all')
|
|
314
|
-
if (
|
|
315
|
-
options.verify !== undefined &&
|
|
316
|
-
options.download !== undefined &&
|
|
317
|
-
options.download !== 'all'
|
|
318
|
-
) {
|
|
319
|
-
log.status(`vx: --verify observes outputs on disk — ignoring --download=${options.download}`)
|
|
320
|
-
}
|
|
314
|
+
// remote execution's outputs come home.
|
|
315
|
+
const downloadPolicy = options.download ?? 'all'
|
|
321
316
|
const localPlaced = new Set(
|
|
322
317
|
[...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
|
|
323
318
|
)
|
|
@@ -344,39 +339,25 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
344
339
|
// exit; ownership moves here so the orchestrator can SIGTERM
|
|
345
340
|
// them once the rest of the graph finishes.
|
|
346
341
|
//
|
|
347
|
-
// A SIGINT/SIGTERM mid-run forwards SIGTERM to everything
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// cancellation, `kill <pid>`) orphans every running child —
|
|
351
|
-
// terminal Ctrl-C only worked via process-group propagation. The
|
|
352
|
-
// handlers are removed in the finally below so repeated run()
|
|
353
|
-
// calls (test suites) never stack listeners.
|
|
342
|
+
// A SIGINT/SIGTERM mid-run forwards SIGTERM to everything in both
|
|
343
|
+
// (`signals.ts`); the handlers are removed in the finally below so
|
|
344
|
+
// repeated run() calls (test suites) never stack listeners.
|
|
354
345
|
const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
|
|
355
346
|
const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
cache.close()
|
|
369
|
-
} catch {
|
|
370
|
-
// double-close race with the normal path; we're exiting anyway
|
|
371
|
-
}
|
|
372
|
-
process.exit(signalExitCode(signal))
|
|
373
|
-
}
|
|
374
|
-
const onSigint = (): void => onSignal('SIGINT')
|
|
375
|
-
const onSigterm = (): void => onSignal('SIGTERM')
|
|
376
|
-
if (options.handleSignals ?? true) {
|
|
377
|
-
process.on('SIGINT', onSigint)
|
|
378
|
-
process.on('SIGTERM', onSigterm)
|
|
347
|
+
const signals = forwardSignals({
|
|
348
|
+
enabled: options.handleSignals ?? true,
|
|
349
|
+
log,
|
|
350
|
+
cache,
|
|
351
|
+
liveChildren,
|
|
352
|
+
persistentRegistry,
|
|
353
|
+
})
|
|
354
|
+
// `RunOptions.signal`: the same teardown the process handler runs, minus
|
|
355
|
+
// the exit — the scheduler stops dispatching (it reads the signal) and
|
|
356
|
+
// run() returns to its caller. Detached in the finally below.
|
|
357
|
+
const onAbort = (): void => {
|
|
358
|
+
void terminateChildren(() => [...liveChildren, ...persistentRegistry.values()])
|
|
379
359
|
}
|
|
360
|
+
options.signal?.addEventListener('abort', onAbort, { once: true })
|
|
380
361
|
// The cache handle must be released on EVERY exit path, not just the
|
|
381
362
|
// happy one: `close()` is also where the run's deferred `accessed_at`
|
|
382
363
|
// bumps are flushed, so a throw between opening the cache and the
|
|
@@ -399,17 +380,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
399
380
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
400
381
|
const endedAtMsAtStart = Date.now()
|
|
401
382
|
const remoteCacheEnabled = prepared.hasRemoteLayer
|
|
402
|
-
// Normalised ONCE so every consumer —
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
// happened
|
|
407
|
-
// that was never written (wiping a successful build's tree and
|
|
408
|
-
// reporting it failed).
|
|
383
|
+
// Normalised ONCE so every consumer — execute-task, the dedup
|
|
384
|
+
// predicate, the recorded invocation row — reads the policy that
|
|
385
|
+
// actually governed the run. Reading the raw request here made tasks
|
|
386
|
+
// clean their outputs before every exec for a save that never
|
|
387
|
+
// happened.
|
|
409
388
|
const policy: CachePolicy = effectiveCachePolicy(
|
|
410
389
|
options.cache ?? FULL_CACHE_POLICY,
|
|
411
390
|
prepared.hasRemoteLayer,
|
|
412
391
|
)
|
|
392
|
+
if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
|
|
393
|
+
log.status(
|
|
394
|
+
'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
|
|
395
|
+
)
|
|
396
|
+
}
|
|
413
397
|
const deferredOutputs = new DeferredOutputs({
|
|
414
398
|
nodes,
|
|
415
399
|
cache,
|
|
@@ -419,29 +403,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
419
403
|
localWrite: policy.localWrite,
|
|
420
404
|
})
|
|
421
405
|
|
|
422
|
-
// `--verify` observes the miss-then-save path and then RESTORES attempt
|
|
423
|
-
// 1 from the artifact that save wrote, so the tree ends byte-identical
|
|
424
|
-
// to what was cached regardless of the verdict. That restore reads the
|
|
425
|
-
// LOCAL artifact file — `restoreOutputs` is a local extraction on every
|
|
426
|
-
// layer, by design — so without the local WRITE axis there is nothing to
|
|
427
|
-
// restore from: verify cleaned a successful build's declared outputs and
|
|
428
|
-
// then failed the run on the missing artifact. A remote fallback would
|
|
429
|
-
// not fix that, only narrow it to "whenever the remote is reachable at
|
|
430
|
-
// that instant", turning a guarantee into a coin flip with data loss on
|
|
431
|
-
// the losing side. So the honest gate is the local write axis, and
|
|
432
|
-
// `--no-cache` / `--cache=local:,…` / `--cache=local:r,…` are refused
|
|
433
|
-
// loudly — silently verifying nothing is the one failure mode
|
|
434
|
-
// verification must never have (same platform-honesty rule as the
|
|
435
|
-
// sandbox-unavailable error). `--force --verify` re-verifies a warm graph.
|
|
436
|
-
if (options.verify !== undefined && !policy.localWrite) {
|
|
437
|
-
throw new UserError(
|
|
438
|
-
'--verify needs the LOCAL cache write axis: it re-runs the task, then restores ' +
|
|
439
|
-
'attempt 1 from the local artifact so the outputs on disk are the ones that were ' +
|
|
440
|
-
'cached. Enable local writes (e.g. --cache=local:w,remote:rw), drop --no-cache, ' +
|
|
441
|
-
'or use --force --verify to re-execute and verify a warm graph',
|
|
442
|
-
)
|
|
443
|
-
}
|
|
444
|
-
|
|
445
406
|
// Per-run context for the Tier-3 `invocations` header row. Captured
|
|
446
407
|
// ONCE (git is ONE spawn for commit+branch, behind try/catch; never
|
|
447
408
|
// fails a run). `dirty` reuses the `git status --porcelain` the
|
|
@@ -501,26 +462,15 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
501
462
|
)
|
|
502
463
|
}
|
|
503
464
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (anySandboxed) {
|
|
514
|
-
const avail = await probeSandbox()
|
|
515
|
-
if (!avail.available) {
|
|
516
|
-
throw new UserError(
|
|
517
|
-
verifyInputs
|
|
518
|
-
? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
|
|
519
|
-
: `sandbox not available: ${avail.reason}`,
|
|
520
|
-
)
|
|
521
|
-
}
|
|
522
|
-
await initSandbox()
|
|
523
|
-
}
|
|
465
|
+
const sandboxArmer = prepareSandbox(nodes.values())
|
|
466
|
+
const outputDirSnapshots: OutputDirSnapshot[] = []
|
|
467
|
+
// Saves run off the execution slot, twice the cap at once (memory:
|
|
468
|
+
// each pack holds an artifact's bytes); a failed save is a miss next
|
|
469
|
+
// time, said once — the task's work ran.
|
|
470
|
+
const saveLane = createSaveLane(2 * concurrency, (err) =>
|
|
471
|
+
log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
472
|
+
)
|
|
473
|
+
const deferredSaves = new Map<string, Promise<void>>()
|
|
524
474
|
|
|
525
475
|
// Focused flow: a requested GROUP has no output of its own, so
|
|
526
476
|
// surface the same-project, non-group tasks it chains (one level)
|
|
@@ -544,16 +494,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
544
494
|
if (node.requested || node.surfaced === true) requestedCount++
|
|
545
495
|
}
|
|
546
496
|
}
|
|
547
|
-
//
|
|
548
|
-
// into
|
|
549
|
-
//
|
|
550
|
-
|
|
551
|
-
// os.totalmem() unless `--memory` overrides it (pass `--memory` in
|
|
552
|
-
// cgroup-limited containers — totalmem() reports the HOST's RAM).
|
|
553
|
-
// Nothing declared → empty map → fields omitted from the scheduler
|
|
554
|
-
// AND the footer → byte-identical legacy path.
|
|
555
|
-
const memBudget = options.memory ?? os.totalmem()
|
|
556
|
-
const resourceCosts = resolveResourceCosts(nodes)
|
|
497
|
+
// Admission over the worker count is a plugin's (`admit`): built once
|
|
498
|
+
// into the predicate the scheduler asks at every local dispatch. No
|
|
499
|
+
// plugin answers → undefined → the count-only path, byte for byte.
|
|
500
|
+
const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
|
|
557
501
|
|
|
558
502
|
// Run context for the footer. The top-of-run header is gone — the
|
|
559
503
|
// banner now lives in the summary, where the eye lands at the end.
|
|
@@ -563,7 +507,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
563
507
|
remoteCacheEnabled,
|
|
564
508
|
concurrency,
|
|
565
509
|
workspaceProjectCount,
|
|
566
|
-
...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
|
|
567
510
|
}
|
|
568
511
|
|
|
569
512
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
@@ -607,7 +550,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
607
550
|
// each probe, so there is no second cache.get. Gated by
|
|
608
551
|
// shouldShortCircuit (local reads on, no remote layer); when off, both
|
|
609
552
|
// maps are empty and the run is byte-identical.
|
|
610
|
-
let shortCircuit: ShortCircuit =
|
|
553
|
+
let shortCircuit: ShortCircuit = emptyShortCircuit()
|
|
611
554
|
if (shouldShortCircuit(nodes, policy, cache)) {
|
|
612
555
|
shortCircuit = await startLocalShortCircuit({
|
|
613
556
|
nodes,
|
|
@@ -622,8 +565,14 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
622
565
|
})
|
|
623
566
|
}
|
|
624
567
|
|
|
568
|
+
// Whether this task runs behind a failure (`continueMode: 'always'`
|
|
569
|
+
// only) — its save is withheld and the taint propagates; see
|
|
570
|
+
// admission.ts.
|
|
571
|
+
const isTainted = taintTracker(options.continueMode === 'always')
|
|
572
|
+
|
|
625
573
|
const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
|
|
626
574
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
575
|
+
const taint = isTainted(node, upstream)
|
|
627
576
|
return {
|
|
628
577
|
node,
|
|
629
578
|
upstream,
|
|
@@ -634,7 +583,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
634
583
|
forwardArgs: options.forwardArgs,
|
|
635
584
|
...(options.retries !== undefined ? { retries: options.retries } : {}),
|
|
636
585
|
...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
|
|
637
|
-
...(options.verify !== undefined ? { verify: options.verify } : {}),
|
|
638
586
|
log,
|
|
639
587
|
executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
|
|
640
588
|
...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
|
|
@@ -648,85 +596,39 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
648
596
|
gitFilesCache,
|
|
649
597
|
hashCache,
|
|
650
598
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
599
|
+
...(taint ? { taintedUpstream: true } : {}),
|
|
600
|
+
...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
|
|
601
|
+
outputDirSnapshots,
|
|
602
|
+
deferSave: saveLane.defer,
|
|
603
|
+
deferredSaves,
|
|
651
604
|
}
|
|
652
605
|
}
|
|
653
606
|
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
const inflight = options.inflight
|
|
660
|
-
const executeWithDedup = async (
|
|
661
|
-
node: TaskNode,
|
|
662
|
-
upstream: TaskOutcome[],
|
|
663
|
-
): Promise<TaskOutcome> => {
|
|
664
|
-
// Dedup only helps when the sibling will WRITE the artifact and
|
|
665
|
-
// this task can READ it back — i.e. both axes effectively on.
|
|
666
|
-
const canRead = policy.localRead || policy.remoteRead
|
|
667
|
-
const canWrite = policy.localWrite || policy.remoteWrite
|
|
668
|
-
const cacheable =
|
|
669
|
-
!isGroupTask(node) &&
|
|
670
|
-
node.config.exec?.persistent === undefined &&
|
|
671
|
-
node.config.cache !== undefined &&
|
|
672
|
-
canRead &&
|
|
673
|
-
canWrite
|
|
674
|
-
// A restore-tier task (confirmed local hit, may run before its
|
|
675
|
-
// deps) needs no dedup — it's a restore, not an executor, and its
|
|
676
|
-
// live `upstream` is incomplete, so the dedup hash recompute would
|
|
677
|
-
// be wrong. Route it straight to executeTask, which reuses the
|
|
678
|
-
// up-front probe.
|
|
679
|
-
const restorable = shortCircuit.restoreTier.has(node.id)
|
|
680
|
-
if (inflight === undefined || !cacheable || restorable) {
|
|
681
|
-
return executeTask(buildExecuteArgs(node, upstream))
|
|
682
|
-
}
|
|
683
|
-
const hash = await computeTaskHash({
|
|
684
|
-
node,
|
|
685
|
-
upstream,
|
|
607
|
+
const executeWithDedup = admitTasks({
|
|
608
|
+
inflight: options.inflight,
|
|
609
|
+
policy,
|
|
610
|
+
shortCircuit,
|
|
611
|
+
hashArgs: {
|
|
686
612
|
workspaceRoot,
|
|
687
613
|
workspaceFingerprint,
|
|
688
614
|
cache,
|
|
689
615
|
forwardArgs: options.forwardArgs,
|
|
690
|
-
|
|
616
|
+
nestedDirsByProject,
|
|
691
617
|
gitFilesCache,
|
|
692
618
|
hashCache,
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
// Join a sibling already computing this exact task: wait, then
|
|
697
|
-
// executeTask cache-hits on the artifact it just saved. The
|
|
698
|
-
// up-front probe (preProbed) predates the sibling's save — its
|
|
699
|
-
// "confirmed stable miss" would skip the lazy cache.get and
|
|
700
|
-
// re-execute, defeating the dedup — so the join path drops it
|
|
701
|
-
// and lets executeTask probe fresh.
|
|
702
|
-
await existing.catch(() => {})
|
|
703
|
-
return executeTask(buildExecuteArgs(node, upstream, false))
|
|
704
|
-
}
|
|
705
|
-
// Become the executor: register a barrier siblings await. get→set has
|
|
706
|
-
// no await between, so registration is atomic — at most one executor
|
|
707
|
-
// per hash. Released on every exit (success / failure / throw).
|
|
708
|
-
let release!: () => void
|
|
709
|
-
inflight.set(
|
|
710
|
-
hash,
|
|
711
|
-
new Promise<void>((resolve) => {
|
|
712
|
-
release = resolve
|
|
713
|
-
}),
|
|
714
|
-
)
|
|
715
|
-
try {
|
|
716
|
-
return await executeTask(buildExecuteArgs(node, upstream))
|
|
717
|
-
} finally {
|
|
718
|
-
inflight.delete(hash)
|
|
719
|
-
release()
|
|
720
|
-
}
|
|
721
|
-
}
|
|
619
|
+
},
|
|
620
|
+
buildExecuteArgs,
|
|
621
|
+
})
|
|
722
622
|
|
|
723
623
|
mark('classify + probe')
|
|
724
624
|
const outcomes = await runGraph({
|
|
725
625
|
nodes,
|
|
726
626
|
concurrency,
|
|
627
|
+
settledOf: (o) => deferredSaves.get(o.node.id),
|
|
727
628
|
...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
|
|
728
|
-
...(
|
|
629
|
+
...(admit !== undefined ? { admit } : {}),
|
|
729
630
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
631
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
730
632
|
onStart: (node) => {
|
|
731
633
|
log.taskStart?.(node)
|
|
732
634
|
},
|
|
@@ -744,72 +646,22 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
744
646
|
restoreTier: shortCircuit.restoreTier,
|
|
745
647
|
})
|
|
746
648
|
|
|
747
|
-
//
|
|
748
|
-
// the
|
|
749
|
-
//
|
|
750
|
-
//
|
|
751
|
-
//
|
|
752
|
-
//
|
|
753
|
-
// `options.log === undefined` means the default logger (a `vx run`
|
|
754
|
-
// invocation), and `handleSignals` excludes watch mode (own signal
|
|
755
|
-
// loop) and embedders that manage lifecycle themselves — both expect
|
|
756
|
-
// run() to return, not block on a server.
|
|
649
|
+
// Which persistent children outlive the graph, and the bounded SIGTERM
|
|
650
|
+
// of the rest, before the summary prints. Scoped to the real CLI
|
|
651
|
+
// foreground: `options.log === undefined` means the default logger (a
|
|
652
|
+
// `vx run` invocation), and `handleSignals` excludes watch mode (own
|
|
653
|
+
// signal loop) and embedders that manage lifecycle themselves — both
|
|
654
|
+
// expect run() to return, not block on a server.
|
|
757
655
|
const foreground = options.log === undefined && (options.handleSignals ?? true)
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
if (foreground) {
|
|
761
|
-
for (const [id, child] of persistentRegistry) {
|
|
762
|
-
const n = nodes.get(id)
|
|
763
|
-
if (n !== undefined && (n.requested || n.surfaced === true)) {
|
|
764
|
-
keepAliveNodes.push(n)
|
|
765
|
-
keepAlive.push(child)
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
const keepAliveSet = new Set(keepAlive)
|
|
770
|
-
|
|
771
|
-
// Shut down the dependency-only persistent tasks before reporting the final
|
|
772
|
-
// summary. SIGTERM gives well-behaved servers (vite, next, esbuild --watch)
|
|
773
|
-
// a moment to clean up. Bun's Subprocess.kill is idempotent on an
|
|
774
|
-
// already-exited child. Bound the wait: a persistent dep that traps or
|
|
775
|
-
// ignores SIGTERM (a wedged mock server) would otherwise hang the run at
|
|
776
|
-
// NORMAL completion forever — after a grace, SIGKILL the stragglers and move
|
|
777
|
-
// on. Well-behaved servers exit in well under the grace, so the happy path
|
|
778
|
-
// pays nothing; the timer is cleared + unref'd so a fast shutdown never
|
|
779
|
-
// delays CLI exit.
|
|
780
|
-
const dyingChildren = [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c))
|
|
781
|
-
for (const child of dyingChildren) child.kill('SIGTERM')
|
|
782
|
-
const allExited = Promise.allSettled(dyingChildren.map((c) => c.exited))
|
|
783
|
-
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
784
|
-
const winner = await Promise.race([
|
|
785
|
-
allExited.then(() => 'exited' as const),
|
|
786
|
-
new Promise<'grace'>((resolve) => {
|
|
787
|
-
graceTimer = setTimeout(() => resolve('grace'), PERSISTENT_SHUTDOWN_GRACE_MS)
|
|
788
|
-
graceTimer.unref?.()
|
|
789
|
-
}),
|
|
790
|
-
])
|
|
791
|
-
if (graceTimer !== undefined) clearTimeout(graceTimer)
|
|
792
|
-
if (winner === 'grace') {
|
|
793
|
-
for (const child of dyingChildren) child.kill('SIGKILL')
|
|
794
|
-
await allExited
|
|
795
|
-
}
|
|
656
|
+
const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground)
|
|
657
|
+
await shutdownPersistent(persistentRegistry, keepAlive.children)
|
|
796
658
|
|
|
797
659
|
mark('run graph')
|
|
798
660
|
// Clear the status line for good before the summary prints.
|
|
799
661
|
log.runEnd?.()
|
|
800
662
|
|
|
801
663
|
const list = [...outcomes.values()]
|
|
802
|
-
const ok =
|
|
803
|
-
list.every((o) => isPassStatus(o.status)) &&
|
|
804
|
-
// `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
|
|
805
|
-
// re-run that failed, or a read of undeclared inputs) turns the run red so
|
|
806
|
-
// CI catches it.
|
|
807
|
-
!list.some(
|
|
808
|
-
(o) =>
|
|
809
|
-
o.verify?.kind === 'nondeterministic' ||
|
|
810
|
-
o.verify?.kind === 'rerun-failed' ||
|
|
811
|
-
o.verify?.kind === 'undeclared-inputs',
|
|
812
|
-
)
|
|
664
|
+
const ok = list.every((o) => isPassStatus(o.status))
|
|
813
665
|
|
|
814
666
|
// The summary + artifact writers + recordRun pass all exclude group
|
|
815
667
|
// tasks via the shared tallyOutcomes helper. We pass the full
|
|
@@ -818,13 +670,18 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
818
670
|
const totalMs = Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000
|
|
819
671
|
// Foreground dev mode: between the task frame and the footer, list
|
|
820
672
|
// the persistent tasks still running (see the keep-alive block below).
|
|
821
|
-
if (
|
|
822
|
-
for (const line of formatPersistentList(
|
|
673
|
+
if (keepAlive.nodes.length > 0) {
|
|
674
|
+
for (const line of formatPersistentList(keepAlive.nodes, colors)) log.status(line)
|
|
823
675
|
}
|
|
824
676
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
825
677
|
// A task killed by a shutdown signal is in no bucket above, yet it makes
|
|
826
678
|
// `ok` false — name it, or the red exit is undiagnosable.
|
|
827
679
|
for (const line of formatAbortedSection(list)) log.status(line)
|
|
680
|
+
// Judged against the history BEFORE this run's rows land, so the query
|
|
681
|
+
// is one scan over the executed tasks' keys and nothing at all on a run
|
|
682
|
+
// that executed none (every hit, every skip).
|
|
683
|
+
const flaky = detectFlaky(prepared.localCache.dbHandle(), flakyCandidates(list))
|
|
684
|
+
for (const line of formatFlakySection(flaky)) log.status(line)
|
|
828
685
|
// Outputs that never came home are not an error, but a silent `dist/`
|
|
829
686
|
// that is empty-or-stale would be: name every task whose bytes are
|
|
830
687
|
// still remote.
|
|
@@ -835,24 +692,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
835
692
|
` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
|
|
836
693
|
)
|
|
837
694
|
}
|
|
838
|
-
if (options.verify !== undefined) {
|
|
839
|
-
for (const line of formatVerifySection(list)) log.status(line)
|
|
840
|
-
// A fingerprint-only run attaches no verdicts, so the verdict-driven
|
|
841
|
-
// section above prints nothing — report what actually happened.
|
|
842
|
-
if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
|
|
843
|
-
const n = list.filter((o) => o.outputFp !== undefined).length
|
|
844
|
-
log.status('')
|
|
845
|
-
log.status(
|
|
846
|
-
` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
|
|
847
|
-
)
|
|
848
|
-
// Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
|
|
849
|
-
// A per-platform matrix wired without `--force` produces nothing
|
|
850
|
-
// forever, so name the cause instead of a bare 0.
|
|
851
|
-
if (n === 0) {
|
|
852
|
-
log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
695
|
|
|
857
696
|
// Optional artifacts. Errors are surfaced to the user but don't
|
|
858
697
|
// change the run's exit code — the run already happened.
|
|
@@ -868,6 +707,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
868
707
|
totalMs,
|
|
869
708
|
ok,
|
|
870
709
|
outcomes: list,
|
|
710
|
+
flaky,
|
|
871
711
|
})
|
|
872
712
|
log.status(`vx: summary written to ${wrote}`)
|
|
873
713
|
} catch (err) {
|
|
@@ -890,119 +730,32 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
890
730
|
}
|
|
891
731
|
|
|
892
732
|
// Record each task to the run history in a single SQLite transaction
|
|
893
|
-
// (one fsync instead of N)
|
|
894
|
-
//
|
|
895
|
-
//
|
|
896
|
-
//
|
|
897
|
-
//
|
|
898
|
-
//
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
const toRecord: RunRecord[] = []
|
|
902
|
-
// Per-task telemetry mirrors `toRecord` 1:1 — built only when a
|
|
903
|
-
// telemetry sink is active, so a no-telemetry run allocates nothing.
|
|
904
|
-
const summaryTasks: TaskTelemetry[] = []
|
|
905
|
-
let failedCount = 0
|
|
906
|
-
let hitLocalCount = 0
|
|
907
|
-
let hitRemoteCount = 0
|
|
908
|
-
for (const o of list) {
|
|
909
|
-
if (isGroupTask(o.node)) continue
|
|
910
|
-
// aborted (killed by a shutdown signal) isn't a real run.
|
|
911
|
-
if (o.status === 'aborted') continue
|
|
912
|
-
if (telemetry !== undefined) {
|
|
913
|
-
const t: TaskTelemetry = {
|
|
914
|
-
taskId: o.node.id,
|
|
915
|
-
project: o.node.projectName,
|
|
916
|
-
task: o.node.taskName,
|
|
917
|
-
status: o.status,
|
|
918
|
-
cacheSource: deriveCacheSource(o.status),
|
|
919
|
-
exitCode: o.exitCode,
|
|
920
|
-
durationMs: o.durationMs,
|
|
921
|
-
}
|
|
922
|
-
if (o.hash !== undefined) t.hash = o.hash
|
|
923
|
-
if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
|
|
924
|
-
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
925
|
-
if (o.where !== undefined) t.where = o.where
|
|
926
|
-
if (o.outputs !== undefined) t.outputs = o.outputs
|
|
927
|
-
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
928
|
-
if (o.verify !== undefined) t.verify = o.verify
|
|
929
|
-
if (o.outputFp !== undefined) t.outputFp = o.outputFp
|
|
930
|
-
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
931
|
-
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
932
|
-
summaryTasks.push(t)
|
|
933
|
-
}
|
|
934
|
-
// Every non-group, non-aborted outcome gets a row — the same set
|
|
935
|
-
// `tallyOutcomes` counts, so `invocations.task_count` equals both the
|
|
936
|
-
// terminal's "N total" and `COUNT(*) FROM runs WHERE run_id = ?`.
|
|
937
|
-
// An outcome with NO hash (a `skipped` task never probed the cache; a
|
|
938
|
-
// `persistent` one is never cacheable) used to be dropped here because
|
|
939
|
-
// `runs.hash` is NOT NULL — which made a failing persistent task record
|
|
940
|
-
// `0 tasks, 0 failures` on a run the terminal called red, and a failed
|
|
941
|
-
// task with a skipped dependent record 1 of 2. `bindRun` stores `''` for
|
|
942
|
-
// those instead; the key-diff readers guard it.
|
|
943
|
-
toRecord.push({
|
|
944
|
-
...(o.hash !== undefined ? { hash: o.hash } : {}),
|
|
945
|
-
project: o.node.projectName,
|
|
946
|
-
task: o.node.taskName,
|
|
947
|
-
status: o.status,
|
|
948
|
-
exitCode: o.exitCode,
|
|
949
|
-
durationMs: o.durationMs,
|
|
950
|
-
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
951
|
-
// Anchor to the REAL per-task wall-clock window: run-start wall time +
|
|
952
|
-
// the task's ns offset (captured for hits and executed tasks alike).
|
|
953
|
-
// The `now - duration` fallback applies to outcomes without an offset —
|
|
954
|
-
// today only `skipped`, which the scheduler finishes synchronously with
|
|
955
|
-
// no span, so it collapses to a zero-width mark at the run's end. Using
|
|
956
|
-
// run-end-minus-duration for EVERYTHING was the old bug that piled every
|
|
957
|
-
// task at the right edge of the timeline.
|
|
958
|
-
startedAt:
|
|
959
|
-
o.wallclockStartNs !== undefined
|
|
960
|
-
? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
|
|
961
|
-
: now - o.durationMs,
|
|
962
|
-
endedAt:
|
|
963
|
-
o.wallclockEndNs !== undefined
|
|
964
|
-
? endedAtMsAtStart + Math.round(Number(o.wallclockEndNs) / 1e6)
|
|
965
|
-
: now,
|
|
966
|
-
runId,
|
|
967
|
-
...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
|
|
968
|
-
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
969
|
-
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
|
|
970
|
-
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
|
|
971
|
-
cacheHit: isCacheHit(o.status),
|
|
972
|
-
...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
|
|
973
|
-
})
|
|
974
|
-
if (o.status === 'failed') failedCount++
|
|
975
|
-
if (o.status === 'cache-hit') hitLocalCount++
|
|
976
|
-
if (o.status === 'cache-hit-remote') hitRemoteCount++
|
|
977
|
-
}
|
|
978
|
-
const invocation: InvocationRecord = {
|
|
733
|
+
// (one fsync instead of N), with the invocation header row alongside,
|
|
734
|
+
// atomically via recordRunBundle. The Tier-3 input-fingerprint rows
|
|
735
|
+
// (entry_inputs) are NOT built here — they're persisted inside each
|
|
736
|
+
// entry's save transaction (miss path only), so a warm all-cache-hit
|
|
737
|
+
// run does no extra recording work. The telemetry mirror is built in
|
|
738
|
+
// the same pass, only when a sink is active.
|
|
739
|
+
const records = assembleRunRecords({
|
|
740
|
+
outcomes: list,
|
|
979
741
|
runId,
|
|
742
|
+
startedAtMs: endedAtMsAtStart,
|
|
743
|
+
endedAtMs,
|
|
744
|
+
totalMs,
|
|
745
|
+
ok,
|
|
980
746
|
command: options.command ?? process.argv.slice(1).join(' '),
|
|
981
|
-
requestedTasks:
|
|
747
|
+
requestedTasks: options.tasks,
|
|
982
748
|
cachePolicy: compactCachePolicy(policy),
|
|
983
749
|
concurrency,
|
|
984
750
|
flow: options.flow ?? null,
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
exitOk: ok,
|
|
994
|
-
commitSha: gitContext.commitSha,
|
|
995
|
-
branch: gitContext.branch,
|
|
996
|
-
dirty: gitContext.dirty,
|
|
997
|
-
ci: ciContext.ci,
|
|
998
|
-
ciProvider: ciContext.provider,
|
|
999
|
-
host: hostContext.host,
|
|
1000
|
-
os: hostContext.os,
|
|
1001
|
-
arch: hostContext.arch,
|
|
1002
|
-
vxVersion: VERSION,
|
|
1003
|
-
tags: JSON.stringify(options.tags ?? {}),
|
|
1004
|
-
}
|
|
1005
|
-
cache.recordRunBundle({ runs: toRecord, invocation })
|
|
751
|
+
forwardArgs: options.forwardArgs,
|
|
752
|
+
tags: options.tags ?? {},
|
|
753
|
+
git: gitContext,
|
|
754
|
+
ci: ciContext,
|
|
755
|
+
host: hostContext,
|
|
756
|
+
withTelemetry: telemetry !== undefined,
|
|
757
|
+
})
|
|
758
|
+
cache.recordRunBundle(records)
|
|
1006
759
|
mark('record history')
|
|
1007
760
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1008
761
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
@@ -1010,7 +763,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1010
763
|
// emitSummary/flush are crash-isolated, so a faulty sink can't fail
|
|
1011
764
|
// the run; flush is the sink's last chance to ship buffered records.
|
|
1012
765
|
if (telemetry !== undefined && runContextRecord !== undefined) {
|
|
1013
|
-
const summary = assembleRunSummary(runContextRecord,
|
|
766
|
+
const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
|
|
1014
767
|
startedAt: endedAtMsAtStart,
|
|
1015
768
|
endedAt: endedAtMs,
|
|
1016
769
|
totalDurationMs: Math.round(totalMs),
|
|
@@ -1039,7 +792,24 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1039
792
|
// write with nothing but a warning. The seam's contract is that a
|
|
1040
793
|
// cache layer stays usable until the run's uploads have settled.
|
|
1041
794
|
await prefetchDone
|
|
795
|
+
// Every deferred save settles first: the upload drain below carries
|
|
796
|
+
// the entries the saves queued, and the snapshot loop after it reads
|
|
797
|
+
// what the saves pushed.
|
|
798
|
+
await saveLane.drain()
|
|
799
|
+
mark('save lane')
|
|
1042
800
|
await cache.drainUploads?.()
|
|
801
|
+
// The miss path's output-directory snapshots, taken now that the
|
|
802
|
+
// directories are old enough for the snapshot's racy window (see
|
|
803
|
+
// miss-save.ts). A few at a time: each is an lstat + readdir per
|
|
804
|
+
// prefix and one index transaction.
|
|
805
|
+
for (let i = 0; i < outputDirSnapshots.length; i += 32) {
|
|
806
|
+
await Promise.all(
|
|
807
|
+
outputDirSnapshots
|
|
808
|
+
.slice(i, i + 32)
|
|
809
|
+
.map((s) => cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes)),
|
|
810
|
+
)
|
|
811
|
+
}
|
|
812
|
+
mark('output dir snapshots')
|
|
1043
813
|
await teardownPlugins(prepared.plugins, (m) => log.status(m))
|
|
1044
814
|
closeCache()
|
|
1045
815
|
mark('close')
|
|
@@ -1048,7 +818,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1048
818
|
// Tear down SRT's network bridge + (on macOS) log monitor. No-op if
|
|
1049
819
|
// no task was sandboxed; otherwise SRT keeps proxy servers alive and
|
|
1050
820
|
// the next vx run would init on top of stale state.
|
|
1051
|
-
if (
|
|
821
|
+
if (sandboxArmer?.armed) {
|
|
1052
822
|
try {
|
|
1053
823
|
await resetSandbox()
|
|
1054
824
|
} catch (err) {
|
|
@@ -1061,12 +831,30 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1061
831
|
// persistent task (dev server / watcher). The run is "done" in every
|
|
1062
832
|
// bookkeeping sense — summary printed, history recorded — but the
|
|
1063
833
|
// server is still up and that's the point. Stay in the foreground
|
|
1064
|
-
// until
|
|
1065
|
-
// dies; our SIGINT handler also exits 130), and a crash
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
834
|
+
// until ONE of them exits: Ctrl-C hits the whole process group (the
|
|
835
|
+
// server dies; our SIGINT handler also exits 130), and a crash ends
|
|
836
|
+
// the session — the others are torn down (SIGTERM, grace, SIGKILL)
|
|
837
|
+
// and a non-zero exit makes the run not ok, so `vx run dev` in a
|
|
838
|
+
// script fails when the server it started fell over. Until
|
|
839
|
+
// 2026-09-10 this waited for EVERY server, so the SIGTERM after it
|
|
840
|
+
// was dead code and a crashed server left the rest running under a
|
|
841
|
+
// run that never returned. One status line names the server that
|
|
842
|
+
// ended the session and its code — the summary above said `success`
|
|
843
|
+
// for it, and an exit 1 with no word about why is a mystery in a log.
|
|
844
|
+
if (keepAlive.children.length > 0) {
|
|
845
|
+
const first = await Promise.race(
|
|
846
|
+
keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
|
|
847
|
+
)
|
|
848
|
+
const node = keepAlive.nodes[first.i]!
|
|
849
|
+
const others = keepAlive.nodes.length - 1
|
|
850
|
+
log.status(
|
|
851
|
+
`vx: ${node.id} exited with code ${first.code}` +
|
|
852
|
+
(others > 0
|
|
853
|
+
? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
|
|
854
|
+
: ''),
|
|
855
|
+
)
|
|
856
|
+
await terminateChildren(() => keepAlive.children)
|
|
857
|
+
return { ok: ok && first.code === 0, outcomes: list }
|
|
1070
858
|
}
|
|
1071
859
|
|
|
1072
860
|
return { ok, outcomes: list }
|
|
@@ -1074,8 +862,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
1074
862
|
// Idempotent; also reached on mid-run throws, so a crashed cycle
|
|
1075
863
|
// can't leave a live status-line ticker behind.
|
|
1076
864
|
log.runEnd?.()
|
|
1077
|
-
|
|
1078
|
-
|
|
865
|
+
signals.remove()
|
|
866
|
+
options.signal?.removeEventListener('abort', onAbort)
|
|
1079
867
|
// Plugins installed at the top of run() get their bus subscriptions
|
|
1080
868
|
// released here. Idempotent; safe even if installPlugins threw.
|
|
1081
869
|
disposePlugins?.()
|
|
@@ -1140,172 +928,23 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1140
928
|
})
|
|
1141
929
|
} finally {
|
|
1142
930
|
prepared.cache.close()
|
|
931
|
+
// The same table a run prints: a dry run is how the prepare stages
|
|
932
|
+
// (discovery, config load, the git enumeration) get profiled on a
|
|
933
|
+
// real repo that has no install and no cache to run against.
|
|
934
|
+
mark('close')
|
|
935
|
+
printTimings()
|
|
1143
936
|
}
|
|
1144
937
|
}
|
|
1145
938
|
|
|
1146
939
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*/
|
|
1151
|
-
function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
|
|
1152
|
-
const pinned = new Set<string>()
|
|
1153
|
-
const memo = new Map<string, boolean>()
|
|
1154
|
-
const visit = (id: string): boolean => {
|
|
1155
|
-
const known = memo.get(id)
|
|
1156
|
-
if (known !== undefined) return known
|
|
1157
|
-
const node = nodes.get(id)
|
|
1158
|
-
if (node === undefined) return false
|
|
1159
|
-
memo.set(id, false) // cycle guard; the graph builder already rejects cycles
|
|
1160
|
-
const result =
|
|
1161
|
-
node.config.exec?.persistent !== undefined ||
|
|
1162
|
-
node.config.exec?.remote === false ||
|
|
1163
|
-
node.deps.some((d) => visit(d))
|
|
1164
|
-
memo.set(id, result)
|
|
1165
|
-
if (result) pinned.add(id)
|
|
1166
|
-
return result
|
|
1167
|
-
}
|
|
1168
|
-
for (const id of nodes.keys()) visit(id)
|
|
1169
|
-
return pinned
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
interface Placements {
|
|
1173
|
-
executors: Map<string, TaskExecutor>
|
|
1174
|
-
/**
|
|
1175
|
-
* `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
|
|
1176
|
-
* this machine: never executed, declared outputs never cleaned or
|
|
1177
|
-
* restored. The task exists to produce a remote input tree; without a
|
|
1178
|
-
* remote pool, dependents use the machine's ambient state exactly as they
|
|
1179
|
-
* did before the field existed.
|
|
1180
|
-
*/
|
|
1181
|
-
remoteOnlyNoop: Set<string>
|
|
1182
|
-
/** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
|
|
1183
|
-
remoteOnly: Set<string>
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function placeTasks(
|
|
1187
|
-
nodes: Map<string, TaskNode>,
|
|
1188
|
-
executors: readonly TaskExecutor[],
|
|
1189
|
-
pinAllLocal = false,
|
|
1190
|
-
): Placements {
|
|
1191
|
-
const pinned = pinnedLocalSet(nodes)
|
|
1192
|
-
const placements: Placements = {
|
|
1193
|
-
executors: new Map(),
|
|
1194
|
-
remoteOnlyNoop: new Set(),
|
|
1195
|
-
remoteOnly: new Set(),
|
|
1196
|
-
}
|
|
1197
|
-
for (const node of nodes.values()) {
|
|
1198
|
-
if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
|
|
1199
|
-
const executor = selectExecutor(executors, {
|
|
1200
|
-
taskId: node.id,
|
|
1201
|
-
projectName: node.projectName,
|
|
1202
|
-
projectDir: node.projectDir,
|
|
1203
|
-
command: node.config.exec!.command,
|
|
1204
|
-
pinnedLocal: pinAllLocal || pinned.has(node.id),
|
|
1205
|
-
cacheable: node.config.cache !== undefined,
|
|
1206
|
-
...(node.config.exec?.resources === undefined
|
|
1207
|
-
? {}
|
|
1208
|
-
: { resources: node.config.exec.resources }),
|
|
1209
|
-
})
|
|
1210
|
-
placements.executors.set(node.id, executor)
|
|
1211
|
-
if (node.config.exec?.remote === 'only') {
|
|
1212
|
-
// A pinned 'only' task (it transitively depends on a persistent one)
|
|
1213
|
-
// lands here too: pinning wins, so it noops rather than shipping.
|
|
1214
|
-
if (executor.remote === true) placements.remoteOnly.add(node.id)
|
|
1215
|
-
else placements.remoteOnlyNoop.add(node.id)
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
return placements
|
|
1219
|
-
}
|
|
1220
|
-
|
|
1221
|
-
/**
|
|
1222
|
-
* `executorOf` for `planRun`, or nothing. Declining plugins, a single
|
|
1223
|
-
* executor, or a resolution error all yield nothing: `--dry` is an
|
|
1224
|
-
* inspection command and must not fail over a label.
|
|
1225
|
-
*/
|
|
1226
|
-
async function planExecutorOf(
|
|
1227
|
-
prepared: Awaited<ReturnType<typeof prepareRun>>,
|
|
1228
|
-
log: Logger,
|
|
1229
|
-
policy: 'all' | 'toplevel' | 'none',
|
|
1230
|
-
): Promise<{
|
|
1231
|
-
executorOf?: (id: string) => string | undefined
|
|
1232
|
-
downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
|
|
1233
|
-
downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
|
|
1234
|
-
}> {
|
|
1235
|
-
let executors: readonly TaskExecutor[]
|
|
1236
|
-
try {
|
|
1237
|
-
executors = await resolveExecutors(prepared.plugins, {
|
|
1238
|
-
workspaceRoot: prepared.workspaceRoot,
|
|
1239
|
-
cacheDir: prepared.cacheDir,
|
|
1240
|
-
warn: (m: string) => log.status(m),
|
|
1241
|
-
concurrency: Math.max(1, navigator.hardwareConcurrency),
|
|
1242
|
-
})
|
|
1243
|
-
} catch {
|
|
1244
|
-
return {}
|
|
1245
|
-
}
|
|
1246
|
-
const placements = placeTasks(prepared.nodes, executors)
|
|
1247
|
-
// Download modes need placement regardless of how many executors there
|
|
1248
|
-
// are (a single REMOTE one still defers); executor LABELS only earn their
|
|
1249
|
-
// column when there is a choice to report.
|
|
1250
|
-
const download =
|
|
1251
|
-
policy === 'all'
|
|
1252
|
-
? undefined
|
|
1253
|
-
: resolveDownloadModes({
|
|
1254
|
-
nodes: prepared.nodes,
|
|
1255
|
-
policy,
|
|
1256
|
-
localPlaced: new Set(
|
|
1257
|
-
[...prepared.nodes.keys()].filter(
|
|
1258
|
-
(id) => placements.executors.get(id)?.remote !== true,
|
|
1259
|
-
),
|
|
1260
|
-
),
|
|
1261
|
-
remoteOnly: placements.remoteOnly,
|
|
1262
|
-
})
|
|
1263
|
-
return {
|
|
1264
|
-
...(executors.length < 2
|
|
1265
|
-
? {}
|
|
1266
|
-
: {
|
|
1267
|
-
executorOf: (id: string) =>
|
|
1268
|
-
placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
|
|
1269
|
-
}),
|
|
1270
|
-
...(download === undefined
|
|
1271
|
-
? {}
|
|
1272
|
-
: {
|
|
1273
|
-
downloadOf: (id: string) => download.modeOf.get(id),
|
|
1274
|
-
downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
|
|
1275
|
-
taskId,
|
|
1276
|
-
reason,
|
|
1277
|
-
})),
|
|
1278
|
-
}),
|
|
1279
|
-
}
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
/**
|
|
1283
|
-
* Stands in for the executor of a task that was never placed — a group task
|
|
1284
|
-
* (runs nothing) or a persistent one (`executePersistentTask` owns it and
|
|
1285
|
-
* never reads this field). It THROWS rather than silently picking some
|
|
1286
|
-
* executor from the list, so a refactor that routes such a task through the
|
|
1287
|
-
* exec path fails loudly instead of shipping a localhost server to a worker.
|
|
940
|
+
* The first-run case, told apart from a typo: no package in the workspace
|
|
941
|
+
* has a `vx.config.*` at all, so no name could have resolved. Reached only
|
|
942
|
+
* on the error path.
|
|
1288
943
|
*/
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
},
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
|
|
1297
|
-
return executors.some((e) => e.capacity !== undefined)
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
function poolOfPlacement(
|
|
1301
|
-
placements: Placements,
|
|
1302
|
-
): (id: string) => { name: string; capacity: number } | undefined {
|
|
1303
|
-
return (id) => {
|
|
1304
|
-
const executor = placements.executors.get(id)
|
|
1305
|
-
return executor?.capacity === undefined
|
|
1306
|
-
? undefined
|
|
1307
|
-
: { name: executor.name, capacity: executor.capacity }
|
|
1308
|
-
}
|
|
944
|
+
function initHint(prepared: { anyProjectConfig: boolean }): string {
|
|
945
|
+
return prepared.anyProjectConfig
|
|
946
|
+
? ''
|
|
947
|
+
: ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
|
|
1309
948
|
}
|
|
1310
949
|
|
|
1311
950
|
/**
|
|
@@ -1319,37 +958,47 @@ function didYouMean(
|
|
|
1319
958
|
unresolved: readonly string[],
|
|
1320
959
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
1321
960
|
): string {
|
|
1322
|
-
const nearest = (name: string, candidates: Iterable<string>): string | undefined => {
|
|
1323
|
-
let best: string | undefined
|
|
1324
|
-
let bestD = 3
|
|
1325
|
-
for (const c of candidates) {
|
|
1326
|
-
const d = editDistance(name, c)
|
|
1327
|
-
if (d < bestD) {
|
|
1328
|
-
bestD = d
|
|
1329
|
-
best = c
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
return best === name ? undefined : best
|
|
1333
|
-
}
|
|
1334
961
|
const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
|
|
1335
962
|
const allTasks = new Set<string>()
|
|
1336
963
|
for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
|
|
1337
|
-
|
|
964
|
+
// A Set: two typos of the same task hint it once, not once per typo.
|
|
965
|
+
const hints = new Set<string>()
|
|
1338
966
|
for (const spec of unresolved) {
|
|
1339
967
|
const at = spec.indexOf('#')
|
|
1340
968
|
if (at < 0) {
|
|
1341
969
|
const t = nearest(spec, allTasks)
|
|
1342
|
-
if (t !== undefined) hints.
|
|
970
|
+
if (t !== undefined) hints.add(t)
|
|
1343
971
|
continue
|
|
1344
972
|
}
|
|
1345
973
|
const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
|
|
1346
974
|
if (!projects.has(proj)) {
|
|
1347
975
|
const p = nearest(proj, projects.keys())
|
|
1348
|
-
if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.
|
|
976
|
+
if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
|
|
1349
977
|
continue
|
|
1350
978
|
}
|
|
1351
979
|
const t = nearest(task, tasksOf(projects.get(proj)))
|
|
1352
|
-
if (t !== undefined) hints.
|
|
980
|
+
if (t !== undefined) hints.add(`${proj}#${t}`)
|
|
981
|
+
}
|
|
982
|
+
return hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** The executed, keyed outcomes of a run — what flakiness is judged on. */
|
|
986
|
+
function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
|
|
987
|
+
const out: FlakyCandidate[] = []
|
|
988
|
+
for (const o of outcomes) {
|
|
989
|
+
if (o.status !== 'success' && o.status !== 'failed') continue
|
|
990
|
+
// "Same inputs, different outcome" is a claim only a task with declared
|
|
991
|
+
// inputs can make: a task with no `cache` block keys on its config alone
|
|
992
|
+
// and runs every time, so one bad network day would read as a flake for
|
|
993
|
+
// thirty days. Groups do no work.
|
|
994
|
+
if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
|
|
995
|
+
out.push({
|
|
996
|
+
project: o.node.projectName,
|
|
997
|
+
task: o.node.taskName,
|
|
998
|
+
hash: o.hash,
|
|
999
|
+
status: o.status,
|
|
1000
|
+
attempts: o.attempts ?? 1,
|
|
1001
|
+
})
|
|
1353
1002
|
}
|
|
1354
|
-
return
|
|
1003
|
+
return out
|
|
1355
1004
|
}
|