@vzn/vx 0.0.638 → 0.0.639
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/docs/caching.md +44 -15
- package/docs/cli.md +168 -345
- package/docs/execution.md +18 -43
- package/docs/features.md +7 -10
- package/docs/patterns.md +5 -7
- package/docs/schema.md +47 -49
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/skills/vx/SKILL.md +6 -4
- package/src/cache/cache.ts +40 -10
- package/src/cache/git-inputs.ts +6 -0
- package/src/cache/index.ts +2 -2
- package/src/cache/layer.ts +25 -11
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +10 -22
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/last.ts +6 -3
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/show.ts +6 -0
- package/src/cli/upgrade.ts +123 -39
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch.ts +29 -14
- package/src/cli/why.ts +4 -4
- package/src/config.ts +0 -6
- package/src/exec/executor.ts +1 -1
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/index.ts +9 -1
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +4 -16
- package/src/graph/task-graph.ts +87 -17
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +10 -28
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +21 -386
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +1 -73
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +3 -3
- package/src/orchestrator/plan.ts +18 -5
- package/src/orchestrator/plugin-host.ts +67 -47
- package/src/orchestrator/plugin.ts +6 -6
- package/src/orchestrator/prepare.ts +28 -29
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +81 -179
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +260 -601
- package/src/orchestrator/sandbox-fix.ts +0 -16
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +107 -3
- package/src/orchestrator/steps.ts +50 -0
- package/src/orchestrator/task-hash.ts +3 -16
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +1 -1
- package/src/orchestrator/telemetry.ts +5 -82
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +39 -33
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +84 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +1 -22
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
package/src/cli/upgrade.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
// package manager (npm, pnpm, bun, yarn; global or a project's
|
|
11
11
|
// dependency) is upgraded by that package manager, never by a rename over
|
|
12
12
|
// a file it owns. vx never installs Node or a package manager.
|
|
13
|
+
// Handed a call by a global vx, the project's vx moves that global too,
|
|
14
|
+
// to the same version (`upgradeGlobal`).
|
|
13
15
|
// Named `upgrade` (not `update`) per CLI convention: bun upgrade,
|
|
14
16
|
// deno upgrade — "update" is what package managers do to indexes.
|
|
15
17
|
|
|
@@ -290,17 +292,19 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
|
|
|
290
292
|
/**
|
|
291
293
|
* The binary of `pkg@spec`, verified: the registry document's signature,
|
|
292
294
|
* then the tarball's integrity, then the extraction, each refusing before
|
|
293
|
-
* the next step runs. Null when the release is
|
|
294
|
-
* `announce` hears the version before the download
|
|
295
|
+
* the next step runs. Null when the release is `current`, the version of
|
|
296
|
+
* the vx being replaced; `announce` hears the version before the download
|
|
297
|
+
* starts.
|
|
295
298
|
*/
|
|
296
299
|
export async function releaseBinary(
|
|
297
300
|
pkg: string,
|
|
298
301
|
spec: string,
|
|
299
302
|
announce: (version: string) => void,
|
|
300
303
|
key: SigningKey = NPM_SIGNING_KEY,
|
|
304
|
+
current = VERSION,
|
|
301
305
|
): Promise<Uint8Array | null> {
|
|
302
306
|
const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
|
|
303
|
-
if (release.version ===
|
|
307
|
+
if (release.version === current) return null
|
|
304
308
|
announce(release.version)
|
|
305
309
|
return binaryFromTarball(await downloadVerified(release))
|
|
306
310
|
}
|
|
@@ -518,15 +522,24 @@ async function targetVersion(tag: string | undefined): Promise<string> {
|
|
|
518
522
|
return v
|
|
519
523
|
}
|
|
520
524
|
|
|
521
|
-
/**
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
525
|
+
/**
|
|
526
|
+
* Run the package manager that owns a vx at `current` to bring it to
|
|
527
|
+
* `version`; vx never installs one. `step` names the install when a run
|
|
528
|
+
* moves two (`project `, `global `).
|
|
529
|
+
*/
|
|
530
|
+
async function upgradeManaged(
|
|
531
|
+
install: ManagedInstall,
|
|
532
|
+
version: string,
|
|
533
|
+
current = VERSION,
|
|
534
|
+
step = '',
|
|
535
|
+
): Promise<void> {
|
|
536
|
+
if (version === current) {
|
|
537
|
+
ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
|
|
538
|
+
return
|
|
527
539
|
}
|
|
528
540
|
const argv = upgradeArgv(install, version)
|
|
529
|
-
|
|
541
|
+
// PATH as it is now: `Bun.which` alone reads the one the process started with.
|
|
542
|
+
const bin = Bun.which(argv[0]!, { PATH: process.env['PATH'] ?? '' })
|
|
530
543
|
if (bin === null) {
|
|
531
544
|
throw new UserError(
|
|
532
545
|
`vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
|
|
@@ -534,7 +547,7 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
|
|
|
534
547
|
}
|
|
535
548
|
ui.print(
|
|
536
549
|
'out',
|
|
537
|
-
ui.status('info', 'vx upgrade', `${
|
|
550
|
+
ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
|
|
538
551
|
ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
|
|
539
552
|
)
|
|
540
553
|
const proc = Bun.spawnSync({
|
|
@@ -547,7 +560,90 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
|
|
|
547
560
|
`vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
|
|
548
561
|
)
|
|
549
562
|
}
|
|
550
|
-
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Replace the binary at `dest`, at `current`, with the verified release
|
|
567
|
+
* `spec` names (`latest` or a version). `step` as in `upgradeManaged`.
|
|
568
|
+
*/
|
|
569
|
+
async function upgradeBinary(
|
|
570
|
+
dest: string,
|
|
571
|
+
spec: string,
|
|
572
|
+
current = VERSION,
|
|
573
|
+
step = '',
|
|
574
|
+
key: SigningKey = NPM_SIGNING_KEY,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
const pkg = platformPackage()
|
|
577
|
+
let installed: string | undefined
|
|
578
|
+
const replaced = await replaceBinary(
|
|
579
|
+
dest,
|
|
580
|
+
() =>
|
|
581
|
+
releaseBinary(
|
|
582
|
+
pkg,
|
|
583
|
+
spec,
|
|
584
|
+
(version) =>
|
|
585
|
+
ui.print(
|
|
586
|
+
'out',
|
|
587
|
+
ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
|
|
588
|
+
ui.text(ui.dim(dest)),
|
|
589
|
+
),
|
|
590
|
+
key,
|
|
591
|
+
current,
|
|
592
|
+
),
|
|
593
|
+
// The replaced binary's own version: the new build speaks for itself
|
|
594
|
+
// rather than this process guessing, and one that cannot is rolled back.
|
|
595
|
+
(bin) => {
|
|
596
|
+
installed = answeredVersion(bin)
|
|
597
|
+
return installed !== undefined
|
|
598
|
+
},
|
|
599
|
+
)
|
|
600
|
+
ui.print(
|
|
601
|
+
'out',
|
|
602
|
+
replaced
|
|
603
|
+
? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
|
|
604
|
+
: ui.status('info', 'vx upgrade', `${step}already at ${current}`),
|
|
605
|
+
)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
|
|
609
|
+
function answeredVersion(bin: string): string | undefined {
|
|
610
|
+
return startedVersion(bin)?.split(' ')[1]
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Bring `from`, the global vx that handed this run to the project's own
|
|
615
|
+
* (`VX_HANDED_OFF`), to `version`, the way it was installed: a binary
|
|
616
|
+
* installed by hand is replaced, a package manager's global install is
|
|
617
|
+
* updated by that manager. A refusal names it.
|
|
618
|
+
*/
|
|
619
|
+
export async function upgradeGlobal(
|
|
620
|
+
from: string,
|
|
621
|
+
version: string,
|
|
622
|
+
key: SigningKey = NPM_SIGNING_KEY,
|
|
623
|
+
): Promise<void> {
|
|
624
|
+
await named(`global vx ${from}`, async () => {
|
|
625
|
+
const current = answeredVersion(from)
|
|
626
|
+
if (current === undefined) {
|
|
627
|
+
throw new UserError('vx upgrade: it did not answer `--version`, so it is left as it is')
|
|
628
|
+
}
|
|
629
|
+
if (current === version) {
|
|
630
|
+
ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
|
|
631
|
+
return
|
|
632
|
+
}
|
|
633
|
+
const install = managedInstall(from, path.dirname(from))
|
|
634
|
+
if (install === null) await upgradeBinary(from, version, current, 'global ', key)
|
|
635
|
+
else await upgradeManaged(install, version, current, 'global ')
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
|
|
640
|
+
async function named(what: string, run: () => Promise<void>): Promise<void> {
|
|
641
|
+
try {
|
|
642
|
+
await run()
|
|
643
|
+
} catch (err) {
|
|
644
|
+
if (!(err instanceof UserError)) throw err
|
|
645
|
+
throw new UserError(err.message.replace(/^vx upgrade: /, `vx upgrade: ${what}: `), err.code)
|
|
646
|
+
}
|
|
551
647
|
}
|
|
552
648
|
|
|
553
649
|
export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
@@ -575,33 +671,21 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
575
671
|
}
|
|
576
672
|
const dest = process.execPath
|
|
577
673
|
const install = managedInstall(dest, process.cwd())
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
// rather than this process guessing, and one that cannot is rolled back.
|
|
594
|
-
(bin) => {
|
|
595
|
-
installed = startedVersion(bin) ?? ''
|
|
596
|
-
return installed !== ''
|
|
597
|
-
},
|
|
598
|
-
)
|
|
599
|
-
ui.print(
|
|
600
|
-
'out',
|
|
601
|
-
replaced
|
|
602
|
-
? ui.status('ok', 'vx upgrade', `installed ${installed.replace(/^vx /, '')}`)
|
|
603
|
-
: ui.status('info', 'vx upgrade', `already at ${VERSION}`),
|
|
604
|
-
)
|
|
674
|
+
// A global vx handed this run to the project's own: both move, to one
|
|
675
|
+
// version, the project first (owner, 2026-10-15).
|
|
676
|
+
const from = process.env['VX_HANDED_OFF']
|
|
677
|
+
if (from !== undefined) {
|
|
678
|
+
const version = await targetVersion(tag)
|
|
679
|
+
await named('project vx', () =>
|
|
680
|
+
install === null
|
|
681
|
+
? upgradeBinary(dest, version, VERSION, 'project ')
|
|
682
|
+
: upgradeManaged(install, version, VERSION, 'project '),
|
|
683
|
+
)
|
|
684
|
+
await upgradeGlobal(from, version)
|
|
685
|
+
return 0
|
|
686
|
+
}
|
|
687
|
+
if (install !== null) await upgradeManaged(install, await targetVersion(tag))
|
|
688
|
+
else await upgradeBinary(dest, versionSpec(tag))
|
|
605
689
|
return 0
|
|
606
690
|
}
|
|
607
691
|
|
package/src/cli/watch-cycle.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// as Ctrl-C would and the loop starts the next one; a cycle running any
|
|
6
6
|
// other work is never stopped.
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { type RunOptions } from '../orchestrator/index.js'
|
|
9
|
+
import { createEventBus } from '../report/index.js'
|
|
9
10
|
|
|
10
11
|
export interface WatchCycle {
|
|
11
12
|
/** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
|
package/src/cli/watch.ts
CHANGED
|
@@ -61,22 +61,17 @@ const DEBOUNCE_MAX_MS = 1_000
|
|
|
61
61
|
* the help line and the completions, and a test holds the two together.
|
|
62
62
|
*/
|
|
63
63
|
export function watchRefusal(parsed: RunArgs): string | null {
|
|
64
|
-
if (parsed.dry !== undefined
|
|
65
|
-
return 'vx watch: --dry
|
|
64
|
+
if (parsed.dry !== undefined) {
|
|
65
|
+
return 'vx watch: --dry is not supported in watch mode'
|
|
66
66
|
}
|
|
67
|
-
if (parsed.
|
|
68
|
-
return 'vx watch: --
|
|
67
|
+
if (parsed.profile !== undefined) {
|
|
68
|
+
return 'vx watch: --profile is not supported in watch mode (would overwrite per cycle)'
|
|
69
69
|
}
|
|
70
|
-
//
|
|
71
|
-
//
|
|
70
|
+
// It formats ONE run's result and is consumed by `runCmd` alone, so a
|
|
71
|
+
// watch loop silently ignored it. `--verbosity 0` is not rejected: it
|
|
72
72
|
// asks for the output watch already gives.
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
parsed.reportFile !== undefined ||
|
|
76
|
-
parsed.verbosity > 0 ||
|
|
77
|
-
parsed.format !== 'pretty'
|
|
78
|
-
) {
|
|
79
|
-
return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
|
|
73
|
+
if (parsed.verbosity > 0) {
|
|
74
|
+
return 'vx watch: --verbosity is not supported in watch mode (it reports a single run)'
|
|
80
75
|
}
|
|
81
76
|
return null
|
|
82
77
|
}
|
|
@@ -626,6 +621,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
626
621
|
}
|
|
627
622
|
}
|
|
628
623
|
const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
|
|
624
|
+
/** The projects the workspace-wide arm names an event by. */
|
|
625
|
+
let wide: readonly ProjectMeta[] = []
|
|
626
|
+
// The deepest, as a nested project's file is its own.
|
|
627
|
+
const ownerOf = (abs: string): ProjectMeta | undefined => {
|
|
628
|
+
let best: ProjectMeta | undefined
|
|
629
|
+
for (const proj of wide)
|
|
630
|
+
if (
|
|
631
|
+
abs.startsWith(proj.dir + path.sep) &&
|
|
632
|
+
(best === undefined || proj.dir.length > best.dir.length)
|
|
633
|
+
)
|
|
634
|
+
best = proj
|
|
635
|
+
return best
|
|
636
|
+
}
|
|
629
637
|
/** The root arm: recursive when workspace-wide or the one stream, else the root's own files only. */
|
|
630
638
|
let rootArm: WatchHandle = CLOSED
|
|
631
639
|
let dotVxArm: WatchHandle = CLOSED
|
|
@@ -700,7 +708,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
700
708
|
configImportFiles.includes(abs)
|
|
701
709
|
)
|
|
702
710
|
reread = true
|
|
703
|
-
|
|
711
|
+
const owner = ownerOf(abs)
|
|
712
|
+
trigger(
|
|
713
|
+
owner === undefined
|
|
714
|
+
? `root ${filename}`
|
|
715
|
+
: `${owner.name} ${path.relative(owner.dir, abs)}`,
|
|
716
|
+
abs,
|
|
717
|
+
)
|
|
704
718
|
})
|
|
705
719
|
: oneStream
|
|
706
720
|
? arm(workspaceRoot, true, streamEvent)
|
|
@@ -725,6 +739,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
725
739
|
// (it would cover every project + node_modules + caches). On macOS one
|
|
726
740
|
// recursive stream at the root stands for all of them (`oneStream`).
|
|
727
741
|
const armMode = (watched: readonly ProjectMeta[]): void => {
|
|
742
|
+
wide = workspaceWide ? watched : []
|
|
728
743
|
if (!workspaceWide) {
|
|
729
744
|
streamed = oneStream ? toStream(watched) : []
|
|
730
745
|
const inStream = new Set(streamed.map((s) => s.proj))
|
package/src/cli/why.ts
CHANGED
|
@@ -9,13 +9,13 @@ import { flagHint, formatValue, seeHelp } from './help.js'
|
|
|
9
9
|
import { splitTaskId } from '../graph/index.js'
|
|
10
10
|
import {
|
|
11
11
|
cacheKeyDiff,
|
|
12
|
-
|
|
12
|
+
explainCacheKey,
|
|
13
13
|
inputWriters,
|
|
14
14
|
latestRunId,
|
|
15
15
|
rootCauses,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from '../orchestrator/index.js'
|
|
16
|
+
whyDidThisRerun,
|
|
17
|
+
} from '../history/index.js'
|
|
18
|
+
import { resolveRunId } from '../orchestrator/index.js'
|
|
19
19
|
import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
|
|
20
20
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
21
21
|
import { findCwdProject } from './select.js'
|
package/src/config.ts
CHANGED
|
@@ -62,12 +62,6 @@ export interface RunSettings {
|
|
|
62
62
|
* default) skips its dependents, `always` runs them too.
|
|
63
63
|
*/
|
|
64
64
|
continue?: 'never' | 'deps-ok' | 'always'
|
|
65
|
-
/** How long a task that ignores SIGTERM gets before SIGKILL. Omitted → `'2s'`. */
|
|
66
|
-
killGrace?: Duration
|
|
67
|
-
/** The bound on each plugin's end-of-run flush and teardown. Omitted → `'3s'`. */
|
|
68
|
-
teardownTimeout?: Duration
|
|
69
|
-
/** How long a persistent task may take to match `readyWhen` before vx says it waits. Omitted → `'10s'`. */
|
|
70
|
-
readyNotice?: Duration
|
|
71
65
|
/**
|
|
72
66
|
* Graph rules checked by `vx lock` and `vx lock --check`, never by a
|
|
73
67
|
* run. Each is on
|
package/src/exec/executor.ts
CHANGED
|
@@ -142,7 +142,7 @@ export interface ExecuteRequest {
|
|
|
142
142
|
* work and returns: a child an executor spawned is not one core can
|
|
143
143
|
* reach (H-10, H-12). One already aborted is work not to start (B-55).
|
|
144
144
|
* Any exit after the timeout's abort, a 0 included, is a timeout. Core
|
|
145
|
-
* waits the kill grace (
|
|
145
|
+
* waits the kill grace (2 s) after the abort, then
|
|
146
146
|
* settles the attempt without `execute` and abandons it (H-14).
|
|
147
147
|
*/
|
|
148
148
|
readonly signal?: AbortSignal
|
|
@@ -2332,7 +2332,7 @@ function outsideWritesHint(
|
|
|
2332
2332
|
? `${refused} The task has its own temp directory, empty at its start: write under ` +
|
|
2333
2333
|
`$TMPDIR (os.tmpdir() in Node and Bun) instead of a fixed path.`
|
|
2334
2334
|
: `${refused} If the task needs one, grant its directory, e.g. ` +
|
|
2335
|
-
`\`allow: { write: [${jsString(`${spelled}/`)}] }\`.`
|
|
2335
|
+
`\`allow: { write: [${jsString(spelled.endsWith('/') ? spelled : `${spelled}/`)}] }\`.`
|
|
2336
2336
|
return { timestamp: new Date(), hint: true, line }
|
|
2337
2337
|
}
|
|
2338
2338
|
|
|
@@ -578,7 +578,9 @@ export function refusedWritesOutside(
|
|
|
578
578
|
if (v.path === undefined || !path.isAbsolute(v.path) || !write || reported(v)) continue
|
|
579
579
|
if (!skip.some((s) => atOrUnder(v.path!, s))) paths.add(v.path)
|
|
580
580
|
}
|
|
581
|
-
|
|
581
|
+
// `mkdir -p ~/.cache/tool` is refused at each directory on its way;
|
|
582
|
+
// the one it wanted is the deepest.
|
|
583
|
+
return [...paths].filter((p) => ![...paths].some((q) => q !== p && atOrUnder(q, p)))
|
|
582
584
|
}
|
|
583
585
|
|
|
584
586
|
/**
|
package/src/graph/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
parseDependencySpec,
|
|
9
9
|
type DependencySpec,
|
|
10
10
|
} from './dependency-spec.js'
|
|
11
|
+
export { computeReverseDepCount } from './priorities.js'
|
|
11
12
|
export {
|
|
12
13
|
type ContinueMode,
|
|
13
14
|
deadServerBehind,
|
|
@@ -16,8 +17,15 @@ export {
|
|
|
16
17
|
RestoreDemoted,
|
|
17
18
|
runGraph,
|
|
18
19
|
type TaskOutcome,
|
|
19
|
-
type TaskStatus,
|
|
20
20
|
} from './scheduler.js'
|
|
21
|
+
export {
|
|
22
|
+
type CacheSource,
|
|
23
|
+
deriveCacheSource,
|
|
24
|
+
isCacheHit,
|
|
25
|
+
isPassStatus,
|
|
26
|
+
TASK_STATUSES,
|
|
27
|
+
type TaskStatus,
|
|
28
|
+
} from './task-status.js'
|
|
21
29
|
export {
|
|
22
30
|
buildTaskGraph,
|
|
23
31
|
checkGraph,
|
package/src/graph/priorities.ts
CHANGED
|
@@ -85,7 +85,7 @@ export function tieredReverseDepCount(
|
|
|
85
85
|
* dependents only (`calculateReverseDeps` in
|
|
86
86
|
* `packages/nx/src/tasks-runner/utils.ts`, read 2026-09-24).
|
|
87
87
|
*/
|
|
88
|
-
export function computeReverseDepCount(nodes:
|
|
88
|
+
export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Map<string, number> {
|
|
89
89
|
// Exact transitive-dependent COUNTS need closure SETS (diamonds
|
|
90
90
|
// double-count under naive summing). Set-of-strings closures are
|
|
91
91
|
// O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type ScheduleStrategy,
|
|
14
14
|
} from './priorities.js'
|
|
15
15
|
import { isGroupTask, type TaskNode } from './task-graph.js'
|
|
16
|
+
import type { TaskStatus } from './task-status.js'
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Thrown by `execute` for a restore-tier task that turned out to have
|
|
@@ -29,19 +30,6 @@ export class RestoreDemoted extends Error {
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export type TaskStatus =
|
|
33
|
-
| 'success'
|
|
34
|
-
| 'cache-hit'
|
|
35
|
-
| 'cache-hit-remote'
|
|
36
|
-
| 'failed'
|
|
37
|
-
| 'skipped'
|
|
38
|
-
// A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
|
|
39
|
-
// the task never finished on its own terms, so it reverts to aborted —
|
|
40
|
-
// excluded from the tally and the run history. It still PROPAGATES like
|
|
41
|
-
// a failure (see `willSkip`): the child died mid-write, so its outputs
|
|
42
|
-
// are partial.
|
|
43
|
-
| 'aborted'
|
|
44
|
-
|
|
45
33
|
/** What moved in a missed task's key since the last entry saved for it. */
|
|
46
34
|
export interface InputChanges {
|
|
47
35
|
/** How many components changed, appeared or went. 0: the same key, its entry gone. */
|
|
@@ -79,8 +67,8 @@ export interface TaskOutcome {
|
|
|
79
67
|
* How long an `admit` policy held this task after it was ready with a
|
|
80
68
|
* free worker — the wait the plugin, not the count gate, imposed. Set
|
|
81
69
|
* only when a policy refused it at least once (so a run with no policy
|
|
82
|
-
* carries the field on no outcome);
|
|
83
|
-
*
|
|
70
|
+
* carries the field on no outcome); the event stream and the summary
|
|
71
|
+
* footer show it.
|
|
84
72
|
*/
|
|
85
73
|
admissionHeldMs?: number
|
|
86
74
|
/**
|
|
@@ -136,7 +124,7 @@ export interface TaskOutcome {
|
|
|
136
124
|
* On a `skipped` outcome: the task at the ROOT of what blocked it — the
|
|
137
125
|
* failed (or aborted) upstream, followed through any chain of skips
|
|
138
126
|
* between. Absent when the skip was fail-fast's (no upstream failed).
|
|
139
|
-
* The footer,
|
|
127
|
+
* The footer, the telemetry record and the run row all
|
|
140
128
|
* read this one field (item 267).
|
|
141
129
|
*/
|
|
142
130
|
blockedBy?: string
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
|
|
2
2
|
import {
|
|
3
3
|
asTrees,
|
|
4
|
+
checkIgnoreSync,
|
|
5
|
+
ignoreProbes,
|
|
4
6
|
isLiteralPattern,
|
|
7
|
+
listedSync,
|
|
5
8
|
normalizeGlob,
|
|
6
9
|
relPosix,
|
|
7
10
|
splitNegations,
|
|
@@ -9,6 +12,7 @@ import {
|
|
|
9
12
|
taskGlob,
|
|
10
13
|
refusal,
|
|
11
14
|
UserError,
|
|
15
|
+
wholeIgnored,
|
|
12
16
|
wholeSubtreePrefixes,
|
|
13
17
|
} from '../util/index.js'
|
|
14
18
|
import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
|
|
@@ -584,7 +588,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
|
|
|
584
588
|
// task behind it would fold no upstream either.
|
|
585
589
|
if (!packageGraph.edgeSource) {
|
|
586
590
|
throw new UserError(
|
|
587
|
-
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
|
|
591
|
+
`${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
|
|
588
592
|
'VX_E_DEPENDS_ON',
|
|
589
593
|
)
|
|
590
594
|
}
|
|
@@ -1128,6 +1132,15 @@ function detectOutputCollisions(
|
|
|
1128
1132
|
}
|
|
1129
1133
|
}
|
|
1130
1134
|
|
|
1135
|
+
/**
|
|
1136
|
+
* What a task's `!` output entries take back (A-44). A glob of the other
|
|
1137
|
+
* task's that one takes back whole is a path this task never cleans or
|
|
1138
|
+
* captures, so the pair cannot collide there.
|
|
1139
|
+
*/
|
|
1140
|
+
function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
|
|
1141
|
+
return splitNegations(n.config.cache?.outputs[field] ?? []).negative
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1131
1144
|
/**
|
|
1132
1145
|
* `collide` for a root-anchored output against another project's `files`
|
|
1133
1146
|
* output rebased to the root. With an edge the dependant adds to its
|
|
@@ -1146,9 +1159,12 @@ function collideAcross(
|
|
|
1146
1159
|
exclusive: boolean,
|
|
1147
1160
|
): void {
|
|
1148
1161
|
if (neverWritesLocally(ws) || neverWritesLocally(files)) return
|
|
1162
|
+
const wsBack = takenBackOf(ws, 'workspaceFiles')
|
|
1163
|
+
const filesBack = takenBackOf(files, 'files').map((g) => (rel === '' ? g : `${rel}/${g}`))
|
|
1149
1164
|
for (const ga of wsGlobs) {
|
|
1150
1165
|
for (const gb of rebased) {
|
|
1151
|
-
if (!outputsOverlap(ga, gb))
|
|
1166
|
+
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
|
|
1167
|
+
continue
|
|
1152
1168
|
const head =
|
|
1153
1169
|
`${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
|
|
1154
1170
|
`inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
|
|
@@ -1405,9 +1421,12 @@ function collide(
|
|
|
1405
1421
|
exclusive: boolean,
|
|
1406
1422
|
): void {
|
|
1407
1423
|
if (neverWritesLocally(a) || neverWritesLocally(b)) return
|
|
1424
|
+
const aBack = takenBackOf(a, field)
|
|
1425
|
+
const bBack = takenBackOf(b, field)
|
|
1408
1426
|
for (const ga of aGlobs ?? []) {
|
|
1409
1427
|
for (const gb of bGlobs ?? []) {
|
|
1410
|
-
if (!outputsOverlap(ga, gb))
|
|
1428
|
+
if (!outputsOverlap(ga, gb) || outputTakenBack(gb, aBack) || outputTakenBack(ga, bBack))
|
|
1429
|
+
continue
|
|
1411
1430
|
const head =
|
|
1412
1431
|
`${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
|
|
1413
1432
|
(ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
|
|
@@ -1550,6 +1569,8 @@ interface Side {
|
|
|
1550
1569
|
reads: boolean
|
|
1551
1570
|
/** The project's root-relative dir for rebased `files` globs. */
|
|
1552
1571
|
rel?: string
|
|
1572
|
+
/** The root-relative dir project-relative `globs` sit in, for git. */
|
|
1573
|
+
anchor?: string | undefined
|
|
1553
1574
|
}
|
|
1554
1575
|
|
|
1555
1576
|
/**
|
|
@@ -1583,6 +1604,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1583
1604
|
let projectReaders = false
|
|
1584
1605
|
let rootReaders = false
|
|
1585
1606
|
const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
|
|
1607
|
+
const found: Found = new Map()
|
|
1586
1608
|
const bucket = (name: string): Side[] => {
|
|
1587
1609
|
let list = byProject.get(name)
|
|
1588
1610
|
if (list === undefined) byProject.set(name, (list = []))
|
|
@@ -1594,7 +1616,8 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1594
1616
|
const outFiles = positives(cache.outputs.files)
|
|
1595
1617
|
const outWs = positives(cache.outputs.workspaceFiles ?? [])
|
|
1596
1618
|
if (outFiles.length > 0) {
|
|
1597
|
-
|
|
1619
|
+
const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
|
|
1620
|
+
bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
|
|
1598
1621
|
filesWriters.push(n)
|
|
1599
1622
|
}
|
|
1600
1623
|
if (outWs.length > 0) {
|
|
@@ -1623,7 +1646,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1623
1646
|
(s) => s.globs,
|
|
1624
1647
|
(s) => s.reads,
|
|
1625
1648
|
)) {
|
|
1626
|
-
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1649
|
+
readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
|
|
1627
1650
|
}
|
|
1628
1651
|
}
|
|
1629
1652
|
}
|
|
@@ -1641,10 +1664,20 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1641
1664
|
(s) => s.globs,
|
|
1642
1665
|
(s) => s.reads,
|
|
1643
1666
|
)) {
|
|
1644
|
-
readsOutputs(memo, sides[i]!, sides[j]!, 'files')
|
|
1667
|
+
readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
|
|
1645
1668
|
}
|
|
1646
1669
|
}
|
|
1647
|
-
if (
|
|
1670
|
+
if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
|
|
1671
|
+
refuseUnlessIgnored(found, workspaceRoot)
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
function readsRootedOutputs(
|
|
1675
|
+
memo: TakeBackMemo,
|
|
1676
|
+
found: Found,
|
|
1677
|
+
rooted: Side[],
|
|
1678
|
+
filesWriters: readonly TaskNode[],
|
|
1679
|
+
workspaceRoot: string | undefined,
|
|
1680
|
+
): void {
|
|
1648
1681
|
if (workspaceRoot !== undefined) {
|
|
1649
1682
|
for (const node of filesWriters) {
|
|
1650
1683
|
const rel = relPosix(workspaceRoot, node.projectDir)
|
|
@@ -1659,8 +1692,37 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
|
|
|
1659
1692
|
(s) => s.globs,
|
|
1660
1693
|
(s) => s.reads,
|
|
1661
1694
|
)) {
|
|
1662
|
-
readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1695
|
+
readsOutputs(memo, found, rooted[i]!, rooted[j]!, 'workspaceFiles')
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
/** Each overlapped output, root-relative, and the refusal it meets; the first pair found per output. */
|
|
1700
|
+
type Found = Map<string, () => UserError>
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* An output git ignores is no overlap (M, 2026-10-15): a key reads only what
|
|
1704
|
+
* git lists, so such an output never reaches it. Asked of git once, for
|
|
1705
|
+
* every overlapped output together; an output git cannot speak for (no work
|
|
1706
|
+
* tree, a glob no few paths stand for) is refused as before. The answer
|
|
1707
|
+
* holds for the lock; a run's stability gate asks git again for itself.
|
|
1708
|
+
*/
|
|
1709
|
+
function refuseUnlessIgnored(found: Found, workspaceRoot: string | undefined): void {
|
|
1710
|
+
if (found.size === 0) return
|
|
1711
|
+
if (workspaceRoot !== undefined) {
|
|
1712
|
+
const asks = [...found.keys()].map(ignoreProbes)
|
|
1713
|
+
const paths = asks.flatMap((a) => a?.paths ?? [])
|
|
1714
|
+
const ignored = checkIgnoreSync(workspaceRoot, paths)
|
|
1715
|
+
let listed: string[] | null | undefined
|
|
1716
|
+
const pass = wholeIgnored(found.keys(), ignored, (g) => {
|
|
1717
|
+
listed ??= listedSync(workspaceRoot, [
|
|
1718
|
+
...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
|
|
1719
|
+
])
|
|
1720
|
+
return listed === null || listed.some((f) => taskGlob(g).match(f))
|
|
1721
|
+
})
|
|
1722
|
+
for (const [g, refuse] of found) if (!pass.has(g)) throw refuse()
|
|
1723
|
+
return
|
|
1663
1724
|
}
|
|
1725
|
+
throw found.values().next().value!()
|
|
1664
1726
|
}
|
|
1665
1727
|
|
|
1666
1728
|
/**
|
|
@@ -1707,6 +1769,7 @@ function takenBack(
|
|
|
1707
1769
|
|
|
1708
1770
|
function readsOutputs(
|
|
1709
1771
|
memo: TakeBackMemo,
|
|
1772
|
+
found: Found,
|
|
1710
1773
|
x: Side,
|
|
1711
1774
|
y: Side,
|
|
1712
1775
|
field: 'files' | 'workspaceFiles',
|
|
@@ -1715,21 +1778,28 @@ function readsOutputs(
|
|
|
1715
1778
|
const [reader, writer] = x.reads ? [x, y] : [y, x]
|
|
1716
1779
|
const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
|
|
1717
1780
|
for (const go of writer.globs) {
|
|
1718
|
-
|
|
1781
|
+
const rooted =
|
|
1782
|
+
writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
|
|
1783
|
+
if (found.has(rooted) || takenBack(memo, reader, field, go)) continue
|
|
1719
1784
|
for (const gi of reader.globs) {
|
|
1720
1785
|
if (!outputsOverlap(gi, go)) continue
|
|
1721
1786
|
const shown =
|
|
1722
1787
|
writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
|
|
1723
1788
|
const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1789
|
+
found.set(
|
|
1790
|
+
rooted,
|
|
1791
|
+
() =>
|
|
1792
|
+
new UserError(
|
|
1793
|
+
`${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
|
|
1794
|
+
`${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
|
|
1795
|
+
`another task's outputs (the dependency's key already cascades through dependsOn). ` +
|
|
1796
|
+
`Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
|
|
1797
|
+
`cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
|
|
1798
|
+
`let it wait for its producer.`,
|
|
1799
|
+
'VX_E_INPUT_IS_OUTPUT',
|
|
1800
|
+
),
|
|
1732
1801
|
)
|
|
1802
|
+
break
|
|
1733
1803
|
}
|
|
1734
1804
|
}
|
|
1735
1805
|
}
|