@vzn/vx 0.0.639 → 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 +111 -83
- package/docs/cli.md +70 -40
- package/docs/execution.md +4 -4
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +235 -180
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/cache/cache.ts +4 -1
- 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 +3 -0
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cli/foreign-flags.ts +1 -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 +95 -19
- 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 +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/index.ts +1 -1
- 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/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -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 +62 -22
- 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 +124 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +51 -21
- 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/status-line.ts +7 -3
- package/src/settings.ts +11 -17
- package/src/util/paths.ts +1 -1
- 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
|
@@ -3,25 +3,45 @@
|
|
|
3
3
|
// `:teardown`. `:` is no task id's separator, so a step never collides with
|
|
4
4
|
// a project named `vx` or a plugin package's own `#setup` task. A step is
|
|
5
5
|
// not a task: no stat, history row or telemetry record counts it.
|
|
6
|
+
//
|
|
7
|
+
// A run shows only the steps it waits on, before its first task or its end
|
|
8
|
+
// (M, 2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
|
|
9
|
+
// `background` step: never shown, and kept for `vx last` (run-steps.ts).
|
|
6
10
|
import type { Logger } from '../report/index.js'
|
|
11
|
+
import type { RunStep } from './run-steps.js'
|
|
7
12
|
|
|
8
13
|
export interface Steps {
|
|
9
14
|
/** Time `work` as step `id` when steps show; else run it as it is. */
|
|
10
15
|
step<T>(id: string, work: () => Promise<T>): Promise<T>
|
|
11
16
|
stepSync<T>(id: string, work: () => T): T
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
stepSync: (_id, work) => work(),
|
|
17
|
+
/** Time `work`, which the run does not wait on, as step `id` into `background`. */
|
|
18
|
+
background<T>(id: string, work: () => Promise<T>): Promise<T>
|
|
19
|
+
/** The background steps that have ended. */
|
|
20
|
+
readonly ended: readonly RunStep[]
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/** Steps that tell `log`, or none when nothing renders them (zero cost). */
|
|
20
24
|
export function createSteps(log: Logger, shown: boolean): Steps {
|
|
21
|
-
|
|
25
|
+
const ended: RunStep[] = []
|
|
26
|
+
// Timed whether or not anything shows: only a run with a remote starts one.
|
|
27
|
+
const background = async <T>(id: string, work: () => Promise<T>): Promise<T> => {
|
|
28
|
+
const t0 = performance.now()
|
|
29
|
+
let ok = false
|
|
30
|
+
try {
|
|
31
|
+
const value = await work()
|
|
32
|
+
ok = true
|
|
33
|
+
return value
|
|
34
|
+
} finally {
|
|
35
|
+
ended.push({ id, durationMs: Math.round(performance.now() - t0), ok })
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (!shown)
|
|
39
|
+
return { step: (_id, work) => work(), stepSync: (_id, work) => work(), background, ended }
|
|
22
40
|
const end = (id: string, t0: number, ok: boolean): void =>
|
|
23
41
|
log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
|
|
24
42
|
return {
|
|
43
|
+
background,
|
|
44
|
+
ended,
|
|
25
45
|
async step(id, work) {
|
|
26
46
|
const t0 = performance.now()
|
|
27
47
|
log.stepStart?.(id)
|
|
@@ -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
|
: []),
|
|
@@ -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/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/paths.ts
CHANGED
|
@@ -163,7 +163,7 @@ export function isLiteralPattern(glob: string): boolean {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
/**
|
|
166
|
-
* The alphabet of a TASK glob (`
|
|
166
|
+
* The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
|
|
167
167
|
* `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
|
|
168
168
|
* need the POSITION of the first wildcard rather than a verdict. Every site
|
|
169
169
|
* that asks "must this declaration be matched?" reads this one regex: item
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// index + unstaged — so it captures everything you touched and nothing
|
|
7
7
|
// the base branch moved on with. Matches Turbo's `[<since>]` semantics.
|
|
8
8
|
|
|
9
|
-
import { realpath } from 'node:fs/promises'
|
|
9
|
+
import { realpath, stat } from 'node:fs/promises'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import {
|
|
12
12
|
executablePath,
|
|
@@ -75,7 +75,7 @@ export interface AffectedArgs {
|
|
|
75
75
|
since: string
|
|
76
76
|
projects: readonly ProjectMeta[]
|
|
77
77
|
/**
|
|
78
|
-
* Which projects declare a `
|
|
78
|
+
* Which projects declare a `inputs.workspaceFiles` glob matching one of
|
|
79
79
|
* these workspace-relative paths.
|
|
80
80
|
*
|
|
81
81
|
* A workspace-anchored glob is the documented escape hatch for inputs
|
|
@@ -107,6 +107,12 @@ export interface AffectedArgs {
|
|
|
107
107
|
* run then paid again (I-26).
|
|
108
108
|
*/
|
|
109
109
|
untracked?: () => Promise<readonly string[] | null>
|
|
110
|
+
/**
|
|
111
|
+
* The tracked symlinks, workspace-relative, from the run's enumeration
|
|
112
|
+
* (its index OIDs spell one `120000:`); null or absent spawns `git ls-files
|
|
113
|
+
* -s` here. A changed or untracked link is a changed path already.
|
|
114
|
+
*/
|
|
115
|
+
links?: () => Promise<readonly string[] | null>
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
export interface FingerprintClaims {
|
|
@@ -253,6 +259,16 @@ export async function affectedChanges(
|
|
|
253
259
|
const changed = [...diffed.paths, ...untracked.filter((s) => !isInstalledPath(s))].filter(
|
|
254
260
|
(s) => s !== LOCKFILE_NAME,
|
|
255
261
|
)
|
|
262
|
+
// A symlinked input keys the content behind it (cache/linked-inputs.ts),
|
|
263
|
+
// and the diff names that content's path, never the link's: a link whose
|
|
264
|
+
// target changed is a changed path itself.
|
|
265
|
+
changed.push(
|
|
266
|
+
...(await linkedChanges(
|
|
267
|
+
args.workspaceRoot,
|
|
268
|
+
(await args.links?.().catch(() => null)) ?? (await trackedLinks(args.workspaceRoot)),
|
|
269
|
+
changed,
|
|
270
|
+
)),
|
|
271
|
+
)
|
|
256
272
|
// The nested repositories git reports as one path: a gitlink, or an
|
|
257
273
|
// untracked embedded repository (`dir/`, the only path git prints so).
|
|
258
274
|
const nested = new Set(diffed.gitlinks)
|
|
@@ -503,6 +519,69 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
|
503
519
|
return (await f.exists()) ? await f.bytes() : null
|
|
504
520
|
}
|
|
505
521
|
|
|
522
|
+
/** The symlinks the index lists, workspace-relative. */
|
|
523
|
+
async function trackedLinks(workspaceRoot: string): Promise<string[]> {
|
|
524
|
+
const out: string[] = []
|
|
525
|
+
for (const record of await gitPaths(workspaceRoot, ['ls-files', '-s', '-z'])) {
|
|
526
|
+
if (record.startsWith('120000 ')) out.push(record.slice(record.indexOf('\t') + 1))
|
|
527
|
+
}
|
|
528
|
+
return out
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The links in `links` whose target changed: a file `changed` names, or a
|
|
533
|
+
* directory holding one. A target git cannot diff is changed on every run:
|
|
534
|
+
* one outside the workspace, or git-ignored (or ignored files under a linked
|
|
535
|
+
* directory), since the key reads it whatever git says. A dangling link
|
|
536
|
+
* reads nothing; retargeting it is a change to the link itself.
|
|
537
|
+
*/
|
|
538
|
+
async function linkedChanges(
|
|
539
|
+
workspaceRoot: string,
|
|
540
|
+
links: readonly string[],
|
|
541
|
+
changed: readonly string[],
|
|
542
|
+
): Promise<string[]> {
|
|
543
|
+
if (links.length === 0) return []
|
|
544
|
+
const realRoot = await realpath(workspaceRoot).catch(() => workspaceRoot)
|
|
545
|
+
const out: string[] = []
|
|
546
|
+
const inside: Array<{ link: string; target: string; dir: boolean }> = []
|
|
547
|
+
for (const link of links) {
|
|
548
|
+
let real: string
|
|
549
|
+
let dir: boolean
|
|
550
|
+
try {
|
|
551
|
+
real = await realpath(path.join(workspaceRoot, link))
|
|
552
|
+
dir = (await stat(real)).isDirectory()
|
|
553
|
+
} catch {
|
|
554
|
+
continue
|
|
555
|
+
}
|
|
556
|
+
const rel = path.relative(realRoot, real)
|
|
557
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) out.push(link)
|
|
558
|
+
else inside.push({ link, target: rel.split(path.sep).join('/'), dir })
|
|
559
|
+
}
|
|
560
|
+
if (inside.length === 0) return out
|
|
561
|
+
const named = inside.map((l) => l.target).filter((t) => t !== '')
|
|
562
|
+
const ignored = await gitPaths(workspaceRoot, [
|
|
563
|
+
'ls-files',
|
|
564
|
+
'--others',
|
|
565
|
+
'--ignored',
|
|
566
|
+
'--exclude-standard',
|
|
567
|
+
'--directory',
|
|
568
|
+
'-z',
|
|
569
|
+
'--',
|
|
570
|
+
...(named.length === inside.length ? named : ['.']),
|
|
571
|
+
])
|
|
572
|
+
const changedSet = new Set(changed)
|
|
573
|
+
const under = (p: string, dir: string): boolean => dir === '' || p.startsWith(`${dir}/`)
|
|
574
|
+
for (const { link, target, dir } of inside) {
|
|
575
|
+
const unseen = ignored.some((i) => {
|
|
576
|
+
const at = i.replace(/\/$/, '')
|
|
577
|
+
return at === target || under(target, at) || (dir && under(at, target))
|
|
578
|
+
})
|
|
579
|
+
const hit = dir ? changed.some((c) => under(c, target)) : changedSet.has(target)
|
|
580
|
+
if (unseen || hit) out.push(link)
|
|
581
|
+
}
|
|
582
|
+
return out
|
|
583
|
+
}
|
|
584
|
+
|
|
506
585
|
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
507
586
|
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
508
587
|
const proc = spawnGit([...cmd], workspaceRoot)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The third `changed file → project` channel for `--affected`.
|
|
2
2
|
//
|
|
3
3
|
// Directory containment answers "which project owns this file", and
|
|
4
|
-
// `
|
|
4
|
+
// `inputs.workspaceFiles` answers "which project declared it". Neither
|
|
5
5
|
// can see the one remaining way a file reaches a task: a project's
|
|
6
6
|
// `vx.config.*` IMPORTS it. Resolved-config hashing folds the imported values
|
|
7
7
|
// into the cache key (architecture principle #4), so editing such a file
|