@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
|
@@ -85,7 +85,7 @@ export function sandboxFix(
|
|
|
85
85
|
projectDir: string,
|
|
86
86
|
mask: (text: string) => string = (t) => t,
|
|
87
87
|
/**
|
|
88
|
-
* The task's `
|
|
88
|
+
* The task's `inputs.files`, undefined when it does not cache: a
|
|
89
89
|
* write under one is granted exactly, and a read of the project no glob
|
|
90
90
|
* matches is named for the key too.
|
|
91
91
|
*/
|
|
@@ -124,19 +124,3 @@ export function sandboxFix(
|
|
|
124
124
|
if (fix[k].size > 0) out[k] = [...new Set([...fix[k]].map(mask))].sort()
|
|
125
125
|
return Object.keys(out).length > 0 ? out : undefined
|
|
126
126
|
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The fix as the config line it is: `exec.sandbox.allow: { read: ['a'] }`,
|
|
130
|
-
* or undefined when every denial was a withheld credential path.
|
|
131
|
-
*/
|
|
132
|
-
export function sandboxFixLine({
|
|
133
|
-
withheld: _,
|
|
134
|
-
inputs: __,
|
|
135
|
-
...fix
|
|
136
|
-
}: SandboxFix): string | undefined {
|
|
137
|
-
if (Object.keys(fix).length === 0) return undefined
|
|
138
|
-
const parts = Object.entries(fix).map(
|
|
139
|
-
([k, list]: [string, string[]]) => `${k}: [${list.map((p) => `'${p}'`).join(', ')}]`,
|
|
140
|
-
)
|
|
141
|
-
return `exec.sandbox.allow: { ${parts.join(', ')} }`
|
|
142
|
-
}
|
|
@@ -163,7 +163,7 @@ export function prepareSandbox(nodes: Iterable<TaskNode>): SandboxArmer | null {
|
|
|
163
163
|
* cached path (through the executor) and the persistent path (spawned here).
|
|
164
164
|
*
|
|
165
165
|
* The user's grants derive NOTHING from `cache` (owner, 2026-09-05). Those
|
|
166
|
-
* are two different questions: `
|
|
166
|
+
* are two different questions: `inputs` says what INVALIDATES the
|
|
167
167
|
* task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
168
168
|
* coupled them in both directions — a declaration added for caching
|
|
169
169
|
* silently widened the sandbox, and a path the task needed had to be
|
|
@@ -476,7 +476,7 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
|
476
476
|
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
477
477
|
`reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
|
|
478
478
|
`\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
|
|
479
|
-
`\`
|
|
479
|
+
`\`inputs.workspaceFiles\`).`
|
|
480
480
|
)
|
|
481
481
|
}
|
|
482
482
|
|
|
@@ -16,13 +16,13 @@
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
17
|
import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
18
|
import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
|
|
19
|
-
import type { Logger } from '
|
|
19
|
+
import type { Logger } from '../report/index.js'
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* How long a SIGTERMed child gets before SIGKILL on the way out. The
|
|
23
23
|
* end-of-run persistent shutdown grants the same two seconds; both read
|
|
24
|
-
* `
|
|
25
|
-
*
|
|
24
|
+
* `killGraceMs` (util/settle.ts) so the suites that prove the escalation
|
|
25
|
+
* do not wait the full grace.
|
|
26
26
|
*/
|
|
27
27
|
export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
28
28
|
|
|
@@ -125,6 +125,13 @@ export function forwardSignals(args: {
|
|
|
125
125
|
}): SignalForwarding {
|
|
126
126
|
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
127
127
|
const exit = (signal: StopSignal): never => {
|
|
128
|
+
// The run's own end already flushed the log; a second signal cut it
|
|
129
|
+
// short, so the failure frames it holds print now (idempotent).
|
|
130
|
+
try {
|
|
131
|
+
args.log.runEnd?.()
|
|
132
|
+
} catch {
|
|
133
|
+
// teardown must not throw on the way out
|
|
134
|
+
}
|
|
128
135
|
for (const child of everyChild()) killTree(child, 'SIGKILL')
|
|
129
136
|
try {
|
|
130
137
|
args.cache.close()
|
|
@@ -152,10 +159,11 @@ export function forwardSignals(args: {
|
|
|
152
159
|
stopping = signal
|
|
153
160
|
claimExitForSignal()
|
|
154
161
|
// Clear the live worker/status region BEFORE exiting so a TTY isn't
|
|
155
|
-
// left with a frozen region in the scrollback.
|
|
156
|
-
//
|
|
162
|
+
// left with a frozen region in the scrollback. Only the region: the
|
|
163
|
+
// tasks this stop aborts still list as rows, above the failure frames
|
|
164
|
+
// the run's own end prints.
|
|
157
165
|
try {
|
|
158
|
-
args.log.
|
|
166
|
+
args.log.runDone?.()
|
|
159
167
|
} catch {
|
|
160
168
|
// teardown must not throw on the way out
|
|
161
169
|
}
|
|
@@ -5,17 +5,30 @@
|
|
|
5
5
|
// out so the two callers can never drift on the stability gate.
|
|
6
6
|
//
|
|
7
7
|
// A task's key is "stable" only if its inputs can't be altered by an
|
|
8
|
-
// upstream's outputs (a `
|
|
8
|
+
// upstream's outputs (a `inputs.files` glob that could match a
|
|
9
9
|
// sibling's declared output has a PRELIMINARY key until that upstream
|
|
10
10
|
// runs — probing it would target the wrong artifact). Unstable tasks
|
|
11
11
|
// fall back to lazy read-through in execute-task, which is always
|
|
12
12
|
// correct. The check is conservative: when unsure, treat as unstable.
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
foldedDeps,
|
|
16
|
+
isGroupTask,
|
|
17
|
+
outputTakenBack,
|
|
18
|
+
type TaskNode,
|
|
19
|
+
type TaskOutcome,
|
|
20
|
+
} from '../graph/index.js'
|
|
15
21
|
import type { CacheLayer, GitFilesCache } from '../cache/index.js'
|
|
16
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
checkIgnore,
|
|
24
|
+
ignoreProbes,
|
|
25
|
+
isLiteralPattern,
|
|
26
|
+
normalizeGlob,
|
|
27
|
+
relPosix,
|
|
28
|
+
taskGlob,
|
|
29
|
+
wholeIgnored,
|
|
30
|
+
} from '../util/index.js'
|
|
17
31
|
import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
|
|
18
|
-
import { foldedDeps } from './keyed-projects.js'
|
|
19
32
|
import {
|
|
20
33
|
computeGroupHash,
|
|
21
34
|
computeTaskHash,
|
|
@@ -116,16 +129,17 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
116
129
|
if (list === undefined) outputsOf.set(n.projectName, [...files])
|
|
117
130
|
else list.push(...files)
|
|
118
131
|
}
|
|
132
|
+
const readableOf = readableOutputs(outputsOf, args.nodes, args.workspaceRoot, args.gitFilesCache)
|
|
119
133
|
const rewritersById = new Map<string, ProjectSet>()
|
|
120
134
|
const unfoldedById = new Map<string, ProjectSet>()
|
|
121
135
|
const wsRewriters = new Set<string>()
|
|
122
136
|
// A cached rewriter matters only to a key that leaves a dependency's key
|
|
123
|
-
// out: a `
|
|
137
|
+
// out: a `inputs.tasks` filter. With none in the graph every edge
|
|
124
138
|
// folds and the sets above stay empty, so they are not built (about 2 ms,
|
|
125
139
|
// median, of a 27 ms memoised walk over the 3,000-task bench; item 750).
|
|
126
140
|
let anyUnfolded = false
|
|
127
141
|
for (const n of args.nodes.values()) {
|
|
128
|
-
if (n.config.
|
|
142
|
+
if (n.config.inputs?.tasks !== undefined) {
|
|
129
143
|
anyUnfolded = true
|
|
130
144
|
break
|
|
131
145
|
}
|
|
@@ -232,7 +246,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
232
246
|
// cascade (and forward their producers, above). They inherit
|
|
233
247
|
// instability from any unstable member.
|
|
234
248
|
// `computeGroupKey`, with the unkeyed branch kept synchronous.
|
|
235
|
-
if (node.config.
|
|
249
|
+
if (node.config.inputs === undefined) {
|
|
236
250
|
const key = computeGroupHash(upstream)
|
|
237
251
|
keyById.set(id, key)
|
|
238
252
|
args.groupKeys?.set(id, { key, upstream })
|
|
@@ -277,15 +291,15 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
277
291
|
{
|
|
278
292
|
wide: unfolded === undefined ? wide : wide === undefined ? unfolded : wide.or(unfolded),
|
|
279
293
|
outputsOf,
|
|
294
|
+
readableOf,
|
|
280
295
|
},
|
|
281
296
|
)
|
|
282
297
|
if (unstable) unstableById.add(id)
|
|
283
298
|
|
|
284
299
|
const cacheEnabled = node.config.cache !== undefined
|
|
285
300
|
if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
|
|
286
|
-
// An uncached key
|
|
287
|
-
//
|
|
288
|
-
// write into this project, or anywhere, leaves the key preliminary.
|
|
301
|
+
// An uncached key stays preliminary behind any upstream that may write
|
|
302
|
+
// into this project, or anywhere: none of its writes is declared.
|
|
289
303
|
else if (
|
|
290
304
|
!cacheEnabled &&
|
|
291
305
|
!unstable &&
|
|
@@ -299,10 +313,94 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
299
313
|
return stableKeys
|
|
300
314
|
}
|
|
301
315
|
|
|
316
|
+
/**
|
|
317
|
+
* The same-project outputs a reader's inputs reach, root-relative, each with
|
|
318
|
+
* its project dir and project-relative glob: the outputs the gate would key
|
|
319
|
+
* a reader late over, unless git ignores them (`askIgnoredOutputs`).
|
|
320
|
+
*/
|
|
321
|
+
export function gatedOutputs(
|
|
322
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
323
|
+
workspaceRoot: string,
|
|
324
|
+
): Map<string, { projectDir: string; glob: string }> {
|
|
325
|
+
const out = new Map<string, { projectDir: string; glob: string }>()
|
|
326
|
+
// Each project's positive outputs with their literal heads, read once:
|
|
327
|
+
// 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
|
|
328
|
+
const outputsOf = new Map<string, { glob: string; head: string }[]>()
|
|
329
|
+
for (const n of nodes.values()) {
|
|
330
|
+
const files = n.config.cache?.outputs.files
|
|
331
|
+
if (files === undefined || files.length === 0) continue
|
|
332
|
+
let list = outputsOf.get(n.projectName)
|
|
333
|
+
if (list === undefined) outputsOf.set(n.projectName, (list = []))
|
|
334
|
+
for (const glob of files)
|
|
335
|
+
if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
|
|
336
|
+
}
|
|
337
|
+
if (outputsOf.size === 0) return out
|
|
338
|
+
for (const n of nodes.values()) {
|
|
339
|
+
const inputs = n.config.inputs?.files
|
|
340
|
+
const outputs = outputsOf.get(n.projectName)
|
|
341
|
+
if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
|
|
342
|
+
let back: string[] | undefined
|
|
343
|
+
let rel: string | undefined
|
|
344
|
+
for (const { glob: o, head } of outputs) {
|
|
345
|
+
if (!workspaceInputsReach(inputs, [head])) continue
|
|
346
|
+
rel ??= relPosix(workspaceRoot, n.projectDir)
|
|
347
|
+
back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
348
|
+
const rooted = rel === '' ? o : `${rel}/${o}`
|
|
349
|
+
if (out.has(rooted) || (back.length > 0 && outputTakenBack(o, back))) continue
|
|
350
|
+
out.set(rooted, { projectDir: n.projectDir, glob: o })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Asks git which of `gated` it ignores, started beside the run's
|
|
358
|
+
* enumeration; the call it returns settles `files.ignoredOutputs` once that
|
|
359
|
+
* enumeration is in, whose listing says whether a tracked or re-included
|
|
360
|
+
* file lies under a glob. Nothing gated: no spawn.
|
|
361
|
+
*/
|
|
362
|
+
export function askIgnoredOutputs(
|
|
363
|
+
gated: ReadonlyMap<string, { projectDir: string; glob: string }>,
|
|
364
|
+
workspaceRoot: string,
|
|
365
|
+
): (files: GitFilesCache) => Promise<void> {
|
|
366
|
+
if (gated.size === 0) return async () => {}
|
|
367
|
+
const paths = [...gated.keys()].flatMap((g) => ignoreProbes(g)?.paths ?? [])
|
|
368
|
+
const asked = paths.length === 0 ? Promise.resolve(null) : checkIgnore(workspaceRoot, paths)
|
|
369
|
+
return async (files) => {
|
|
370
|
+
files.ignoredOutputs = wholeIgnored(gated.keys(), await asked, (g) => {
|
|
371
|
+
const { projectDir, glob } = gated.get(g)!
|
|
372
|
+
const listed = files.get(projectDir)
|
|
373
|
+
return listed === undefined || listed.some((f) => taskGlob(glob).match(f))
|
|
374
|
+
})
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** `outputsOf` less what git ignores whole; the same map when it ignores none. */
|
|
379
|
+
function readableOutputs(
|
|
380
|
+
outputsOf: ReadonlyMap<string, string[]>,
|
|
381
|
+
nodes: ReadonlyMap<string, TaskNode>,
|
|
382
|
+
workspaceRoot: string,
|
|
383
|
+
files: GitFilesCache,
|
|
384
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
385
|
+
const ignored = files.ignoredOutputs
|
|
386
|
+
if (ignored.size === 0) return outputsOf
|
|
387
|
+
const dirOf = new Map<string, string>()
|
|
388
|
+
for (const n of nodes.values()) dirOf.set(n.projectName, relPosix(workspaceRoot, n.projectDir))
|
|
389
|
+
const out = new Map<string, readonly string[]>()
|
|
390
|
+
for (const [project, outputs] of outputsOf) {
|
|
391
|
+
const rel = dirOf.get(project)!
|
|
392
|
+
out.set(
|
|
393
|
+
project,
|
|
394
|
+
outputs.filter((o) => !ignored.has(rel === '' ? o : `${rel}/${o}`)),
|
|
395
|
+
)
|
|
396
|
+
}
|
|
397
|
+
return out
|
|
398
|
+
}
|
|
399
|
+
|
|
302
400
|
const probesAfterWritesOf = new WeakMap<ReadonlyMap<string, TaskNode>, ReadonlySet<string>>()
|
|
303
401
|
|
|
304
402
|
/**
|
|
305
|
-
* The cached tasks whose `
|
|
403
|
+
* The cached tasks whose `inputs.runtime` / `workspaceRuntime` probes
|
|
306
404
|
* may read what an upstream writes this run while the key folds nothing of
|
|
307
405
|
* that upstream (X-34). A probe is a shell command and may read anywhere,
|
|
308
406
|
* so no glob bounds it, and its answer taken before the writer ran is the
|
|
@@ -330,8 +428,8 @@ export function probesAfterWrites(
|
|
|
330
428
|
const late = new Set<string>()
|
|
331
429
|
probesAfterWritesOf.set(nodes, late)
|
|
332
430
|
const probes = (n: TaskNode): boolean =>
|
|
333
|
-
(n.config.
|
|
334
|
-
(n.config.
|
|
431
|
+
(n.config.inputs?.runtime?.length ?? 0) > 0 ||
|
|
432
|
+
(n.config.inputs?.workspaceRuntime?.length ?? 0) > 0
|
|
335
433
|
let anyProbe = false
|
|
336
434
|
for (const n of nodes.values()) {
|
|
337
435
|
if (probes(n)) {
|
|
@@ -361,7 +459,7 @@ export function probesAfterWrites(
|
|
|
361
459
|
let writer = false
|
|
362
460
|
let unfolded = false
|
|
363
461
|
// No filter and no order-only edge: the key folds every dependency.
|
|
364
|
-
const foldsAll = node.orderOnly === undefined && node.config.
|
|
462
|
+
const foldsAll = node.orderOnly === undefined && node.config.inputs?.tasks === undefined
|
|
365
463
|
let folds: Set<string> | undefined
|
|
366
464
|
for (const dep of node.deps) {
|
|
367
465
|
if (!nodes.has(dep)) continue
|
|
@@ -389,7 +487,7 @@ function foldedBy(
|
|
|
389
487
|
upstream: TaskOutcome[],
|
|
390
488
|
keyById: Map<string, string>,
|
|
391
489
|
): (dep: string) => boolean {
|
|
392
|
-
const filter = node.config.
|
|
490
|
+
const filter = node.config.inputs?.tasks
|
|
393
491
|
if (isGroupTask(node) || filter === undefined) return (dep) => keyById.has(dep)
|
|
394
492
|
const hashes = new Set(
|
|
395
493
|
filterUpstreamHashes(upstream, filter, node.projectName, node.id).map(([, h]) => h),
|
|
@@ -431,7 +529,7 @@ function synthUpstream(
|
|
|
431
529
|
* `outputs.files` — direct or transitive — makes the key
|
|
432
530
|
* preliminary. Project boundaries are hard, so only SAME-project
|
|
433
531
|
* outputs can reach a project-relative input.
|
|
434
|
-
* - `
|
|
532
|
+
* - `inputs.workspaceFiles` is boundary-free: it can read ANY
|
|
435
533
|
* project's dir (so any upstream `outputs.files`, in any project,
|
|
436
534
|
* matters) or a root-anchored location (so any upstream
|
|
437
535
|
* `outputs.workspaceFiles` matters). Either → preliminary key. This
|
|
@@ -452,12 +550,14 @@ export function dependsOnSiblingOutputs(
|
|
|
452
550
|
wide: ProjectNames | undefined
|
|
453
551
|
/** Each project's declared `outputs.files`, over all its tasks. */
|
|
454
552
|
outputsOf: ReadonlyMap<string, readonly string[]>
|
|
553
|
+
/** The same less what git ignores whole: no key reads those. */
|
|
554
|
+
readableOf?: ReadonlyMap<string, readonly string[]>
|
|
455
555
|
},
|
|
456
556
|
): boolean {
|
|
457
557
|
const cache = node.config.cache
|
|
458
|
-
|
|
459
|
-
//
|
|
460
|
-
if (
|
|
558
|
+
const declared = node.config.inputs
|
|
559
|
+
// A task with no inputs has no key to take up front.
|
|
560
|
+
if (declared === undefined) return false
|
|
461
561
|
if (upstreamOutputProjects.has(node.projectName)) {
|
|
462
562
|
// A same-project producer whose writes are all declared reaches this
|
|
463
563
|
// task's inputs only where the globs can meet: a `test` reading `src/**`
|
|
@@ -466,21 +566,29 @@ export function dependsOnSiblingOutputs(
|
|
|
466
566
|
// projects (A-20). Undeclared writes, and a rewriter this key does not
|
|
467
567
|
// fold, may land anywhere: those stay project-wide.
|
|
468
568
|
if (sameProject === undefined || sameProject.wide?.has(node.projectName) === true) return true
|
|
469
|
-
const inputs =
|
|
569
|
+
const inputs = declared.files
|
|
470
570
|
const outputs = sameProject.outputsOf.get(node.projectName) ?? []
|
|
471
|
-
if (
|
|
571
|
+
if (outputs.length === 0) return true
|
|
472
572
|
// An output the reader's own `!` inputs take back whole is not in its
|
|
473
573
|
// key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
|
|
474
|
-
// before and after it, which is what `
|
|
475
|
-
//
|
|
574
|
+
// before and after it, which is what `vx lock` asks a config to write
|
|
575
|
+
// (X-54).
|
|
576
|
+
// Nor is an output git ignores whole: a key reads only what git lists.
|
|
577
|
+
// Unless an entry starts inside it (`dist/**`): naming an ignored path
|
|
578
|
+
// makes it an input (`ignoredReach` in cache/inputs.ts).
|
|
579
|
+
const readable = withNamedIgnored(
|
|
580
|
+
inputs,
|
|
581
|
+
outputs,
|
|
582
|
+
sameProject.readableOf?.get(node.projectName) ?? outputs,
|
|
583
|
+
)
|
|
476
584
|
const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
477
|
-
const reach = back.length === 0 ?
|
|
585
|
+
const reach = back.length === 0 ? readable : readable.filter((o) => !outputTakenBack(o, back))
|
|
478
586
|
if (reach.length > 0 && workspaceInputsReach(inputs, reach.map(literalPrefix))) return true
|
|
479
587
|
// Nor where it WRITES inside another task's tree: restored ahead of
|
|
480
588
|
// that producer, its restore raced the producer's clean and extract
|
|
481
589
|
// (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
|
|
482
590
|
// `outputsOf` holds this task's own globs too; set them aside once.
|
|
483
|
-
const own = cache
|
|
591
|
+
const own = cache?.outputs.files ?? []
|
|
484
592
|
if (own.length > 0) {
|
|
485
593
|
const others = [...outputs]
|
|
486
594
|
for (const g of own) {
|
|
@@ -499,7 +607,7 @@ export function dependsOnSiblingOutputs(
|
|
|
499
607
|
// `app` read `gen/**` project-relative), so the mere presence of a
|
|
500
608
|
// workspace-output producer upstream makes the key preliminary.
|
|
501
609
|
if (hasWsOutputUpstream) return true
|
|
502
|
-
const wsInputs =
|
|
610
|
+
const wsInputs = declared.workspaceFiles ?? []
|
|
503
611
|
if (wsInputs.length > 0 && upstreamOutputProjects.size > 0) {
|
|
504
612
|
// Without the dirs the old answer stands: a workspace reader over any
|
|
505
613
|
// producer is preliminary. With them, only a reader whose globs can
|
|
@@ -519,6 +627,29 @@ export function dependsOnSiblingOutputs(
|
|
|
519
627
|
return false
|
|
520
628
|
}
|
|
521
629
|
|
|
630
|
+
/**
|
|
631
|
+
* `readable`, plus each output git ignores whole that a positive entry of
|
|
632
|
+
* `inputs` starts inside: its literal prefix at or below the output's.
|
|
633
|
+
*/
|
|
634
|
+
function withNamedIgnored(
|
|
635
|
+
inputs: readonly string[],
|
|
636
|
+
outputs: readonly string[],
|
|
637
|
+
readable: readonly string[],
|
|
638
|
+
): readonly string[] {
|
|
639
|
+
if (readable.length === outputs.length) return readable
|
|
640
|
+
const named = outputs.filter((o) => {
|
|
641
|
+
if (readable.includes(o)) return false
|
|
642
|
+
const at = literalPrefix(o)
|
|
643
|
+
if (at === '') return false
|
|
644
|
+
return inputs.some((g) => {
|
|
645
|
+
if (g.startsWith('!')) return false
|
|
646
|
+
const p = literalPrefix(g)
|
|
647
|
+
return p === at || p.startsWith(`${at}/`)
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
return named.length === 0 ? readable : [...readable, ...named]
|
|
651
|
+
}
|
|
652
|
+
|
|
522
653
|
/**
|
|
523
654
|
* Can any positive `workspaceFiles` entry match a path inside one of
|
|
524
655
|
* `dirs` (workspace-relative project dirs)? An entry's reach is its
|
|
@@ -546,14 +677,20 @@ export function workspaceInputsReach(
|
|
|
546
677
|
|
|
547
678
|
/** A glob's leading literal segments, `''` when its first holds a wildcard. */
|
|
548
679
|
function literalPrefix(glob: string): string {
|
|
680
|
+
let prefix = literalPrefixes.get(glob)
|
|
681
|
+
if (prefix !== undefined) return prefix
|
|
549
682
|
const literal: string[] = []
|
|
550
683
|
for (const seg of normalizeGlob(glob).split('/')) {
|
|
551
684
|
if (!isLiteralPattern(seg)) break
|
|
552
685
|
literal.push(seg)
|
|
553
686
|
}
|
|
554
|
-
|
|
687
|
+
literalPrefixes.set(glob, (prefix = literal.join('/')))
|
|
688
|
+
return prefix
|
|
555
689
|
}
|
|
556
690
|
|
|
691
|
+
/** Per glob string, which the configs bound: every task repeats a few. */
|
|
692
|
+
const literalPrefixes = new Map<string, string>()
|
|
693
|
+
|
|
557
694
|
/** What the stability gate reads of a set of project names. */
|
|
558
695
|
interface ProjectNames extends Iterable<string> {
|
|
559
696
|
has(name: string): boolean
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// vx's own steps (reading the lock, keying, uploads, teardown) rendered as
|
|
2
|
+
// tasks (M, 2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
|
|
3
|
+
// `:teardown`. `:` is no task id's separator, so a step never collides with
|
|
4
|
+
// a project named `vx` or a plugin package's own `#setup` task. A step is
|
|
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).
|
|
10
|
+
import type { Logger } from '../report/index.js'
|
|
11
|
+
import type { RunStep } from './run-steps.js'
|
|
12
|
+
|
|
13
|
+
export interface Steps {
|
|
14
|
+
/** Time `work` as step `id` when steps show; else run it as it is. */
|
|
15
|
+
step<T>(id: string, work: () => Promise<T>): Promise<T>
|
|
16
|
+
stepSync<T>(id: string, work: () => T): T
|
|
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[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Steps that tell `log`, or none when nothing renders them (zero cost). */
|
|
24
|
+
export function createSteps(log: Logger, shown: boolean): Steps {
|
|
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 }
|
|
40
|
+
const end = (id: string, t0: number, ok: boolean): void =>
|
|
41
|
+
log.stepEnd?.(id, { durationMs: performance.now() - t0, ok })
|
|
42
|
+
return {
|
|
43
|
+
background,
|
|
44
|
+
ended,
|
|
45
|
+
async step(id, work) {
|
|
46
|
+
const t0 = performance.now()
|
|
47
|
+
log.stepStart?.(id)
|
|
48
|
+
let ok = false
|
|
49
|
+
try {
|
|
50
|
+
const value = await work()
|
|
51
|
+
ok = true
|
|
52
|
+
return value
|
|
53
|
+
} finally {
|
|
54
|
+
end(id, t0, ok)
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
stepSync(id, work) {
|
|
58
|
+
const t0 = performance.now()
|
|
59
|
+
log.stepStart?.(id)
|
|
60
|
+
let ok = false
|
|
61
|
+
try {
|
|
62
|
+
const value = work()
|
|
63
|
+
ok = true
|
|
64
|
+
return value
|
|
65
|
+
} finally {
|
|
66
|
+
end(id, t0, ok)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -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,
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
racyWindowMs,
|
|
14
14
|
resolveInputs,
|
|
15
15
|
type GitFilesCache,
|
|
16
|
+
type TaskInputComponent,
|
|
16
17
|
WORKSPACE_OUTPUT_PREFIX,
|
|
17
18
|
} from '../cache/index.js'
|
|
18
19
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
@@ -52,20 +53,6 @@ export function createHashCache(): HashCache {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* One cache-key component captured at hash time. On a cache MISS the
|
|
57
|
-
* orchestrator persists these to `entry_inputs` (keyed by the entry
|
|
58
|
-
* hash, inside the save transaction) so a later run can diff its inputs
|
|
59
|
-
* against this one (the Tier-3 "why did this re-run?" moat). A cache
|
|
60
|
-
* HIT never saves, so it captures nothing — the warm path is free.
|
|
61
|
-
* Mirrors `Cache.key()`'s fold-site rows one-for-one.
|
|
62
|
-
*/
|
|
63
|
-
export interface TaskInputComponent {
|
|
64
|
-
kind: string
|
|
65
|
-
name: string
|
|
66
|
-
hash: string
|
|
67
|
-
}
|
|
68
|
-
|
|
69
56
|
export interface ComputeHashArgs {
|
|
70
57
|
node: TaskNode
|
|
71
58
|
upstream: TaskOutcome[]
|
|
@@ -94,15 +81,16 @@ export interface ComputeHashArgs {
|
|
|
94
81
|
/**
|
|
95
82
|
* Resolve every input the cache key depends on (file content, env
|
|
96
83
|
* values, upstream hashes, config bytes, forwardArgs) and ask the cache layer to combine them. Used by both
|
|
97
|
-
* `executeTask` for the real run and `plan()` for `--dry
|
|
98
|
-
*
|
|
84
|
+
* `executeTask` for the real run and `plan()` for `--dry`
|
|
85
|
+
* previews.
|
|
99
86
|
*
|
|
100
87
|
* Caller is responsible for handling the group-task case before
|
|
101
|
-
* calling this —
|
|
88
|
+
* calling this — an unkeyed group has no `exec` and no `inputs`, so the
|
|
102
89
|
* key-derivation steps below would all fall back to defaults.
|
|
103
90
|
* `computeGroupKey` is exported for that purpose.
|
|
104
91
|
*/
|
|
105
92
|
export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
93
|
+
if (isKeyless(args.node.config)) return keylessHash(args.node.id)
|
|
106
94
|
const end = span('task hash')
|
|
107
95
|
try {
|
|
108
96
|
return await args.cache.key((await resolveKeyInput(args)).input)
|
|
@@ -111,6 +99,27 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
|
|
|
111
99
|
}
|
|
112
100
|
}
|
|
113
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
|
+
|
|
114
123
|
/**
|
|
115
124
|
* One file the key folded: the digest it folded, and since when (ms since
|
|
116
125
|
* the epoch) that digest is known to be the file's content — the
|
|
@@ -263,14 +272,14 @@ async function resolveKeyInput(
|
|
|
263
272
|
args: ComputeHashArgs,
|
|
264
273
|
): Promise<{ input: CacheKeyInput; listings: InputListing[] }> {
|
|
265
274
|
const cfg = args.node.config
|
|
266
|
-
const cacheCfg
|
|
275
|
+
const cacheCfg = cfg.cache
|
|
267
276
|
const outputs = cacheCfg?.outputs.files ?? []
|
|
268
277
|
|
|
269
278
|
const resolved = await resolveInputs({
|
|
270
279
|
projectDir: args.node.projectDir,
|
|
271
280
|
workspaceRoot: args.workspaceRoot,
|
|
272
281
|
envSource: process.env,
|
|
273
|
-
inputs:
|
|
282
|
+
inputs: cfg.inputs,
|
|
274
283
|
ownOutputs: outputs,
|
|
275
284
|
ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
|
|
276
285
|
nestedProjectDirs: args.nestedProjectDirs,
|
|
@@ -295,7 +304,7 @@ async function resolveKeyInput(
|
|
|
295
304
|
|
|
296
305
|
const upstreamPairs = filterUpstreamHashes(
|
|
297
306
|
args.upstream,
|
|
298
|
-
|
|
307
|
+
cfg.inputs?.tasks,
|
|
299
308
|
args.node.projectName,
|
|
300
309
|
args.node.id,
|
|
301
310
|
)
|
|
@@ -303,7 +312,7 @@ async function resolveKeyInput(
|
|
|
303
312
|
// hash → upstream task id, for capture-row naming only (not folded).
|
|
304
313
|
const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
|
|
305
314
|
// The DEPENDENCY closure, groups expanded — for the executor's input root.
|
|
306
|
-
// Deliberately NOT the filtered set above: `
|
|
315
|
+
// Deliberately NOT the filtered set above: `inputs.tasks` says which
|
|
307
316
|
// upstream KEYS this task's key folds, and what a task may READ is
|
|
308
317
|
// `dependsOn`. Locally every dependency's outputs are on disk before the
|
|
309
318
|
// command runs however the filter is written, so an executor that ships the
|
|
@@ -319,7 +328,7 @@ async function resolveKeyInput(
|
|
|
319
328
|
// also merge the workspace-wide partition's OIDs (keyed by abs
|
|
320
329
|
// path, so the two maps agree wherever they overlap).
|
|
321
330
|
let fileHashes = args.gitFilesCache?.oidsFor(args.node.projectDir)
|
|
322
|
-
if ((
|
|
331
|
+
if ((cfg.inputs?.workspaceFiles?.length ?? 0) > 0) {
|
|
323
332
|
const wsOids = args.gitFilesCache?.oidsFor(args.workspaceRoot)
|
|
324
333
|
if (wsOids !== undefined && wsOids !== fileHashes) {
|
|
325
334
|
fileHashes = fileHashes === undefined ? wsOids : new Map([...wsOids, ...fileHashes])
|
|
@@ -403,12 +412,12 @@ function hashableConfig(cfg: TaskConfig): unknown {
|
|
|
403
412
|
}
|
|
404
413
|
|
|
405
414
|
/**
|
|
406
|
-
* 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`,
|
|
407
416
|
* the default `build` (projects.ts) or a config's — its own inputs too,
|
|
408
417
|
* the way a task's key folds them.
|
|
409
418
|
*/
|
|
410
419
|
export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
|
|
411
|
-
if (args.node.config.
|
|
420
|
+
if (args.node.config.inputs === undefined) return computeGroupHash(args.upstream)
|
|
412
421
|
return computeTaskHash(args)
|
|
413
422
|
}
|
|
414
423
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// task printed nothing".
|
|
22
22
|
|
|
23
23
|
import type { TaskStatus } from '../graph/index.js'
|
|
24
|
-
import type { CacheSource } from '
|
|
24
|
+
import type { CacheSource } from '../graph/index.js'
|
|
25
25
|
|
|
26
26
|
/** Version of the drained-bundle shape below. It is the canonical drained
|
|
27
27
|
* logs format, not one transport's: every sink ships the same object. */
|