@vzn/vx 0.0.639 → 0.0.641
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 +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/handoff.ts +5 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +66 -27
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -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/src/workspace/project-loader.ts +5 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lstatSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import type { TaskConfig
|
|
3
|
+
import type { TaskConfig } from '../config.js'
|
|
4
4
|
import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
|
|
5
5
|
import {
|
|
6
6
|
ABSENT_INPUT,
|
|
@@ -85,11 +85,12 @@ export interface ComputeHashArgs {
|
|
|
85
85
|
* previews.
|
|
86
86
|
*
|
|
87
87
|
* Caller is responsible for handling the group-task case before
|
|
88
|
-
* calling this —
|
|
88
|
+
* calling this — an unkeyed group has no `exec` and no `inputs`, so the
|
|
89
89
|
* key-derivation steps below would all fall back to defaults.
|
|
90
90
|
* `computeGroupKey` is exported for that purpose.
|
|
91
91
|
*/
|
|
92
92
|
export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
93
|
+
if (isKeyless(args.node.config)) return keylessHash(args.node.id)
|
|
93
94
|
const end = span('task hash')
|
|
94
95
|
try {
|
|
95
96
|
return await args.cache.key((await resolveKeyInput(args)).input)
|
|
@@ -98,6 +99,27 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
98
99
|
}
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* A task that runs a command and declares no `inputs`: nothing says what
|
|
104
|
+
* it read, so no key can stand for its outputs, and a key that folds it
|
|
105
|
+
* would replay a stale entry (owner, 2026-10-16).
|
|
106
|
+
*/
|
|
107
|
+
export function isKeyless(task: TaskConfig): boolean {
|
|
108
|
+
return task.exec !== undefined && task.inputs === undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Drawn once per process, so no run derives another run's keyless keys;
|
|
113
|
+
* on first use, as the browser playground loads this module with no `crypto`.
|
|
114
|
+
*/
|
|
115
|
+
let keylessSalt: string | undefined
|
|
116
|
+
|
|
117
|
+
/** A keyless task's key: this run's alone, so every key folding it misses. */
|
|
118
|
+
function keylessHash(taskId: string): string {
|
|
119
|
+
keylessSalt ??= crypto.randomUUID()
|
|
120
|
+
return xxh3hex(`keyless\0${keylessSalt}\0${taskId}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
101
123
|
/**
|
|
102
124
|
* One file the key folded: the digest it folded, and since when (ms since
|
|
103
125
|
* the epoch) that digest is known to be the file's content — the
|
|
@@ -250,14 +272,14 @@ async function resolveKeyInput(
|
|
|
250
272
|
args: ComputeHashArgs,
|
|
251
273
|
): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
|
|
252
274
|
const cfg = args.node.config
|
|
253
|
-
const cacheCfg
|
|
275
|
+
const cacheCfg = cfg.cache
|
|
254
276
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
255
277
|
|
|
256
278
|
const resolved = await resolveInputs({
|
|
257
279
|
projectDir: args.node.projectDir,
|
|
258
280
|
workspaceRoot: args.workspaceRoot,
|
|
259
281
|
envSource: process.env,
|
|
260
|
-
inputs:
|
|
282
|
+
inputs: cfg.inputs,
|
|
261
283
|
ownOutputs: outputs,
|
|
262
284
|
ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
|
|
263
285
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
@@ -282,7 +304,7 @@ async function resolveKeyInput(
|
|
|
282
304
|
|
|
283
305
|
const upstreamPairs = filterUpstreamHashes(
|
|
284
306
|
args.upstream,
|
|
285
|
-
|
|
307
|
+
cfg.inputs?.tasks,
|
|
286
308
|
args.node.projectName,
|
|
287
309
|
args.node.id,
|
|
288
310
|
)
|
|
@@ -290,7 +312,7 @@ async function resolveKeyInput(
|
|
|
290
312
|
// hash → upstream task id, for capture-row naming only (not folded).
|
|
291
313
|
const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
|
|
292
314
|
// The DEPENDENCY closure, groups expanded — for the executor's input root.
|
|
293
|
-
// Deliberately NOT the filtered set above: `
|
|
315
|
+
// Deliberately NOT the filtered set above: `inputs.tasks` says which
|
|
294
316
|
// upstream KEYS this task's key folds, and what a task may READ is
|
|
295
317
|
// `dependsOn`. Locally every dependency's outputs are on disk before the
|
|
296
318
|
// command runs however the filter is written, so an executor that ships the
|
|
@@ -306,7 +328,7 @@ async function resolveKeyInput(
|
|
|
306
328
|
// also merge the workspace-wide partition's OIDs (keyed by abs
|
|
307
329
|
// path, so the two maps agree wherever they overlap).
|
|
308
330
|
let fileHashes = args.gitFilesCache?.oidsFor(args.node.projectDir)
|
|
309
|
-
if ((
|
|
331
|
+
if ((cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
|
|
310
332
|
const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
311
333
|
if (wsOids !== undefined && wsOids !== fileHashes) {
|
|
312
334
|
fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
|
|
@@ -390,12 +412,12 @@ function hashableConfig(cfg: TaskConfig): unknown {
|
|
|
390
412
|
}
|
|
391
413
|
|
|
392
414
|
/**
|
|
393
|
-
* A group's key: its upstream, and for a keyed group — one with `
|
|
415
|
+
* A group's key: its upstream, and for a keyed group — one with `inputs`,
|
|
394
416
|
* the default `build` (projects.ts) or a config's — its own inputs too,
|
|
395
417
|
* the way a task's key folds them.
|
|
396
418
|
*/
|
|
397
419
|
export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
|
|
398
|
-
if (args.node.config.
|
|
420
|
+
if (args.node.config.inputs === undefined) return computeGroupHash(args.upstream)
|
|
399
421
|
return computeTaskHash(args)
|
|
400
422
|
}
|
|
401
423
|
|
|
@@ -27,8 +27,8 @@ import type { VxPlugin } from './plugin.js'
|
|
|
27
27
|
export interface TelemetryHandle {
|
|
28
28
|
/** Emit the per-run summary to every sink (crash-isolated). */
|
|
29
29
|
emitSummary(summary: RunSummaryRecord): void
|
|
30
|
-
/** Await every sink's flush (crash-isolated, time-bounded by the sink). */
|
|
31
|
-
flush(): Promise<void>
|
|
30
|
+
/** Await every sink's flush (crash-isolated, time-bounded by the sink), each as a `<plugin>:telemetry` step. */
|
|
31
|
+
flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
|
|
32
32
|
/** Remove the bus subscription. Idempotent. */
|
|
33
33
|
dispose(): void
|
|
34
34
|
/** Sample a running task's process tree; see `TelemetrySource.track`. */
|
|
@@ -125,7 +125,7 @@ export async function subscribeTelemetry(
|
|
|
125
125
|
const dispose = bus.subscribe(source.subscriber)
|
|
126
126
|
return {
|
|
127
127
|
emitSummary: (summary) => source.emitSummary(summary),
|
|
128
|
-
flush: () => source.flush(),
|
|
128
|
+
flush: (step) => source.flush(step),
|
|
129
129
|
dispose,
|
|
130
130
|
...(source.track !== undefined ? { track: source.track } : {}),
|
|
131
131
|
}
|
|
@@ -391,8 +391,11 @@ export interface TelemetrySource {
|
|
|
391
391
|
readonly subscriber: RunEventSubscriber
|
|
392
392
|
/** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
|
|
393
393
|
emitSummary(summary: RunSummaryRecord): void
|
|
394
|
-
/**
|
|
395
|
-
|
|
394
|
+
/**
|
|
395
|
+
* Await every sink's `flush()` (each crash-isolated, all time-bounded),
|
|
396
|
+
* each as the step `<plugin>:telemetry` through `step`.
|
|
397
|
+
*/
|
|
398
|
+
flush(step?: <T>(id: string, work: () => Promise<T>) => Promise<T>): Promise<void>
|
|
396
399
|
/**
|
|
397
400
|
* Sample this task's process tree every `SAMPLE_MS` until the returned
|
|
398
401
|
* function is called, the root exits, or the run ends. Present only when
|
|
@@ -625,7 +628,7 @@ export function createTelemetrySource(args: {
|
|
|
625
628
|
}
|
|
626
629
|
}
|
|
627
630
|
},
|
|
628
|
-
async flush(): Promise<
|
|
631
|
+
async flush(step = <T>(_id: string, work: () => Promise<T>): Promise<T> => work()) {
|
|
629
632
|
const ms = teardownTimeoutMs()
|
|
630
633
|
// Bounded, for the same reason plugin teardown is bounded in
|
|
631
634
|
// plugin-host.ts: run() awaits this BEFORE closeCache() and before it
|
|
@@ -641,7 +644,9 @@ export function createTelemetrySource(args: {
|
|
|
641
644
|
sinks.map(async (sink) => {
|
|
642
645
|
if (disabled.has(sink) || sink.flush === undefined) return
|
|
643
646
|
try {
|
|
644
|
-
await
|
|
647
|
+
await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
|
|
648
|
+
sink.flush!(deadline.signal),
|
|
649
|
+
)
|
|
645
650
|
} catch (err) {
|
|
646
651
|
// A flush failure can never break the run — but it still costs
|
|
647
652
|
// this sink its export, so say so rather than dropping it.
|
|
@@ -1,14 +1,9 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
isGroupTask,
|
|
5
|
-
isTaskPattern,
|
|
6
|
-
parseDependencySpec,
|
|
7
|
-
type DependencySpec,
|
|
2
|
+
selectFoldedDeps,
|
|
3
|
+
type FoldCandidate,
|
|
8
4
|
type TaskNode,
|
|
9
5
|
type TaskOutcome,
|
|
10
6
|
} from '../graph/index.js'
|
|
11
|
-
import { UserError } from '../util/index.js'
|
|
12
7
|
|
|
13
8
|
/**
|
|
14
9
|
* A task's upstream as its KEY reads it: the live outcomes of its scheduled
|
|
@@ -36,15 +31,9 @@ export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutc
|
|
|
36
31
|
return o
|
|
37
32
|
}
|
|
38
33
|
|
|
39
|
-
/** `node.deps` less its ordering-only edges: the dependencies a key may fold. */
|
|
40
|
-
export function keyedDeps(node: TaskNode): readonly string[] {
|
|
41
|
-
const order = node.orderOnly
|
|
42
|
-
return order === undefined ? node.deps : node.deps.filter((d) => !order.includes(d))
|
|
43
|
-
}
|
|
44
|
-
|
|
45
34
|
/**
|
|
46
35
|
* Pick which upstream task hashes participate in the current task's
|
|
47
|
-
* cache key, filtered by `
|
|
36
|
+
* cache key, filtered by `inputs.tasks`. The folded value is the
|
|
48
37
|
* upstream's own cache key (its input-based task hash) — pure-input
|
|
49
38
|
* transitive hashing, like Turbo/Nx. An upstream change propagates
|
|
50
39
|
* through its key into every dependent's key. There is deliberately NO
|
|
@@ -77,135 +66,6 @@ export function filterUpstreamHashes(
|
|
|
77
66
|
])
|
|
78
67
|
}
|
|
79
68
|
|
|
80
|
-
/**
|
|
81
|
-
* A filter selects among the task's own dependencies, never through a
|
|
82
|
-
* group: `dependsOn: ['build']` over a group `build` of `build.a` and
|
|
83
|
-
* `build.b` offers the filter `build`, not its members. A pattern that
|
|
84
|
-
* matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
|
|
85
|
-
* with that member's inputs: a stale hit under a green run. Such a spec
|
|
86
|
-
* is refused, once, over the graph every key site reads. One that matches
|
|
87
|
-
* nothing anywhere stays legal (a preset's pattern needn't match in every
|
|
88
|
-
* project). An exact name is refused earlier, at load, unless `dependsOn`
|
|
89
|
-
* names it (`assertFilterNamesDeclaredDeps`).
|
|
90
|
-
*/
|
|
91
|
-
export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
|
|
92
|
-
for (const node of nodes.values()) {
|
|
93
|
-
const filter = node.config.cache?.inputs?.tasks
|
|
94
|
-
if (filter === undefined || filter.length === 0) continue
|
|
95
|
-
const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
|
|
96
|
-
const groups = deps.filter(isGroupTask)
|
|
97
|
-
if (groups.length === 0) continue
|
|
98
|
-
for (const raw of filter) {
|
|
99
|
-
const spec = parseSpec(raw, node.id)
|
|
100
|
-
if (spec.negated) continue
|
|
101
|
-
const matches = specMatcher(spec)
|
|
102
|
-
const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
|
|
103
|
-
if (deps.some(hit)) continue
|
|
104
|
-
for (const group of groups) {
|
|
105
|
-
const member = groupMembers(group, nodes).find(hit)
|
|
106
|
-
if (member === undefined) continue
|
|
107
|
-
throw new UserError(
|
|
108
|
-
`${node.id}: cache.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`,
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function parseSpec(raw: string, selfTaskId: string): DependencySpec {
|
|
116
|
-
try {
|
|
117
|
-
return parseDependencySpec(raw)
|
|
118
|
-
} catch (err) {
|
|
119
|
-
if (err instanceof DependencySpecError) {
|
|
120
|
-
throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
|
|
121
|
-
}
|
|
122
|
-
throw err
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Every task a group stands for, through nested groups, each once. */
|
|
127
|
-
function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
|
|
128
|
-
const out: TaskNode[] = []
|
|
129
|
-
const seen = new Set<string>([group.id])
|
|
130
|
-
const stack = group.deps.toReversed()
|
|
131
|
-
while (stack.length > 0) {
|
|
132
|
-
const id = stack.pop()!
|
|
133
|
-
if (seen.has(id)) continue
|
|
134
|
-
seen.add(id)
|
|
135
|
-
const n = nodes.get(id)
|
|
136
|
-
if (n === undefined) continue
|
|
137
|
-
out.push(n)
|
|
138
|
-
if (isGroupTask(n)) stack.push(...n.deps.toReversed())
|
|
139
|
-
}
|
|
140
|
-
return out
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/**
|
|
144
|
-
* One dependency as the key fold sees it: the task, and the value the
|
|
145
|
-
* fold dedups by — its hash on the hash path, a structural stand-in for
|
|
146
|
-
* the hash on the graph (`foldUnit` in keyed-projects.ts).
|
|
147
|
-
*/
|
|
148
|
-
export interface FoldCandidate {
|
|
149
|
-
node: TaskNode
|
|
150
|
-
unit: string
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/**
|
|
154
|
-
* The dependencies a task's key folds, per its `cache.inputs.tasks`.
|
|
155
|
-
*
|
|
156
|
-
* Patterns (Turbo/Nx micro-syntax + filter extensions):
|
|
157
|
-
* '*' all same-project upstream
|
|
158
|
-
* '^*' all dep-workspace upstream
|
|
159
|
-
* 'name' same-project task `name`
|
|
160
|
-
* '^name' `name` task in every dep workspace
|
|
161
|
-
* 'pkg#name' specific package's `name` task
|
|
162
|
-
* 'name.*' patterns — EITHER half of any form above may contain `*`
|
|
163
|
-
* (same glob as dependsOn patterns), including the project
|
|
164
|
-
* half of `pkg#name`. A filter that matched literally here
|
|
165
|
-
* would silently select ZERO upstream hashes and decouple the
|
|
166
|
-
* task from its dependencies — a stale-hit trap, so every
|
|
167
|
-
* name is matched through the shared glob.
|
|
168
|
-
* '!<form>' exclude — any of the above with a leading `!`
|
|
169
|
-
*
|
|
170
|
-
* Patterns are applied in order; last write wins, so
|
|
171
|
-
* `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
|
|
172
|
-
*
|
|
173
|
-
* Defaults:
|
|
174
|
-
* - `filter === undefined` → every candidate, as given.
|
|
175
|
-
* - `filter === []` → none (fully decoupled).
|
|
176
|
-
*
|
|
177
|
-
* Otherwise deduped by `unit`, first candidate kept. The unit is the
|
|
178
|
-
* fold's own identity, not the task's: two groups over the same members
|
|
179
|
-
* share one hash, so excluding either excludes both, and a selection by
|
|
180
|
-
* task id would keep the other while the key folded neither.
|
|
181
|
-
*/
|
|
182
|
-
export function selectFoldedDeps(
|
|
183
|
-
deps: readonly FoldCandidate[],
|
|
184
|
-
filter: readonly string[] | undefined,
|
|
185
|
-
selfProjectName: string,
|
|
186
|
-
selfTaskId: string,
|
|
187
|
-
): FoldCandidate[] {
|
|
188
|
-
if (filter === undefined) return [...deps]
|
|
189
|
-
|
|
190
|
-
const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
|
|
191
|
-
|
|
192
|
-
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
193
|
-
const matchers = specs.map((spec) => specMatcher(spec))
|
|
194
|
-
|
|
195
|
-
const selected = new Map<string, FoldCandidate>()
|
|
196
|
-
for (let i = 0; i < specs.length; i++) {
|
|
197
|
-
const spec = specs[i]!
|
|
198
|
-
const matches = matchers[i]!
|
|
199
|
-
for (const d of deps) {
|
|
200
|
-
const isSelf = d.node.projectName === selfProjectName
|
|
201
|
-
if (!matches(d.node, isSelf)) continue
|
|
202
|
-
if (spec.negated) selected.delete(d.unit)
|
|
203
|
-
else if (!selected.has(d.unit)) selected.set(d.unit, d)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
return [...selected.values()]
|
|
207
|
-
}
|
|
208
|
-
|
|
209
69
|
/**
|
|
210
70
|
* Expand any GROUP among these outcomes into the real tasks it stands for,
|
|
211
71
|
* transitively, preserving order and de-duplicating by task id.
|
|
@@ -223,7 +83,7 @@ export function selectFoldedDeps(
|
|
|
223
83
|
* along two paths (a group `build` whose `^build` meets a package diamond)
|
|
224
84
|
* is expanded once, or the walk doubles per layer. Only what the FILTER
|
|
225
85
|
* already selected is passed in, so a group excluded from a task's
|
|
226
|
-
* `
|
|
86
|
+
* `inputs.tasks` brings no members with it.
|
|
227
87
|
*/
|
|
228
88
|
export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
|
|
229
89
|
const out: TaskOutcome[] = []
|
|
@@ -245,40 +105,3 @@ export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutco
|
|
|
245
105
|
}
|
|
246
106
|
return out
|
|
247
107
|
}
|
|
248
|
-
|
|
249
|
-
/** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
|
|
250
|
-
function nameMatcher(name: string): (candidate: string) => boolean {
|
|
251
|
-
if (isTaskPattern(name)) {
|
|
252
|
-
const re = compileTaskPattern(name)
|
|
253
|
-
return (candidate) => re.test(candidate)
|
|
254
|
-
}
|
|
255
|
-
return (candidate) => candidate === name
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
|
|
260
|
-
* form glob: a filter only ever selects from upstreams that already exist,
|
|
261
|
-
* so a package pattern is unambiguous here — unlike dependsOn, which must
|
|
262
|
-
* materialize concrete edges and therefore rejects it.
|
|
263
|
-
*/
|
|
264
|
-
function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
|
|
265
|
-
switch (spec.kind) {
|
|
266
|
-
case 'wildcardSelf':
|
|
267
|
-
return (_n, isSelf) => isSelf
|
|
268
|
-
case 'wildcardDeps':
|
|
269
|
-
return (_n, isSelf) => !isSelf
|
|
270
|
-
case 'self': {
|
|
271
|
-
const task = nameMatcher(spec.task)
|
|
272
|
-
return (n, isSelf) => isSelf && task(n.taskName)
|
|
273
|
-
}
|
|
274
|
-
case 'deps': {
|
|
275
|
-
const task = nameMatcher(spec.task)
|
|
276
|
-
return (n, isSelf) => !isSelf && task(n.taskName)
|
|
277
|
-
}
|
|
278
|
-
case 'cross': {
|
|
279
|
-
const project = nameMatcher(spec.project)
|
|
280
|
-
const task = nameMatcher(spec.task)
|
|
281
|
-
return (n) => project(n.projectName) && task(n.taskName)
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
}
|
|
@@ -532,7 +532,7 @@ function violationSection(outcome: TaskOutcome, colors: ColorSupport): string[]
|
|
|
532
532
|
...(fix.inputs !== undefined
|
|
533
533
|
? [
|
|
534
534
|
dim(
|
|
535
|
-
`and, if the output depends on them,
|
|
535
|
+
`and, if the output depends on them, inputs.files: [${fix.inputs.map((p) => `'${p}'`).join(', ')}]`,
|
|
536
536
|
),
|
|
537
537
|
]
|
|
538
538
|
: []),
|
package/src/report/logger.ts
CHANGED
|
@@ -973,8 +973,11 @@ export function defaultLogger(
|
|
|
973
973
|
const qi = slotQueue.findIndex((s) => s.id === node.id)
|
|
974
974
|
if (qi >= 0) slotQueue.splice(qi, 1)
|
|
975
975
|
}
|
|
976
|
-
const bucket =
|
|
977
|
-
if (bucket !== undefined)
|
|
976
|
+
const bucket = cacheBucketOf(outcome)
|
|
977
|
+
if (bucket !== undefined) {
|
|
978
|
+
if (outcome.hash !== undefined) buckets.set(outcome.hash, bucket)
|
|
979
|
+
buckets.set(node.id, bucket)
|
|
980
|
+
}
|
|
978
981
|
switch (outcome.status) {
|
|
979
982
|
case 'success':
|
|
980
983
|
succeeded++
|
|
@@ -361,9 +361,13 @@ export function formatFailureLine(
|
|
|
361
361
|
)
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/** A step's cache column, dim as `no-cache` is. */
|
|
365
|
+
const INTERNAL = 'internal'
|
|
366
|
+
|
|
364
367
|
/**
|
|
365
368
|
* The list row of one of vx's own steps (`vx:uploads`), on the task grid
|
|
366
|
-
* with
|
|
369
|
+
* with `internal` in the cache column: a step is no task and caches
|
|
370
|
+
* nothing (M, 2026-10-16: `4.60s success internal vx:keep-run`).
|
|
367
371
|
*/
|
|
368
372
|
export function formatStepLine(
|
|
369
373
|
id: string,
|
|
@@ -376,7 +380,7 @@ export function formatStepLine(
|
|
|
376
380
|
durationMs,
|
|
377
381
|
ok ? 'success' : 'failed',
|
|
378
382
|
ok ? SUCCESS : ERROR,
|
|
379
|
-
|
|
383
|
+
INTERNAL,
|
|
380
384
|
'',
|
|
381
385
|
paintPinnedId(id, colors),
|
|
382
386
|
colors,
|
|
@@ -464,7 +468,7 @@ export function formatStatusRegion(
|
|
|
464
468
|
Math.max(0, s.nowMs - step.startedMs),
|
|
465
469
|
'running',
|
|
466
470
|
ACCENT,
|
|
467
|
-
|
|
471
|
+
INTERNAL,
|
|
468
472
|
'',
|
|
469
473
|
paintPinnedId(step.id, colors),
|
|
470
474
|
colors,
|
package/src/report/summary.ts
CHANGED
|
@@ -110,7 +110,7 @@ export interface SummaryStats {
|
|
|
110
110
|
savedMs?: number
|
|
111
111
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
112
112
|
emptyGroups?: readonly string[]
|
|
113
|
-
/** Each finished task's cache bucket by its key, so its remote traffic can move it. */
|
|
113
|
+
/** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
114
114
|
buckets?: ReadonlyMap<string, CacheBucket>
|
|
115
115
|
}
|
|
116
116
|
|
|
@@ -536,12 +536,14 @@ export function formatRunSummary(
|
|
|
536
536
|
)
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
-
/** Each finished task's cache bucket by its key. */
|
|
539
|
+
/** Each finished task's cache bucket by its key and by its id. */
|
|
540
540
|
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
|
|
541
541
|
const out = new Map<string, CacheBucket>()
|
|
542
542
|
for (const o of outcomes) {
|
|
543
|
-
const bucket =
|
|
544
|
-
if (bucket
|
|
543
|
+
const bucket = cacheBucketOf(o)
|
|
544
|
+
if (bucket === undefined) continue
|
|
545
|
+
if (o.hash !== undefined) out.set(o.hash, bucket)
|
|
546
|
+
out.set(o.node.id, bucket)
|
|
545
547
|
}
|
|
546
548
|
return out
|
|
547
549
|
}
|
package/src/settings.ts
CHANGED
|
@@ -108,12 +108,6 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
108
108
|
default: 'true',
|
|
109
109
|
doc: 'Refuse two tasks whose outputs overlap even when an edge orders them.',
|
|
110
110
|
},
|
|
111
|
-
{
|
|
112
|
-
path: 'run.rules.upfrontKeys',
|
|
113
|
-
kind: { type: 'bool' },
|
|
114
|
-
default: 'true',
|
|
115
|
-
doc: "Refuse a task whose inputs can match another task's outputs.",
|
|
116
|
-
},
|
|
117
111
|
{
|
|
118
112
|
path: 'cache.dir',
|
|
119
113
|
kind: { type: 'string' },
|
|
@@ -176,6 +170,13 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
176
170
|
task: 'cache.outputs.clean',
|
|
177
171
|
env: false,
|
|
178
172
|
},
|
|
173
|
+
{
|
|
174
|
+
path: 'cache.upload.failures',
|
|
175
|
+
kind: { type: 'enum', values: ['ci', 'local', 'always'] },
|
|
176
|
+
default: 'ci',
|
|
177
|
+
doc: "Where a failed task's log and outputs are kept in the remote: CI runs, local runs, or both.",
|
|
178
|
+
env: false,
|
|
179
|
+
},
|
|
179
180
|
{
|
|
180
181
|
path: 'lock.reviewFiles',
|
|
181
182
|
kind: { type: 'bool' },
|
|
@@ -393,15 +394,8 @@ export function resolveSetting(
|
|
|
393
394
|
return { value: undefined, source: 'default' }
|
|
394
395
|
}
|
|
395
396
|
|
|
396
|
-
/** `run.rules`,
|
|
397
|
-
export function runRules(workspace: unknown): {
|
|
398
|
-
exclusiveOutputs
|
|
399
|
-
|
|
400
|
-
} {
|
|
401
|
-
const out: { exclusiveOutputs?: boolean; upfrontKeys?: boolean } = {}
|
|
402
|
-
for (const rule of ['exclusiveOutputs', 'upfrontKeys'] as const) {
|
|
403
|
-
const { value } = resolveSetting(`run.rules.${rule}`, workspace)
|
|
404
|
-
if (value !== undefined) out[rule] = value as boolean
|
|
405
|
-
}
|
|
406
|
-
return out
|
|
397
|
+
/** `run.rules`, from its variable, else the workspace; unset stays on. */
|
|
398
|
+
export function runRules(workspace: unknown): { exclusiveOutputs?: boolean } {
|
|
399
|
+
const { value } = resolveSetting('run.rules.exclusiveOutputs', workspace)
|
|
400
|
+
return value === undefined ? {} : { exclusiveOutputs: value as boolean }
|
|
407
401
|
}
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -12,11 +12,12 @@ export function toPosix(p: string): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* `.vx/.gitignore`: git ignores the machine-local `.vx/cache`
|
|
16
|
-
* else in `.vx`, so the workspace
|
|
17
|
-
* committed. `vx init` and the cache
|
|
15
|
+
* `.vx/.gitignore`: git ignores the machine-local `.vx/cache`, `.vx/tmp`
|
|
16
|
+
* (where restores stage) and nothing else in `.vx`, so the workspace
|
|
17
|
+
* config and the lock beside it are committed. `vx init` and the cache
|
|
18
|
+
* write it when missing.
|
|
18
19
|
*/
|
|
19
|
-
export const DOT_VX_GITIGNORE = '/cache\n'
|
|
20
|
+
export const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
|
|
20
21
|
|
|
21
22
|
/** `.vx/.gitattributes`' text: GitHub collapses the machine lock in a diff. */
|
|
22
23
|
export const DOT_VX_GITATTRIBUTES = 'projects.lock.json linguist-generated=true\n'
|
|
@@ -163,7 +164,7 @@ export function isLiteralPattern(glob: string): boolean {
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
/**
|
|
166
|
-
* The alphabet of a TASK glob (`
|
|
167
|
+
* The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
|
|
167
168
|
* `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
|
|
168
169
|
* need the POSITION of the first wildcard rather than a verdict. Every site
|
|
169
170
|
* that asks "must this declaration be matched?" reads this one regex: item
|
|
@@ -385,6 +386,39 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
|
|
|
385
386
|
return (rel) => pos(rel) && !neg(rel)
|
|
386
387
|
}
|
|
387
388
|
|
|
389
|
+
/**
|
|
390
|
+
* What no output glob reaches. An output is otherwise taken as written —
|
|
391
|
+
* `node_modules/**` IS an install task's output — but no task produces
|
|
392
|
+
* these, and a root project declaring `**` would otherwise wipe its
|
|
393
|
+
* repository and the cache it is restoring from (2026-09-16). `.vx` whole
|
|
394
|
+
* and each `vx.lock.json` are vx's own: as outputs, the clean deleted an
|
|
395
|
+
* uncommitted workspace config and a hit restored a lock or ignore file the
|
|
396
|
+
* key does not see over the current one.
|
|
397
|
+
*/
|
|
398
|
+
export const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
|
|
399
|
+
|
|
400
|
+
/** Whether a positive glob in `globs` names a `node_modules` directory. */
|
|
401
|
+
export function namesNodeModules(globs: readonly string[]): boolean {
|
|
402
|
+
return globs.some(
|
|
403
|
+
(g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Whether a restore may write `rel`: one of the outputs `globs` declare,
|
|
409
|
+
* never under `OUTPUT_NEVER`, and under `node_modules` only where a glob
|
|
410
|
+
* names it — what the save side could have packed (`resolveOutputs`). An
|
|
411
|
+
* artifact naming anything else was not written by this task's save, and
|
|
412
|
+
* none of it is restored (2026-10-16).
|
|
413
|
+
*/
|
|
414
|
+
export function restorableOutput(globs: readonly string[]): (rel: string) => boolean {
|
|
415
|
+
const isOutput = outputMatcher(globs)
|
|
416
|
+
const never = anyTaskGlob(
|
|
417
|
+
namesNodeModules(globs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**'],
|
|
418
|
+
)
|
|
419
|
+
return (rel) => isOutput(rel) && !never(rel)
|
|
420
|
+
}
|
|
421
|
+
|
|
388
422
|
/**
|
|
389
423
|
* `pattern` with its first brace group expanded, recursively, while that
|
|
390
424
|
* group holds a `/`; any other pattern as itself, since `Bun.Glob`
|