@vzn/vx 0.0.638 → 0.0.640
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 +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- 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 +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- 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 +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- 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 +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- 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 +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- 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/paths.ts +1 -1
- 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/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- 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/watch.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// The cache does the heavy lifting: re-runs are typically cache hits
|
|
9
9
|
// (input hash matches), so spurious events from edits outside any
|
|
10
|
-
// task's `
|
|
10
|
+
// task's `inputs.files` cost ~tens of ms of orchestrator
|
|
11
11
|
// overhead. We deliberately don't try to filter events through the
|
|
12
12
|
// per-task input globs — the cache key is the source of truth, and
|
|
13
13
|
// filtering would mean re-doing the glob/boundary work on every event.
|
|
@@ -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
|
}
|
|
@@ -284,6 +279,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
284
279
|
outputs: swept.outputs,
|
|
285
280
|
inputs: swept.inputs,
|
|
286
281
|
uncached: swept.uncached,
|
|
282
|
+
namedIgnored: swept.namedIgnored,
|
|
287
283
|
configImports: swept.configImports,
|
|
288
284
|
workspaceConfigImports: swept.workspaceConfigImports,
|
|
289
285
|
fenceDirs: fenceDirs(allProjects),
|
|
@@ -306,6 +302,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
306
302
|
outputs: sweep.outputs,
|
|
307
303
|
inputs: sweep.inputs,
|
|
308
304
|
uncached: sweep.uncached,
|
|
305
|
+
namedIgnored: sweep.namedIgnored,
|
|
309
306
|
configImports: sweep.configImports,
|
|
310
307
|
workspaceConfigImports: sweep.workspaceConfigImports,
|
|
311
308
|
fenceDirs: fenceDirs(all),
|
|
@@ -356,6 +353,8 @@ interface WatchLoopArgs {
|
|
|
356
353
|
inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>>
|
|
357
354
|
/** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
|
|
358
355
|
uncached: ReadonlySet<string>
|
|
356
|
+
/** Paths a task's inputs name outright: a git-ignored one there is still an edit. */
|
|
357
|
+
namedIgnored: readonly string[]
|
|
359
358
|
/** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
|
|
360
359
|
configImports: readonly string[]
|
|
361
360
|
/** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
|
|
@@ -384,6 +383,7 @@ interface Rediscovered {
|
|
|
384
383
|
outputs: ReadonlyMap<string, readonly string[]>
|
|
385
384
|
inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>>
|
|
386
385
|
uncached: ReadonlySet<string>
|
|
386
|
+
namedIgnored: readonly string[]
|
|
387
387
|
configImports: readonly string[]
|
|
388
388
|
workspaceConfigImports: readonly string[]
|
|
389
389
|
fenceDirs: readonly string[]
|
|
@@ -399,6 +399,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
399
399
|
let outputs = args.outputs
|
|
400
400
|
let inputs = args.inputs
|
|
401
401
|
let uncached = args.uncached
|
|
402
|
+
let namedIgnored = args.namedIgnored
|
|
402
403
|
let claimedRootFiles = args.claimedRootFiles
|
|
403
404
|
let configImportFiles = args.configImports
|
|
404
405
|
let wsConfigImportFiles = args.workspaceConfigImports
|
|
@@ -572,6 +573,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
572
573
|
armedAt,
|
|
573
574
|
held: () => held !== undefined,
|
|
574
575
|
uncached: () => uncached,
|
|
576
|
+
namedIgnored: () => namedIgnored,
|
|
575
577
|
fenced: (ownDir, abs) => fenced(ownDir, abs),
|
|
576
578
|
...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
|
|
577
579
|
})
|
|
@@ -626,6 +628,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
626
628
|
}
|
|
627
629
|
}
|
|
628
630
|
const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
|
|
631
|
+
/** The projects the workspace-wide arm names an event by. */
|
|
632
|
+
let wide: readonly ProjectMeta[] = []
|
|
633
|
+
// The deepest, as a nested project's file is its own.
|
|
634
|
+
const ownerOf = (abs: string): ProjectMeta | undefined => {
|
|
635
|
+
let best: ProjectMeta | undefined
|
|
636
|
+
for (const proj of wide)
|
|
637
|
+
if (
|
|
638
|
+
abs.startsWith(proj.dir + path.sep) &&
|
|
639
|
+
(best === undefined || proj.dir.length > best.dir.length)
|
|
640
|
+
)
|
|
641
|
+
best = proj
|
|
642
|
+
return best
|
|
643
|
+
}
|
|
629
644
|
/** The root arm: recursive when workspace-wide or the one stream, else the root's own files only. */
|
|
630
645
|
let rootArm: WatchHandle = CLOSED
|
|
631
646
|
let dotVxArm: WatchHandle = CLOSED
|
|
@@ -634,7 +649,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
634
649
|
if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
|
|
635
650
|
// A module the config imports from inside the project is the config
|
|
636
651
|
// too: its edit can add an output or a `workspaceFiles` input.
|
|
637
|
-
if (shapesWatchedSet(filename) || configImportFiles.includes(abs)
|
|
652
|
+
if (shapesWatchedSet(filename) || configImportFiles.includes(abs) || linkChanged(abs))
|
|
653
|
+
reread = true
|
|
638
654
|
trigger(`${proj.name} ${filename}`, abs)
|
|
639
655
|
}
|
|
640
656
|
const armProject = (proj: ProjectMeta): void => {
|
|
@@ -700,7 +716,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
700
716
|
configImportFiles.includes(abs)
|
|
701
717
|
)
|
|
702
718
|
reread = true
|
|
703
|
-
|
|
719
|
+
const owner = ownerOf(abs)
|
|
720
|
+
trigger(
|
|
721
|
+
owner === undefined
|
|
722
|
+
? `root ${filename}`
|
|
723
|
+
: `${owner.name} ${path.relative(owner.dir, abs)}`,
|
|
724
|
+
abs,
|
|
725
|
+
)
|
|
704
726
|
})
|
|
705
727
|
: oneStream
|
|
706
728
|
? arm(workspaceRoot, true, streamEvent)
|
|
@@ -725,6 +747,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
725
747
|
// (it would cover every project + node_modules + caches). On macOS one
|
|
726
748
|
// recursive stream at the root stands for all of them (`oneStream`).
|
|
727
749
|
const armMode = (watched: readonly ProjectMeta[]): void => {
|
|
750
|
+
wide = workspaceWide ? watched : []
|
|
728
751
|
if (!workspaceWide) {
|
|
729
752
|
streamed = oneStream ? toStream(watched) : []
|
|
730
753
|
const inStream = new Set(streamed.map((s) => s.proj))
|
|
@@ -801,6 +824,65 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
801
824
|
armAncestors()
|
|
802
825
|
}
|
|
803
826
|
|
|
827
|
+
/**
|
|
828
|
+
* What each symlink under a watched project points to: the key reads it,
|
|
829
|
+
* wherever it lies (cache/linked-inputs.ts), and the project's arm hears
|
|
830
|
+
* only the link. A file target's directory, non-recursive, or a directory
|
|
831
|
+
* target, recursive; each event names the link. Links are read again when
|
|
832
|
+
* one comes or goes (`projectEvent`).
|
|
833
|
+
*/
|
|
834
|
+
const linkArms = new Map<string, { target: string; handle: WatchHandle }>()
|
|
835
|
+
let links = atArm?.links ?? new Set<string>()
|
|
836
|
+
let watched = projects
|
|
837
|
+
/** A link came, went or moved: the set `armLinks` reads is stale. */
|
|
838
|
+
const linkChanged = (abs: string): boolean => {
|
|
839
|
+
if (links.has(abs)) return true
|
|
840
|
+
try {
|
|
841
|
+
return fs.lstatSync(abs).isSymbolicLink()
|
|
842
|
+
} catch {
|
|
843
|
+
return false
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
const armLinks = (): void => {
|
|
847
|
+
const want = new Map<string, { proj: ProjectMeta; real: string; dir: boolean }>()
|
|
848
|
+
for (const link of links) {
|
|
849
|
+
let proj: ProjectMeta | undefined
|
|
850
|
+
for (const p of watched)
|
|
851
|
+
if (
|
|
852
|
+
link.startsWith(p.dir + path.sep) &&
|
|
853
|
+
(proj === undefined || p.dir.length > proj.dir.length)
|
|
854
|
+
)
|
|
855
|
+
proj = p
|
|
856
|
+
if (proj === undefined) continue
|
|
857
|
+
try {
|
|
858
|
+
const real = fs.realpathSync(link)
|
|
859
|
+
want.set(link, { proj, real, dir: fs.statSync(real).isDirectory() })
|
|
860
|
+
} catch {
|
|
861
|
+
// Dangling: its target string is all the key reads, and the link's own arm hears that.
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
for (const [link, armed] of linkArms) {
|
|
865
|
+
if (want.get(link)?.real === armed.target) continue
|
|
866
|
+
armed.handle.close()
|
|
867
|
+
linkArms.delete(link)
|
|
868
|
+
}
|
|
869
|
+
for (const [link, { proj, real, dir }] of want) {
|
|
870
|
+
if (linkArms.has(link)) continue
|
|
871
|
+
const name = path.basename(real)
|
|
872
|
+
const label = `${proj.name} ${path.relative(proj.dir, link)}`
|
|
873
|
+
try {
|
|
874
|
+
const handle = arm(dir ? real : path.dirname(real), dir, (filename) => {
|
|
875
|
+
if (!dir && filename !== name) return
|
|
876
|
+
trigger(label, link)
|
|
877
|
+
})
|
|
878
|
+
linkArms.set(link, { target: real, handle })
|
|
879
|
+
changes.seed(link)
|
|
880
|
+
} catch (err) {
|
|
881
|
+
sayCannot(`cannot watch ${real}`, err)
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
804
886
|
// Said once, when the loop first arms: a re-arm repeated it after every
|
|
805
887
|
// cycle that re-read the watched set, between task rows. `debug.watch`
|
|
806
888
|
// says each arm on stderr, for whoever needs to see the set change.
|
|
@@ -828,6 +910,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
828
910
|
outputs = next.outputs
|
|
829
911
|
inputs = next.inputs
|
|
830
912
|
uncached = next.uncached
|
|
913
|
+
namedIgnored = next.namedIgnored
|
|
831
914
|
claimedRootFiles = next.claimedRootFiles
|
|
832
915
|
configImportFiles = next.configImports
|
|
833
916
|
wsConfigImportFiles = next.workspaceConfigImports
|
|
@@ -861,6 +944,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
861
944
|
for (const proj of own) if (!perProject.has(proj.dir)) armProject(proj)
|
|
862
945
|
}
|
|
863
946
|
armImports()
|
|
947
|
+
watched = next.projects
|
|
948
|
+
links = gitFiles(workspaceRoot)?.links ?? links
|
|
949
|
+
armLinks()
|
|
864
950
|
// A new arm proves delivery like the first ones: an edit in the new
|
|
865
951
|
// project right after this cycle is seen, not lost in the gap.
|
|
866
952
|
await pool.proved()
|
|
@@ -943,6 +1029,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
|
|
|
943
1029
|
}
|
|
944
1030
|
armAncestors()
|
|
945
1031
|
armImports()
|
|
1032
|
+
armLinks()
|
|
946
1033
|
|
|
947
1034
|
// The orchestrator's own writes never kick the loop: `makeWatchIgnore`
|
|
948
1035
|
// closes over the RESOLVED cache dir (relocated or not) and the tasks'
|
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'
|
|
@@ -36,8 +36,10 @@ interface WhyArgs {
|
|
|
36
36
|
* `tests/why.test.ts`.
|
|
37
37
|
*/
|
|
38
38
|
export const WHAT_TO_DO: Readonly<Record<string, string>> = {
|
|
39
|
-
file: 'an edit re-runs by design; a file the task does not read belongs out of
|
|
40
|
-
|
|
39
|
+
file: 'an edit re-runs by design; a file the task does not read belongs out of inputs.files',
|
|
40
|
+
linked:
|
|
41
|
+
'what the symlink points to changed (the file, or a file under the directory); an edit there re-runs by design',
|
|
42
|
+
env: "the variable's value moved; one the task does not read belongs out of inputs.env",
|
|
41
43
|
runtime: 'the command printed something else; drop timestamps and paths from its output',
|
|
42
44
|
'ws-runtime':
|
|
43
45
|
'the workspace command printed something else; drop timestamps and paths from its output',
|
|
@@ -372,4 +374,4 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
372
374
|
const ROOTS_SHOWN = 5
|
|
373
375
|
|
|
374
376
|
const WRITTEN_TODO =
|
|
375
|
-
'a task that writes a file a key reads re-keys it every run; give that write its own task with the file as an output, or keep the file out of
|
|
377
|
+
'a task that writes a file a key reads re-keys it every run; give that write its own task with the file as an output, or keep the file out of inputs.files'
|
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
|
|
@@ -116,6 +110,12 @@ export interface CacheSettings {
|
|
|
116
110
|
inputStability?: InputStability
|
|
117
111
|
/** The `cache.outputs.clean` of every task that does not set its own. */
|
|
118
112
|
outputs?: { clean?: boolean }
|
|
113
|
+
/**
|
|
114
|
+
* Which runs keep a failed task's log and the files its outputs left in
|
|
115
|
+
* the remote: CI runs (`'ci'`, the default), runs off CI (`'local'`), or
|
|
116
|
+
* both (`'always'`). Only a run whose policy writes a remote keeps any.
|
|
117
|
+
*/
|
|
118
|
+
upload?: { failures?: 'ci' | 'local' | 'always' }
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
/** `WorkspaceConfig.lock`. */
|
|
@@ -143,12 +143,6 @@ export interface WorkspaceRules {
|
|
|
143
143
|
* overlapping tasks with no edge are refused either way.
|
|
144
144
|
*/
|
|
145
145
|
exclusiveOutputs?: boolean
|
|
146
|
-
/**
|
|
147
|
-
* Refuse a task whose input globs can match another task's declared
|
|
148
|
-
* outputs, so every key is derived before any task runs. Off, such a key
|
|
149
|
-
* waits for its producer (`caching.md` § Local restore tier).
|
|
150
|
-
*/
|
|
151
|
-
upfrontKeys?: boolean
|
|
152
146
|
/**
|
|
153
147
|
* Which workspace projects a project may depend on, checked by `vx lock`
|
|
154
148
|
* over each project's direct dependencies (its manifest's and its tasks'
|
|
@@ -304,12 +298,18 @@ export interface TaskConfig {
|
|
|
304
298
|
* - `'pkg#name'` — specific package's `name` task; not key-checked.
|
|
305
299
|
*/
|
|
306
300
|
dependsOn?: readonly string[]
|
|
301
|
+
/**
|
|
302
|
+
* What the task's key folds, whether or not it caches: its dependents
|
|
303
|
+
* fold that key. A task with `exec` and no `inputs` has no key, so every
|
|
304
|
+
* task that folds it runs and saves nothing. A group with `inputs` is a
|
|
305
|
+
* keyed group: its inputs are a key its dependents fold, with no process.
|
|
306
|
+
*/
|
|
307
|
+
inputs?: InputsConfig
|
|
307
308
|
/**
|
|
308
309
|
* Caching configuration. **Caching is opt-in.** If this field is omitted,
|
|
309
310
|
* the task always runs and nothing is read from or written to the cache.
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
* runs.
|
|
311
|
+
* Requires `exec` and `inputs`: the key is the task's inputs, and a group
|
|
312
|
+
* runs nothing to cache.
|
|
313
313
|
*/
|
|
314
314
|
cache?: CacheConfig
|
|
315
315
|
}
|
|
@@ -330,7 +330,7 @@ export interface TaskConfig {
|
|
|
330
330
|
* `node_modules`, and through it the real path of every workspace
|
|
331
331
|
* package linked there.
|
|
332
332
|
*
|
|
333
|
-
* `cache` grants no access in either direction: `
|
|
333
|
+
* `cache` grants no access in either direction: `inputs` says what
|
|
334
334
|
* INVALIDATES a task, `allow` says what it may TOUCH, and deriving one
|
|
335
335
|
* from the other coupled them both ways — a declaration added for
|
|
336
336
|
* caching silently widened the sandbox, and a path the task needed had
|
|
@@ -404,7 +404,7 @@ export interface SandboxIgnore {
|
|
|
404
404
|
export interface SandboxGrants {
|
|
405
405
|
/**
|
|
406
406
|
* Readable paths or globs, added to the baseline — which is
|
|
407
|
-
* `node_modules`, NOT `
|
|
407
|
+
* `node_modules`, NOT `inputs.files`. A task that reads its own
|
|
408
408
|
* source in the sandbox says so here.
|
|
409
409
|
*/
|
|
410
410
|
read?: readonly string[]
|
|
@@ -579,8 +579,6 @@ export interface ExecEnv {
|
|
|
579
579
|
}
|
|
580
580
|
|
|
581
581
|
export interface CacheConfig {
|
|
582
|
-
/** What participates in the cache key. */
|
|
583
|
-
inputs: CacheInputs
|
|
584
582
|
/** What this task produces. Captured for restore on a cache hit. */
|
|
585
583
|
outputs: CacheOutputs
|
|
586
584
|
/**
|
|
@@ -640,7 +638,7 @@ export interface CacheOutputs {
|
|
|
640
638
|
clean?: boolean
|
|
641
639
|
}
|
|
642
640
|
|
|
643
|
-
export interface
|
|
641
|
+
export interface InputsConfig {
|
|
644
642
|
/**
|
|
645
643
|
* Project-relative globs. Each entry is a positive glob, or a negation
|
|
646
644
|
* prefixed with `!`. Required: pass a recursive glob to mean "all
|
|
@@ -779,8 +777,8 @@ export function defineProject<const T extends ProjectConfig>(
|
|
|
779
777
|
[K in keyof NonNullable<T['tasks']>]?: Known<NonNullable<T['tasks']>[K], TaskConfig> & {
|
|
780
778
|
dependsOn?: readonly DependsOnEntry<Extract<keyof NonNullable<T['tasks']>, string>>[]
|
|
781
779
|
exec?: Known<At<NonNullable<T['tasks']>[K], 'exec'>, ExecConfig>
|
|
780
|
+
inputs?: Known<At<NonNullable<T['tasks']>[K], 'inputs'>, InputsConfig>
|
|
782
781
|
cache?: Known<At<NonNullable<T['tasks']>[K], 'cache'>, CacheConfig> & {
|
|
783
|
-
inputs?: Known<At<At<NonNullable<T['tasks']>[K], 'cache'>, 'inputs'>, CacheInputs>
|
|
784
782
|
outputs?: Known<At<At<NonNullable<T['tasks']>[K], 'cache'>, 'outputs'>, CacheOutputs>
|
|
785
783
|
}
|
|
786
784
|
}
|
package/src/exec/executor.ts
CHANGED
|
@@ -53,13 +53,13 @@ export interface InputFile {
|
|
|
53
53
|
* secrets (cache.db stores only their digests).
|
|
54
54
|
*/
|
|
55
55
|
export interface TaskInputs {
|
|
56
|
-
/** Declared `
|
|
56
|
+
/** Declared `inputs.files` + `workspaceFiles` (own outputs excluded), sorted by path. */
|
|
57
57
|
readonly files: readonly InputFile[]
|
|
58
|
-
/** Declared `
|
|
58
|
+
/** Declared `inputs.env` names with their resolved values; `undefined` when unset. */
|
|
59
59
|
readonly env: ReadonlyArray<{ readonly name: string; readonly value: string | undefined }>
|
|
60
|
-
/** `
|
|
60
|
+
/** `inputs.runtime` commands and the output that was folded — a toolchain expectation. */
|
|
61
61
|
readonly runtime: ReadonlyArray<{ readonly command: string; readonly output: string }>
|
|
62
|
-
/** `
|
|
62
|
+
/** `inputs.workspaceRuntime`, same shape. */
|
|
63
63
|
readonly workspaceRuntime: ReadonlyArray<{ readonly command: string; readonly output: string }>
|
|
64
64
|
/**
|
|
65
65
|
* Dependencies whose cache keys are folded. `outputs` lists each one's
|
|
@@ -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
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Parser for the Turbo/Nx-style task-edge micro-syntax used in
|
|
2
|
-
// `dependsOn` and `
|
|
2
|
+
// `dependsOn` and `inputs.tasks`. Returns a small discriminated
|
|
3
3
|
// union the graph builder + cache filter both consume.
|
|
4
4
|
|
|
5
5
|
export type DependencySpec =
|
|
@@ -32,7 +32,7 @@ export function compileTaskPattern(pattern: string): RegExp {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
|
-
* Parse one `dependsOn` / `
|
|
35
|
+
* Parse one `dependsOn` / `inputs.tasks` entry.
|
|
36
36
|
*
|
|
37
37
|
* `name` → self.name
|
|
38
38
|
* `^name` → every dep workspace's `name` task
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// Which dependencies a task's key folds (`inputs.tasks`), on the graph
|
|
2
|
+
// alone: the hash path, the sandbox's keyed set and `vx lock`'s
|
|
3
|
+
// input-overlap rule read this one selection, so they cannot drift.
|
|
4
|
+
|
|
5
|
+
import { UserError } from '../util/index.js'
|
|
6
|
+
import {
|
|
7
|
+
DependencySpecError,
|
|
8
|
+
compileTaskPattern,
|
|
9
|
+
isTaskPattern,
|
|
10
|
+
parseDependencySpec,
|
|
11
|
+
type DependencySpec,
|
|
12
|
+
} from './dependency-spec.js'
|
|
13
|
+
import type { TaskNode } from './task-graph.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A task is a "group" if it has no `exec` — it exists only to chain
|
|
17
|
+
* `dependsOn` (an umbrella for `vx run ci`). Group tasks:
|
|
18
|
+
* - never spawn a process,
|
|
19
|
+
* - never read/write the cache (it's a config error to declare one),
|
|
20
|
+
* - never appear in the run summary or `runs` analytics table,
|
|
21
|
+
* - render no framed block in the live output.
|
|
22
|
+
* Six call sites used to repeat `node.config.exec === undefined`;
|
|
23
|
+
* centralising the predicate prevents that check from drifting.
|
|
24
|
+
*/
|
|
25
|
+
export function isGroupTask(node: TaskNode): boolean {
|
|
26
|
+
return node.config.exec === undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
|
|
30
|
+
export function keyedDeps(node: TaskNode): readonly string[] {
|
|
31
|
+
const order = node.orderOnly
|
|
32
|
+
return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A filter selects among the task's own dependencies, never through a
|
|
37
|
+
* group: `dependsOn: ['build']` over a group `build` of `build.a` and
|
|
38
|
+
* `build.b` offers the filter `build`, not its members. A pattern that
|
|
39
|
+
* matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
|
|
40
|
+
* with that member's inputs: a stale hit under a green run. Such a spec
|
|
41
|
+
* is refused, once, over the graph every key site reads. One that matches
|
|
42
|
+
* nothing anywhere stays legal (a preset's pattern needn't match in every
|
|
43
|
+
* project). An exact name is refused earlier, at load, unless `dependsOn`
|
|
44
|
+
* names it (`assertFilterNamesDeclaredDeps`).
|
|
45
|
+
*/
|
|
46
|
+
export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
|
|
47
|
+
for (const node of nodes.values()) {
|
|
48
|
+
const filter = node.config.inputs?.tasks
|
|
49
|
+
if (filter === undefined || filter.length === 0) continue
|
|
50
|
+
const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
|
|
51
|
+
const groups = deps.filter(isGroupTask)
|
|
52
|
+
if (groups.length === 0) continue
|
|
53
|
+
for (const raw of filter) {
|
|
54
|
+
const spec = parseSpec(raw, node.id)
|
|
55
|
+
if (spec.negated) continue
|
|
56
|
+
const matches = specMatcher(spec)
|
|
57
|
+
const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
|
|
58
|
+
if (deps.some(hit)) continue
|
|
59
|
+
for (const group of groups) {
|
|
60
|
+
const member = groupMembers(group, nodes).find(hit)
|
|
61
|
+
if (member === undefined) continue
|
|
62
|
+
throw new UserError(
|
|
63
|
+
`${node.id}: inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function parseSpec(raw: string, selfTaskId: string): DependencySpec {
|
|
71
|
+
try {
|
|
72
|
+
return parseDependencySpec(raw)
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err instanceof DependencySpecError) {
|
|
75
|
+
throw new UserError(`${selfTaskId}: inputs.tasks: ${err.message}`)
|
|
76
|
+
}
|
|
77
|
+
throw err
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Every task a group stands for, through nested groups, each once. */
|
|
82
|
+
function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
|
|
83
|
+
const out: TaskNode[] = []
|
|
84
|
+
const seen = new Set<string>([group.id])
|
|
85
|
+
const stack = group.deps.toReversed()
|
|
86
|
+
while (stack.length > 0) {
|
|
87
|
+
const id = stack.pop()!
|
|
88
|
+
if (seen.has(id)) continue
|
|
89
|
+
seen.add(id)
|
|
90
|
+
const n = nodes.get(id)
|
|
91
|
+
if (n === undefined) continue
|
|
92
|
+
out.push(n)
|
|
93
|
+
if (isGroupTask(n)) stack.push(...n.deps.toReversed())
|
|
94
|
+
}
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One dependency as the key fold sees it: the task, and the value the
|
|
100
|
+
* fold dedups by — its hash on the hash path, a structural stand-in for
|
|
101
|
+
* the hash on the graph (`foldUnit` in keyed-projects.ts).
|
|
102
|
+
*/
|
|
103
|
+
export interface FoldCandidate {
|
|
104
|
+
node: TaskNode
|
|
105
|
+
unit: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The dependencies a task's key folds, per its `inputs.tasks`.
|
|
110
|
+
*
|
|
111
|
+
* Patterns (Turbo/Nx micro-syntax + filter extensions):
|
|
112
|
+
* '*' all same-project upstream
|
|
113
|
+
* '^*' all dep-workspace upstream
|
|
114
|
+
* 'name' same-project task `name`
|
|
115
|
+
* '^name' `name` task in every dep workspace
|
|
116
|
+
* 'pkg#name' specific package's `name` task
|
|
117
|
+
* 'name.*' patterns — EITHER half of any form above may contain `*`
|
|
118
|
+
* (same glob as dependsOn patterns), including the project
|
|
119
|
+
* half of `pkg#name`. A filter that matched literally here
|
|
120
|
+
* would silently select ZERO upstream hashes and decouple the
|
|
121
|
+
* task from its dependencies — a stale-hit trap, so every
|
|
122
|
+
* name is matched through the shared glob.
|
|
123
|
+
* '!<form>' exclude — any of the above with a leading `!`
|
|
124
|
+
*
|
|
125
|
+
* Patterns are applied in order; last write wins, so
|
|
126
|
+
* `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
|
|
127
|
+
*
|
|
128
|
+
* Defaults:
|
|
129
|
+
* - `filter === undefined` → every candidate, as given.
|
|
130
|
+
* - `filter === []` → none (fully decoupled).
|
|
131
|
+
*
|
|
132
|
+
* Otherwise deduped by `unit`, first candidate kept. The unit is the
|
|
133
|
+
* fold's own identity, not the task's: two groups over the same members
|
|
134
|
+
* share one hash, so excluding either excludes both, and a selection by
|
|
135
|
+
* task id would keep the other while the key folded neither.
|
|
136
|
+
*/
|
|
137
|
+
export function selectFoldedDeps(
|
|
138
|
+
deps: readonly FoldCandidate[],
|
|
139
|
+
filter: readonly string[] | undefined,
|
|
140
|
+
selfProjectName: string,
|
|
141
|
+
selfTaskId: string,
|
|
142
|
+
): FoldCandidate[] {
|
|
143
|
+
if (filter === undefined) return [...deps]
|
|
144
|
+
|
|
145
|
+
const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
|
|
146
|
+
|
|
147
|
+
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
148
|
+
const matchers = specs.map((spec) => specMatcher(spec))
|
|
149
|
+
|
|
150
|
+
const selected = new Map<string, FoldCandidate>()
|
|
151
|
+
for (let i = 0; i < specs.length; i++) {
|
|
152
|
+
const spec = specs[i]!
|
|
153
|
+
const matches = matchers[i]!
|
|
154
|
+
for (const d of deps) {
|
|
155
|
+
const isSelf = d.node.projectName === selfProjectName
|
|
156
|
+
if (!matches(d.node, isSelf)) continue
|
|
157
|
+
if (spec.negated) selected.delete(d.unit)
|
|
158
|
+
else if (!selected.has(d.unit)) selected.set(d.unit, d)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return [...selected.values()]
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
|
|
165
|
+
function nameMatcher(name: string): (candidate: string) => boolean {
|
|
166
|
+
if (isTaskPattern(name)) {
|
|
167
|
+
const re = compileTaskPattern(name)
|
|
168
|
+
return (candidate) => re.test(candidate)
|
|
169
|
+
}
|
|
170
|
+
return (candidate) => candidate === name
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
|
|
175
|
+
* form glob: a filter only ever selects from upstreams that already exist,
|
|
176
|
+
* so a package pattern is unambiguous here — unlike dependsOn, which must
|
|
177
|
+
* materialize concrete edges and therefore rejects it.
|
|
178
|
+
*/
|
|
179
|
+
function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
|
|
180
|
+
switch (spec.kind) {
|
|
181
|
+
case 'wildcardSelf':
|
|
182
|
+
return (_n, isSelf) => isSelf
|
|
183
|
+
case 'wildcardDeps':
|
|
184
|
+
return (_n, isSelf) => !isSelf
|
|
185
|
+
case 'self': {
|
|
186
|
+
const task = nameMatcher(spec.task)
|
|
187
|
+
return (n, isSelf) => isSelf && task(n.taskName)
|
|
188
|
+
}
|
|
189
|
+
case 'deps': {
|
|
190
|
+
const task = nameMatcher(spec.task)
|
|
191
|
+
return (n, isSelf) => !isSelf && task(n.taskName)
|
|
192
|
+
}
|
|
193
|
+
case 'cross': {
|
|
194
|
+
const project = nameMatcher(spec.project)
|
|
195
|
+
const task = nameMatcher(spec.task)
|
|
196
|
+
return (n) => project(n.projectName) && task(n.taskName)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The dependencies `node`'s key folds, per the hash path's rules above, as
|
|
203
|
+
* `keyUpstream` hands them to the key: the scheduled ones less the edges
|
|
204
|
+
* `--exclude-dependencies` left for order alone, plus the dropped ones it
|
|
205
|
+
* keys all the same (`excludedUpstream`), whose own dependencies the walk
|
|
206
|
+
* reads from the run's `keyOnly` map. `node.deps` held the order-only
|
|
207
|
+
* edges and lacked the dropped ones: the sandbox granted a sibling the key
|
|
208
|
+
* does not answer for.
|
|
209
|
+
*/
|
|
210
|
+
export function foldedDeps(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate[] {
|
|
211
|
+
const candidates: FoldCandidate[] = []
|
|
212
|
+
for (const id of keyedDeps(node)) {
|
|
213
|
+
const dep = nodeOf(id)
|
|
214
|
+
candidates.push({ node: dep, unit: foldUnit(dep) })
|
|
215
|
+
}
|
|
216
|
+
for (const { node: dep } of node.excludedUpstream ?? []) {
|
|
217
|
+
candidates.push({ node: dep, unit: foldUnit(dep) })
|
|
218
|
+
}
|
|
219
|
+
if (isGroupTask(node) && node.config.inputs === undefined) return candidates
|
|
220
|
+
return selectFoldedDeps(candidates, node.config.inputs?.tasks, node.projectName, node.id)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* What stands in for a dependency's hash in the dedup: equal exactly when
|
|
225
|
+
* the hashes are. An exec task's key folds its own id (`task:<id>`), so its
|
|
226
|
+
* id is enough; a group's hash is a roll-up of its members' ids and hashes
|
|
227
|
+
* with no id of its own, so two groups over the same members hash alike.
|
|
228
|
+
* The members are `keyUpstream`'s, which `computeGroupHash` is handed: under
|
|
229
|
+
* `--exclude-dependencies` `node.deps` holds order-only edges and lacks the
|
|
230
|
+
* dropped ones, and two groups the key told apart shared a unit.
|
|
231
|
+
*/
|
|
232
|
+
function foldUnit(node: TaskNode): string {
|
|
233
|
+
if (!isGroupTask(node) || node.config.inputs !== undefined) return `task|${node.id}`
|
|
234
|
+
const members = [...keyedDeps(node), ...(node.excludedUpstream ?? []).map((u) => u.node.id)]
|
|
235
|
+
return `group|${members.sort().join('|')}`
|
|
236
|
+
}
|