@vzn/vx 0.0.650 → 0.0.651
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 +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +202 -25
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +3 -2
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/schema.ts +11 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +213 -203
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +14 -10
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +5 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +47 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +1 -1
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +53 -22
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/events.ts +3 -11
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +272 -173
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +73 -38
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +100 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
DiscoverContext,
|
|
34
34
|
ExecutorContext,
|
|
35
35
|
FingerprintChange,
|
|
36
|
+
FingerprintDigests,
|
|
36
37
|
FingerprintContext,
|
|
37
38
|
GraphHookContext,
|
|
38
39
|
KeyHookContext,
|
|
@@ -44,6 +45,7 @@ import type {
|
|
|
44
45
|
ConfigContext,
|
|
45
46
|
} from './plugin.js'
|
|
46
47
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
48
|
+
import { hashExtra } from './lockfile-claim.js'
|
|
47
49
|
|
|
48
50
|
/** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
|
|
49
51
|
function describeValue(v: unknown): string {
|
|
@@ -392,6 +394,65 @@ export async function claimedAffected(
|
|
|
392
394
|
ctx: FingerprintContext,
|
|
393
395
|
): Promise<Set<string> | undefined> {
|
|
394
396
|
const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
|
|
397
|
+
return projectNames(plugin, answer, ctx)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* `FingerprintClaim.digests`' answer for `file`, each extra file it names
|
|
402
|
+
* with its hash now (`hashExtra`, as a run checks it); `undefined` when the
|
|
403
|
+
* plugin has none. A digest of a name no project carries, or a value that
|
|
404
|
+
* is not a string, is refused by name: `vx lock` would commit it.
|
|
405
|
+
*/
|
|
406
|
+
export async function claimedDigests(
|
|
407
|
+
plugin: VxPlugin,
|
|
408
|
+
file: string,
|
|
409
|
+
ctx: FingerprintContext,
|
|
410
|
+
): Promise<
|
|
411
|
+
| {
|
|
412
|
+
hash: string
|
|
413
|
+
projects: FingerprintDigests['projects']
|
|
414
|
+
extras?: Readonly<Record<string, string>>
|
|
415
|
+
}
|
|
416
|
+
| undefined
|
|
417
|
+
> {
|
|
418
|
+
const digests = plugin.fingerprint?.digests
|
|
419
|
+
if (digests === undefined) return undefined
|
|
420
|
+
const answer: unknown = await safe(plugin, 'fingerprint', () =>
|
|
421
|
+
digests.call(plugin.fingerprint, file, ctx),
|
|
422
|
+
)
|
|
423
|
+
if (answer === undefined) return undefined
|
|
424
|
+
const { hash, projects, extras } = (answer ?? {}) as Partial<FingerprintDigests>
|
|
425
|
+
if (
|
|
426
|
+
typeof hash !== 'string' ||
|
|
427
|
+
typeof projects !== 'object' ||
|
|
428
|
+
projects === null ||
|
|
429
|
+
Object.values(projects).some((d) => typeof d !== 'string') ||
|
|
430
|
+
(extras !== undefined &&
|
|
431
|
+
(!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
|
|
432
|
+
) {
|
|
433
|
+
throw new UserError(
|
|
434
|
+
`plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
const known = new Set(ctx.projects.map((p) => p.name))
|
|
438
|
+
const stray = Object.keys(projects).find((n) => !known.has(n))
|
|
439
|
+
if (stray !== undefined) {
|
|
440
|
+
throw new UserError(
|
|
441
|
+
`plugin '${plugin.name}' failed in fingerprint: digest of ${JSON.stringify(stray)}, which is not a project in this workspace`,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
if (extras === undefined || extras.length === 0) return { hash, projects }
|
|
445
|
+
const hashed = await Promise.all(
|
|
446
|
+
extras.map(async (rel) => [rel, await hashExtra(ctx.workspaceRoot, rel)] as const),
|
|
447
|
+
)
|
|
448
|
+
return { hash, projects, extras: Object.fromEntries(hashed) }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function projectNames(
|
|
452
|
+
plugin: VxPlugin,
|
|
453
|
+
answer: unknown,
|
|
454
|
+
ctx: FingerprintContext,
|
|
455
|
+
): Promise<Set<string> | undefined> {
|
|
395
456
|
if (answer === undefined) return undefined
|
|
396
457
|
// A plain object is no list: `for…of` threw "is not iterable", naming
|
|
397
458
|
// neither the plugin nor the hook.
|
|
@@ -27,9 +27,9 @@ import {
|
|
|
27
27
|
type PluginSecrets,
|
|
28
28
|
SecretBroker,
|
|
29
29
|
thrownMessage,
|
|
30
|
-
ui,
|
|
31
30
|
UserError,
|
|
32
31
|
} from '../util/index.js'
|
|
32
|
+
import * as ui from '../ui/index.js'
|
|
33
33
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
34
34
|
import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
|
|
35
35
|
import type { CiContext } from './run-context.js'
|
|
@@ -460,7 +460,29 @@ export interface GraphHookContext extends BaseContext {
|
|
|
460
460
|
readonly requested: readonly string[]
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
-
export interface KeyHookContext extends BaseContext {
|
|
463
|
+
export interface KeyHookContext extends BaseContext {
|
|
464
|
+
/**
|
|
465
|
+
* What `vx lock` recorded of the claimed `file` in
|
|
466
|
+
* `.vx/dependencies.lock.json` (`FingerprintClaim.digests`): a run keys
|
|
467
|
+
* from the record and never parses the file. Undefined when the file is
|
|
468
|
+
* absent and unrecorded. Refuses, naming `vx lock`, when the lock is
|
|
469
|
+
* absent or records no such present file; a recorded file whose bytes
|
|
470
|
+
* moved since refused the run before the key stage.
|
|
471
|
+
*/
|
|
472
|
+
locked(file: string): Promise<LockedFile | undefined>
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** `KeyHookContext.locked`'s answer. */
|
|
476
|
+
export interface LockedFile {
|
|
477
|
+
/**
|
|
478
|
+
* The other files the digests read → each one's hash then (`hashExtra`).
|
|
479
|
+
* Not checked by core: a `link:` directory is edited with no install,
|
|
480
|
+
* so a claimant whose extra file moved digests anew.
|
|
481
|
+
*/
|
|
482
|
+
readonly extras: Readonly<Record<string, string>>
|
|
483
|
+
/** The project at `projectDir`'s digest; undefined when none. Refuses for a project the lock does not list. */
|
|
484
|
+
digest(projectDir: string): string | undefined
|
|
485
|
+
}
|
|
464
486
|
|
|
465
487
|
/** A plugin's claim on workspace-root files — see `VxPlugin.fingerprint`. */
|
|
466
488
|
export interface FingerprintClaim {
|
|
@@ -491,6 +513,29 @@ export interface FingerprintClaim {
|
|
|
491
513
|
bytes: Uint8Array,
|
|
492
514
|
ctx: FingerprintContext,
|
|
493
515
|
): readonly string[] | Promise<readonly string[]>
|
|
516
|
+
/**
|
|
517
|
+
* Each project's digest of the claimed `file` as it is now, by project
|
|
518
|
+
* name, with `hash`, the xxh3 of the bytes they came from (`xxh3hex`),
|
|
519
|
+
* and `extras`, the other files they read. `vx lock` records them in
|
|
520
|
+
* `.vx/dependencies.lock.json` with each extra file's hash; a run keys
|
|
521
|
+
* from that record (`KeyHookContext.locked`), and `--affected`
|
|
522
|
+
* reads the base's copy there instead of asking `affected` to parse the
|
|
523
|
+
* base side, when its `hash` is the base file's xxh3. `undefined`:
|
|
524
|
+
* nothing to record.
|
|
525
|
+
*/
|
|
526
|
+
digests?(
|
|
527
|
+
file: string,
|
|
528
|
+
ctx: FingerprintContext,
|
|
529
|
+
): FingerprintDigests | undefined | Promise<FingerprintDigests | undefined>
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** `FingerprintClaim.digests`' answer. */
|
|
533
|
+
export interface FingerprintDigests {
|
|
534
|
+
readonly hash: string
|
|
535
|
+
/** Project name → its digest of the file. */
|
|
536
|
+
readonly projects: Readonly<Record<string, string>>
|
|
537
|
+
/** Root-relative files besides `file` whose bytes the digests read (a `bun.lock` patch). */
|
|
538
|
+
readonly extras?: readonly string[]
|
|
494
539
|
}
|
|
495
540
|
|
|
496
541
|
export interface FingerprintChange {
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
gitPathspecs,
|
|
40
40
|
MAX_SCOPED_PATHSPECS,
|
|
41
41
|
startGitEnumeration,
|
|
42
|
+
checkBlobSizes,
|
|
42
43
|
lazyGitEnumeration,
|
|
43
44
|
measureFsClockLag,
|
|
44
45
|
DEFAULT_STORE,
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
lockDigest,
|
|
53
54
|
PROJECT_CONFIG_FILENAMES,
|
|
54
55
|
readClaimedFiles,
|
|
56
|
+
readDependenciesLock,
|
|
55
57
|
resolveCacheDir,
|
|
56
58
|
resolveStoreRoot,
|
|
57
59
|
type ProjectEntry,
|
|
@@ -76,6 +78,7 @@ import {
|
|
|
76
78
|
teardownPlugins,
|
|
77
79
|
type ScheduleRun,
|
|
78
80
|
} from './plugin-host.js'
|
|
81
|
+
import { lockedFiles } from './locked-digests.js'
|
|
79
82
|
import {
|
|
80
83
|
discoverProjects,
|
|
81
84
|
gitOfDiscovery,
|
|
@@ -382,6 +385,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
382
385
|
const fingerprintsAt = Date.now()
|
|
383
386
|
const claimed = [...fingerprintClaims(plugins).keys()]
|
|
384
387
|
const claimedBytes = await readClaimedFiles(workspaceRoot, claimed, reads)
|
|
388
|
+
// What `vx lock` recorded of them: a run keys from it (locked-digests.ts).
|
|
389
|
+
const dependenciesLock = claimed.length > 0 ? readDependenciesLock(workspaceRoot, reads) : null
|
|
390
|
+
dependenciesLock?.catch(() => {})
|
|
391
|
+
// A whole-tree run's blob-size check starts with the cache it reads its
|
|
392
|
+
// verdict from, beside the config load and graph build: after them, a cold
|
|
393
|
+
// 100-package run waited 40-50 ms on it before its first task (2026-11-03).
|
|
394
|
+
if (git.started !== undefined) {
|
|
395
|
+
localCache.holdBlobSizes()
|
|
396
|
+
git.started.then((e) => checkBlobSizes(e, localCache)).catch(() => {})
|
|
397
|
+
}
|
|
385
398
|
mark('open cache')
|
|
386
399
|
|
|
387
400
|
// Configs load FROM .vx/projects.lock.json with no evaluation and no check of
|
|
@@ -776,6 +789,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
776
789
|
// 1,000 projects when it ran before the spawn.
|
|
777
790
|
const settleIgnored = askIgnoredOutputs(gatedOutputs(nodes, workspaceRoot), workspaceRoot)
|
|
778
791
|
const enumeration = await enumerating
|
|
792
|
+
localCache.holdBlobSizes()
|
|
779
793
|
await applyGitEnumeration(
|
|
780
794
|
enumeration,
|
|
781
795
|
workspaceRoot,
|
|
@@ -800,7 +814,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
800
814
|
}
|
|
801
815
|
}
|
|
802
816
|
if (hasHook(plugins, 'key')) {
|
|
803
|
-
|
|
817
|
+
const locked = lockedFiles(workspaceRoot, claimed, claimedBytes, await dependenciesLock)
|
|
818
|
+
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log), locked })
|
|
804
819
|
}
|
|
805
820
|
refuseFiltersThroughGroups(nodes)
|
|
806
821
|
const exclude = options.excludeDependencies
|
|
@@ -370,8 +370,11 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
370
370
|
}
|
|
371
371
|
continue
|
|
372
372
|
}
|
|
373
|
-
const
|
|
373
|
+
const frozen: ProjectConfig =
|
|
374
374
|
meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
|
|
375
|
+
// The lock shares a task two projects declare alike: an edit in place
|
|
376
|
+
// works on this project's own copy.
|
|
377
|
+
const config = projectStage ? structuredClone(frozen) : frozen
|
|
375
378
|
if (projectStage) {
|
|
376
379
|
// The `project` stage edits the validated object in place; core
|
|
377
380
|
// re-validates after EACH plugin so a plugin can only produce what
|
|
@@ -30,7 +30,7 @@ import type { RunContextRecord } from './telemetry.js'
|
|
|
30
30
|
export interface GitContext {
|
|
31
31
|
/** `git rev-parse HEAD`, or null outside a repo / on failure. */
|
|
32
32
|
commitSha: string | null
|
|
33
|
-
/** `
|
|
33
|
+
/** The branch HEAD names (`refs/heads/` stripped), with or without a commit; null when detached. */
|
|
34
34
|
branch: string | null
|
|
35
35
|
/** True if `git status --porcelain` was non-empty; null on failure. */
|
|
36
36
|
dirty: boolean | null
|
|
@@ -61,9 +61,11 @@ export interface HostContext {
|
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
63
|
* One short `git` spawn per run (not per task), behind try/catch.
|
|
64
|
-
* `git rev-parse HEAD --
|
|
65
|
-
* and the
|
|
66
|
-
* two separate `rev-parse` calls.
|
|
64
|
+
* `git rev-parse HEAD --symbolic-full-name HEAD` returns the commit on
|
|
65
|
+
* line 1 and the ref on line 2 in a SINGLE invocation — half the spawns
|
|
66
|
+
* of two separate `rev-parse` calls. The full name, not `--abbrev-ref`:
|
|
67
|
+
* a tag named like the branch made that answer `heads/main`
|
|
68
|
+
* (vercel/turborepo#13882). `dirty` is NOT probed here: the run's
|
|
67
69
|
* `GitFilesCache` populate already ran `git status --porcelain` for
|
|
68
70
|
* input enumeration, so the orchestrator passes that aggregate in
|
|
69
71
|
* (`dirty`) rather than paying for a second status spawn. Pass
|
|
@@ -95,7 +97,7 @@ export function captureGitContext(
|
|
|
95
97
|
workspaceRoot,
|
|
96
98
|
'rev-parse',
|
|
97
99
|
'HEAD',
|
|
98
|
-
'--
|
|
100
|
+
'--symbolic-full-name',
|
|
99
101
|
'HEAD',
|
|
100
102
|
],
|
|
101
103
|
env: gitEnv(),
|
|
@@ -107,15 +109,19 @@ export function captureGitContext(
|
|
|
107
109
|
const sha = lines[0]?.trim()
|
|
108
110
|
const br = lines[1]?.trim()
|
|
109
111
|
if (sha) commitSha = sha
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
+
// A detached HEAD answers the literal string `HEAD`, never `refs/heads/…`.
|
|
113
|
+
// Recording that verbatim INVENTS a branch: every detached
|
|
112
114
|
// run shares a pseudo-branch "HEAD", which is how `actions/checkout`
|
|
113
115
|
// leaves a pull_request build. The trust boundary (`branch ===
|
|
114
116
|
// defaultBranch`) means it is at least never mistaken for trunk, but
|
|
115
117
|
// every PR collapses into one scope — so one PR's timings feed another's
|
|
116
118
|
// baseline, cross-branch regression detection counts them as a single
|
|
117
119
|
// branch, and a consumer grouping runs by branch reads "HEAD".
|
|
118
|
-
if (br
|
|
120
|
+
if (br?.startsWith('refs/heads/')) branch = br.slice('refs/heads/'.length)
|
|
121
|
+
} else {
|
|
122
|
+
// No commit yet: `rev-parse HEAD` fails, yet HEAD already names the
|
|
123
|
+
// branch the first commit lands on (`git branch --show-current`).
|
|
124
|
+
branch = unbornBranch(workspaceRoot)
|
|
119
125
|
}
|
|
120
126
|
} catch {
|
|
121
127
|
// git unavailable / non-repo: both fields stay null.
|
|
@@ -129,6 +135,18 @@ export function captureGitContext(
|
|
|
129
135
|
return { commitSha, branch, dirty }
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/** The branch HEAD names, asked of git only after `rev-parse HEAD` failed. */
|
|
139
|
+
function unbornBranch(workspaceRoot: string): string | null {
|
|
140
|
+
const proc = Bun.spawnSync({
|
|
141
|
+
cmd: [executablePath('git'), '-C', workspaceRoot, 'symbolic-ref', '--quiet', 'HEAD'],
|
|
142
|
+
env: gitEnv(),
|
|
143
|
+
stdout: 'pipe',
|
|
144
|
+
stderr: 'ignore',
|
|
145
|
+
})
|
|
146
|
+
const ref = proc.exitCode === 0 ? new TextDecoder().decode(proc.stdout).trim() : ''
|
|
147
|
+
return ref.startsWith('refs/heads/') ? ref.slice('refs/heads/'.length) : null
|
|
148
|
+
}
|
|
149
|
+
|
|
132
150
|
/**
|
|
133
151
|
* `HEAD`'s commit and branch from the `.git` files, or null when anything
|
|
134
152
|
* about the layout is unfamiliar (the caller then spawns git). Handles a
|
|
@@ -138,7 +156,7 @@ export function captureGitContext(
|
|
|
138
156
|
*/
|
|
139
157
|
function readHeadDirect(
|
|
140
158
|
workspaceRoot: string,
|
|
141
|
-
): { commitSha: string; branch: string | null } | null {
|
|
159
|
+
): { commitSha: string | null; branch: string | null } | null {
|
|
142
160
|
try {
|
|
143
161
|
const dirs = refDirsOf(workspaceRoot)
|
|
144
162
|
if (dirs === null) return null
|
|
@@ -166,7 +184,8 @@ function readHeadDirect(
|
|
|
166
184
|
}
|
|
167
185
|
}
|
|
168
186
|
}
|
|
169
|
-
|
|
187
|
+
// Neither a loose nor a packed ref: the branch has no commit yet.
|
|
188
|
+
return { commitSha: null, branch }
|
|
170
189
|
} catch {
|
|
171
190
|
return null
|
|
172
191
|
}
|
|
@@ -8,7 +8,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
|
8
8
|
import { decodeOutputLog } from './output-log.js'
|
|
9
9
|
import { PASS_STATUSES } from '../history/index.js'
|
|
10
10
|
import { maskedLine, splitTaskId } from '../util/index.js'
|
|
11
|
-
import { fileLocations, fileMemo, type OutputLocation } from '../
|
|
11
|
+
import { fileLocations, fileMemo, type OutputLocation } from '../ui/index.js'
|
|
12
12
|
|
|
13
13
|
/** A failed task of a run and what it said. */
|
|
14
14
|
export interface TaskFailure {
|
|
@@ -38,6 +38,8 @@ export interface RunRecordsInput {
|
|
|
38
38
|
host: HostContext
|
|
39
39
|
/** Build the telemetry mirror; a run with no sink allocates none. */
|
|
40
40
|
withTelemetry: boolean
|
|
41
|
+
/** Each task's count of the tasks and groups downstream of it (the scheduler's): the rows' `blocks`. */
|
|
42
|
+
blocks?: ReadonlyMap<string, number>
|
|
41
43
|
/** The run's remote failures by key, so far (`Cache.traffic`). */
|
|
42
44
|
remoteTraffic?: ReadonlyMap<string, { failures: readonly string[] }>
|
|
43
45
|
}
|
|
@@ -124,6 +126,14 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
124
126
|
if (o.whereCores !== undefined) r.placementCores = o.whereCores
|
|
125
127
|
if (o.whereMemoryBytes !== undefined) r.placementMemoryBytes = o.whereMemoryBytes
|
|
126
128
|
if (o.cpuShare !== undefined) r.cpuShare = o.cpuShare
|
|
129
|
+
if (o.queuedMs !== undefined) r.queuedMs = o.queuedMs
|
|
130
|
+
if (o.outputBytes !== undefined) r.outputBytes = o.outputBytes
|
|
131
|
+
const blocks = input.blocks?.get(o.node.id)
|
|
132
|
+
if (blocks !== undefined) r.blocks = blocks
|
|
133
|
+
if (o.flaky !== undefined) {
|
|
134
|
+
r.flakyPasses = o.flaky.passes
|
|
135
|
+
r.flakyFailures = o.flaky.failures
|
|
136
|
+
}
|
|
127
137
|
if (o.wallclockStartNs !== undefined) r.wallclockStartNs = o.wallclockStartNs
|
|
128
138
|
if (o.wallclockEndNs !== undefined) r.wallclockEndNs = o.wallclockEndNs
|
|
129
139
|
if (hit) r.restored = o.restored === true
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -39,11 +39,12 @@ import {
|
|
|
39
39
|
type TaskNode,
|
|
40
40
|
type TaskOutcome,
|
|
41
41
|
} from '../graph/index.js'
|
|
42
|
+
import { graphCounts } from './graph-memo.js'
|
|
42
43
|
import {
|
|
43
44
|
mark,
|
|
44
45
|
stageTimes,
|
|
45
46
|
MAX_TIMEOUT_MS,
|
|
46
|
-
|
|
47
|
+
timingLines,
|
|
47
48
|
ulid,
|
|
48
49
|
isLiteralPattern,
|
|
49
50
|
listed,
|
|
@@ -53,12 +54,17 @@ import {
|
|
|
53
54
|
secretMask,
|
|
54
55
|
isFsRefusal,
|
|
55
56
|
isUserError,
|
|
56
|
-
detectColors,
|
|
57
|
-
type ColorSupport,
|
|
58
57
|
SecretBroker,
|
|
59
58
|
thrownMessage,
|
|
60
|
-
type ui,
|
|
61
59
|
} from '../util/index.js'
|
|
60
|
+
import * as ui from '../ui/index.js'
|
|
61
|
+
|
|
62
|
+
/** The stage table `VX_DEBUG_TIMING=1` asks for, on stderr. */
|
|
63
|
+
function printTimings(): void {
|
|
64
|
+
const lines = timingLines()
|
|
65
|
+
if (lines !== undefined) ui.print('err', ui.trace(lines))
|
|
66
|
+
}
|
|
67
|
+
import { detectColors, type ColorSupport } from '../ui/index.js'
|
|
62
68
|
import { keyedProjects } from './keyed-projects.js'
|
|
63
69
|
import { isDefaultBuild } from '../graph/index.js'
|
|
64
70
|
import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
|
|
@@ -70,7 +76,6 @@ import { excludedTaint } from './excluded-keys.js'
|
|
|
70
76
|
import { dependedOn, keyUpstream } from './upstream.js'
|
|
71
77
|
import { createLiveUsage } from './live-usage.js'
|
|
72
78
|
import {
|
|
73
|
-
blocksCounts,
|
|
74
79
|
busLogger,
|
|
75
80
|
createEventBus,
|
|
76
81
|
createForecast,
|
|
@@ -356,6 +361,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
356
361
|
const detach = (): void => {
|
|
357
362
|
unsubscribeTerminal()
|
|
358
363
|
unsubscribeWarned()
|
|
364
|
+
// A run refused or thrown before its tasks (an unknown task, a plugin
|
|
365
|
+
// stage that threw) never reached the end that takes the live region
|
|
366
|
+
// down: a load slow enough to show its step left `vx:load running`
|
|
367
|
+
// on screen, and the CLI's refusal was written onto its last line.
|
|
368
|
+
terminal?.runDone?.()
|
|
359
369
|
terminal?.settle()
|
|
360
370
|
}
|
|
361
371
|
// A plugin's `output` renders the run from its start; core's renderer
|
|
@@ -913,14 +923,39 @@ async function runOnBus(
|
|
|
913
923
|
const fault = executorFaults(warnOf(log))
|
|
914
924
|
const footers = executors.filter((e) => e.footer !== undefined)
|
|
915
925
|
const placers = executors.filter((e) => e.running !== undefined)
|
|
916
|
-
//
|
|
917
|
-
// over the graph, built on the
|
|
918
|
-
|
|
919
|
-
|
|
926
|
+
// The scheduler's dependents count, and every row's `blocks` (the live
|
|
927
|
+
// list's and the results'): one closure over the graph, built on the
|
|
928
|
+
// first that asks.
|
|
929
|
+
let counted: ReadonlyMap<string, number> | undefined
|
|
930
|
+
const blocksNow = (): ReadonlyMap<string, number> =>
|
|
931
|
+
(counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
|
|
932
|
+
// Every row's `usual` and the forecast's timings: history read once, on
|
|
933
|
+
// the first row that asks, so a run that executes nothing never reads
|
|
934
|
+
// it. It was read only a second into a live run, so rows before that,
|
|
935
|
+
// a short run's and every log's showed none (2026-10-29).
|
|
936
|
+
let p50s: ReadonlyMap<string, number> | undefined
|
|
937
|
+
const p50sNow = (): ReadonlyMap<string, number> => {
|
|
938
|
+
if (p50s !== undefined) return p50s
|
|
939
|
+
const ids: string[] = []
|
|
940
|
+
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
941
|
+
try {
|
|
942
|
+
p50s = new LocalHistoryProvider(
|
|
943
|
+
indexDb(prepared.localCache),
|
|
944
|
+
undefined,
|
|
945
|
+
prepared.branch(),
|
|
946
|
+
taskDefinitions(nodes.values()),
|
|
947
|
+
).p50sNow(ids)
|
|
948
|
+
} catch {
|
|
949
|
+
// History is observability: an unreadable one knows no task.
|
|
950
|
+
p50s = new Map()
|
|
951
|
+
}
|
|
952
|
+
return p50s
|
|
953
|
+
}
|
|
920
954
|
// The live rows' `cpu` and `mem`; it looks only once the live list asks.
|
|
921
955
|
const liveUsage = createLiveUsage()
|
|
922
956
|
const runContext = {
|
|
923
957
|
blocks: blocksNow,
|
|
958
|
+
usual: (id: string) => p50sNow().get(id),
|
|
924
959
|
usage: liveUsage.usage,
|
|
925
960
|
...(footers.length > 0
|
|
926
961
|
? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
|
|
@@ -979,17 +1014,7 @@ async function runOnBus(
|
|
|
979
1014
|
// never reads history.
|
|
980
1015
|
terminal?.forecast(
|
|
981
1016
|
() => createForecast(nodes, new Map(), concurrency, blocksNow()),
|
|
982
|
-
async () =>
|
|
983
|
-
const ids: string[] = []
|
|
984
|
-
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
985
|
-
const p50s = await new LocalHistoryProvider(
|
|
986
|
-
indexDb(prepared.localCache),
|
|
987
|
-
undefined,
|
|
988
|
-
prepared.branch(),
|
|
989
|
-
taskDefinitions(nodes.values()),
|
|
990
|
-
).p50sFor(ids)
|
|
991
|
-
return createForecast(nodes, p50s, concurrency, blocksNow())
|
|
992
|
-
},
|
|
1017
|
+
async () => createForecast(nodes, p50sNow(), concurrency, blocksNow()),
|
|
993
1018
|
)
|
|
994
1019
|
|
|
995
1020
|
// Remote-only: kick off background prefetches so remote-GET latency
|
|
@@ -1268,10 +1293,12 @@ async function runOnBus(
|
|
|
1268
1293
|
}
|
|
1269
1294
|
}
|
|
1270
1295
|
let lockBroken: boolean | undefined
|
|
1296
|
+
prepared.localCache.storeHeldBlobSizes()
|
|
1271
1297
|
const outcomes = await runGraph({
|
|
1272
1298
|
nodes,
|
|
1273
1299
|
concurrency,
|
|
1274
1300
|
restoreConcurrency: options.restoreConcurrency ?? 2 * concurrency,
|
|
1301
|
+
reverseDeps: blocksNow,
|
|
1275
1302
|
settledOf: (o) => deferredSaves.get(o.node.id),
|
|
1276
1303
|
...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
|
|
1277
1304
|
...(admit !== undefined ? { admit } : {}),
|
|
@@ -1280,8 +1307,8 @@ async function runOnBus(
|
|
|
1280
1307
|
serverDied,
|
|
1281
1308
|
onFailFast: () => failFast.abort(),
|
|
1282
1309
|
signal: stop.signal,
|
|
1283
|
-
onStart: (node
|
|
1284
|
-
log.taskStart?.(node
|
|
1310
|
+
onStart: (node) => {
|
|
1311
|
+
log.taskStart?.(node)
|
|
1285
1312
|
},
|
|
1286
1313
|
onFinish: (o) => {
|
|
1287
1314
|
if (cacheOff) o.cacheOff = true
|
|
@@ -1380,6 +1407,9 @@ async function runOnBus(
|
|
|
1380
1407
|
const hold =
|
|
1381
1408
|
options.holdPersistent === true &&
|
|
1382
1409
|
(!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
|
|
1410
|
+
// A stop already signalled every server once and waits out its grace:
|
|
1411
|
+
// a SIGTERM here, while a server ran its cleanup, cut it short.
|
|
1412
|
+
if (stop.signal.aborted) await stop.aborting?.catch(() => {})
|
|
1383
1413
|
const { keepAlive, crashed: crashedPersistent } = await disposeServers({
|
|
1384
1414
|
registry: persistentRegistry,
|
|
1385
1415
|
nodes,
|
|
@@ -1488,6 +1518,7 @@ async function runOnBus(
|
|
|
1488
1518
|
ci: ciContext,
|
|
1489
1519
|
host: hostContext,
|
|
1490
1520
|
withTelemetry: telemetry !== undefined,
|
|
1521
|
+
blocks: blocksNow(),
|
|
1491
1522
|
remoteTraffic: prepared.localCache.traffic.keys,
|
|
1492
1523
|
})
|
|
1493
1524
|
const records = recordsOf(list, ok)
|
|
@@ -446,8 +446,8 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
|
446
446
|
return (
|
|
447
447
|
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
|
|
448
448
|
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
449
|
-
`reaches one (\`^build
|
|
450
|
-
|
|
449
|
+
`reaches one (\`^build\`, where ${w.name}#build keys its sources), or grant and key ` +
|
|
450
|
+
`the files yourself (\`allow.read\` plus ` +
|
|
451
451
|
`\`inputs.workspaceFiles\`).`
|
|
452
452
|
)
|
|
453
453
|
}
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
// them to the runner around every spawn.
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
claimExitForSignal,
|
|
19
|
+
dieOf,
|
|
20
|
+
hangupIgnored,
|
|
21
|
+
killGraceMs,
|
|
22
|
+
settleWithin,
|
|
23
|
+
} from '../util/index.js'
|
|
18
24
|
import {
|
|
19
25
|
holdGroups,
|
|
20
26
|
killTree,
|
|
@@ -154,10 +160,7 @@ export function forwardSignals(args: {
|
|
|
154
160
|
// and a 130 exit ran the script's next line. The code stays the
|
|
155
161
|
// fallback should the signal not land.
|
|
156
162
|
if (args.handsTerminal !== true) {
|
|
157
|
-
process.once('exit', () =>
|
|
158
|
-
process.removeAllListeners(signal)
|
|
159
|
-
process.kill(process.pid, signal)
|
|
160
|
-
})
|
|
163
|
+
process.once('exit', () => dieOf(signal))
|
|
161
164
|
}
|
|
162
165
|
process.exit(signalExitCode(signal))
|
|
163
166
|
}
|
|
@@ -519,9 +519,23 @@ function hashableConfig(cfg: TaskConfig): unknown {
|
|
|
519
519
|
// Keys sorted: moving `cache` above `exec`, or a `define` in another
|
|
520
520
|
// order, missed every key of the task, and a `project` or `graph` plugin
|
|
521
521
|
// writes a config the lock never sorted (2026-10-25).
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
522
|
+
const env = cfg.inputs?.env
|
|
523
|
+
const pass = cfg.exec?.env?.passThrough
|
|
524
|
+
if (cfg.exec?.remote === undefined && sortedNames(env) && sortedNames(pass))
|
|
525
|
+
return keysSorted(cfg)
|
|
526
|
+
const { remote: _remote, ...execRest } = cfg.exec ?? ({} as NonNullable<TaskConfig['exec']>)
|
|
527
|
+
// Env names are sets: each value is read by its name, so their order
|
|
528
|
+
// keys nothing (conformance env/declared-order-irrelevant).
|
|
529
|
+
if (!sortedNames(pass)) execRest.env = { ...execRest.env, passThrough: [...pass!].sort() }
|
|
530
|
+
const out: TaskConfig = cfg.exec === undefined ? { ...cfg } : { ...cfg, exec: execRest }
|
|
531
|
+
if (!sortedNames(env)) out.inputs = { ...cfg.inputs!, env: [...env!].sort() }
|
|
532
|
+
return keysSorted(out)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function sortedNames(names: readonly string[] | undefined): boolean {
|
|
536
|
+
if (names === undefined) return true
|
|
537
|
+
for (let i = 1; i < names.length; i++) if (names[i - 1]! > names[i]!) return false
|
|
538
|
+
return true
|
|
525
539
|
}
|
|
526
540
|
|
|
527
541
|
/**
|
package/src/report/events.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface RunStartInfo {
|
|
|
45
45
|
*/
|
|
46
46
|
export type RunEvent =
|
|
47
47
|
| { kind: 'run:start'; info: RunStartInfo }
|
|
48
|
-
| { kind: 'task:start'; node: TaskNode
|
|
48
|
+
| { kind: 'task:start'; node: TaskNode }
|
|
49
49
|
| { kind: 'task:stdout'; node: TaskNode; chunk: string }
|
|
50
50
|
| { kind: 'task:stderr'; node: TaskNode; chunk: string }
|
|
51
51
|
| { kind: 'task:complete'; node: TaskNode; outcome: TaskOutcome }
|
|
@@ -139,12 +139,7 @@ export function busLogger(bus: EventBus): Logger {
|
|
|
139
139
|
taskStderr: (node, chunk) => bus.emit({ kind: 'task:stderr', node, chunk }),
|
|
140
140
|
taskComplete: (node, outcome) => bus.emit({ kind: 'task:complete', node, outcome }),
|
|
141
141
|
runStart: (info) => bus.emit({ kind: 'run:start', info }),
|
|
142
|
-
taskStart: (node,
|
|
143
|
-
bus.emit(
|
|
144
|
-
info?.waitedMs === undefined
|
|
145
|
-
? { kind: 'task:start', node }
|
|
146
|
-
: { kind: 'task:start', node, waitedMs: info.waitedMs },
|
|
147
|
-
),
|
|
142
|
+
taskStart: (node) => bus.emit({ kind: 'task:start', node }),
|
|
148
143
|
runEnd: () => {
|
|
149
144
|
if (ended) return
|
|
150
145
|
ended = true
|
|
@@ -179,10 +174,7 @@ export function terminalSubscriber(sink: Logger): RunEventSubscriber {
|
|
|
179
174
|
sink.runStart?.(event.info)
|
|
180
175
|
return
|
|
181
176
|
case 'task:start':
|
|
182
|
-
sink.taskStart?.(
|
|
183
|
-
event.node,
|
|
184
|
-
event.waitedMs === undefined ? undefined : { waitedMs: event.waitedMs },
|
|
185
|
-
)
|
|
177
|
+
sink.taskStart?.(event.node)
|
|
186
178
|
return
|
|
187
179
|
case 'task:stdout':
|
|
188
180
|
sink.taskStdout(event.node, event.chunk)
|