@vzn/vx 0.0.630 → 0.0.632
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 +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +1 -1
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +65 -0
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/workspace/workspace.ts +1 -0
package/src/cli/why.ts
CHANGED
|
@@ -9,12 +9,11 @@ import path from 'node:path'
|
|
|
9
9
|
import { Cache } from '../cache/index.js'
|
|
10
10
|
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
11
11
|
import { splitTaskId } from '../graph/index.js'
|
|
12
|
-
import type { Database } from 'bun:sqlite'
|
|
13
12
|
import {
|
|
14
|
-
type CacheKeyDiff,
|
|
15
13
|
cacheKeyDiff,
|
|
16
14
|
explainCacheKeyQuery as explainCacheKey,
|
|
17
15
|
latestRunId,
|
|
16
|
+
rootCauses,
|
|
18
17
|
whyDidThisRerunQuery as whyDidThisRerun,
|
|
19
18
|
resolveRunId,
|
|
20
19
|
} from '../orchestrator/index.js'
|
|
@@ -151,7 +150,10 @@ async function resolveTarget(
|
|
|
151
150
|
const ids = pairs.map((p) => `${p.project}#${p.task}`)
|
|
152
151
|
const invoked = ids.length === 0 && db.query('SELECT 1 FROM invocations LIMIT 1').get() !== null
|
|
153
152
|
if (target.includes('#')) {
|
|
154
|
-
throw new UserError(
|
|
153
|
+
throw new UserError(
|
|
154
|
+
`vx why: no recorded runs for "${target}"${suggest(target, ids, invoked)}`,
|
|
155
|
+
'VX_E_NO_HISTORY',
|
|
156
|
+
)
|
|
155
157
|
}
|
|
156
158
|
const matches = ids.filter((id) => id.endsWith(`#${target}`))
|
|
157
159
|
if (matches.length === 1) return matches[0]!
|
|
@@ -161,10 +163,12 @@ async function resolveTarget(
|
|
|
161
163
|
throw new UserError(
|
|
162
164
|
`vx why: "${target}" ran in ${matches.length} projects — pick one:\n` +
|
|
163
165
|
matches.map((m) => ` ${m}`).join('\n'),
|
|
166
|
+
'VX_E_AMBIGUOUS',
|
|
164
167
|
)
|
|
165
168
|
}
|
|
166
169
|
throw new UserError(
|
|
167
170
|
`vx why: no recorded runs for task "${target}"${suggest(target, ids, invoked)}`,
|
|
171
|
+
'VX_E_NO_HISTORY',
|
|
168
172
|
)
|
|
169
173
|
}
|
|
170
174
|
|
|
@@ -187,9 +191,12 @@ function restoredWord(r: { restored: boolean | null }): string {
|
|
|
187
191
|
|
|
188
192
|
export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
189
193
|
const parsed = parseWhyArgs(args)
|
|
190
|
-
if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}
|
|
194
|
+
if (parsed.error !== undefined) throw new UserError(`vx why: ${parsed.error}`, 'VX_E_USAGE')
|
|
191
195
|
if (parsed.target === undefined) {
|
|
192
|
-
throw new UserError(
|
|
196
|
+
throw new UserError(
|
|
197
|
+
'vx why: <task> required (e.g. vx why app#build, or vx why build)',
|
|
198
|
+
'VX_E_USAGE',
|
|
199
|
+
)
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
const root = await findWorkspaceRoot(process.cwd())
|
|
@@ -206,6 +213,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
206
213
|
if (runId === null) {
|
|
207
214
|
throw new UserError(
|
|
208
215
|
`vx why: no recorded run ${parsed.runId} (vx last --list shows recent runs)`,
|
|
216
|
+
'VX_E_NO_HISTORY',
|
|
209
217
|
)
|
|
210
218
|
}
|
|
211
219
|
} else runId = latestRunId(db, taskId)
|
|
@@ -231,6 +239,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
231
239
|
if (!why.found) {
|
|
232
240
|
throw new UserError(
|
|
233
241
|
`vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last RUNID\` what one ran`,
|
|
242
|
+
'VX_E_NO_HISTORY',
|
|
234
243
|
)
|
|
235
244
|
}
|
|
236
245
|
const diff = cacheKeyDiff(db, runId, taskId)
|
|
@@ -344,38 +353,3 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
344
353
|
}
|
|
345
354
|
|
|
346
355
|
const ROOTS_SHOWN = 5
|
|
347
|
-
|
|
348
|
-
interface RootCause {
|
|
349
|
-
/** From the asked task down to the one whose own inputs moved. */
|
|
350
|
-
chain: string[]
|
|
351
|
-
entries: CacheKeyDiff['entries']
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* The tasks under `taskId`'s moved upstreams whose OWN key components
|
|
356
|
-
* moved in the same run, each with the chain that carried it up. A task
|
|
357
|
-
* visited once is not walked again (a diamond names its root once).
|
|
358
|
-
*/
|
|
359
|
-
function rootCauses(
|
|
360
|
-
db: Database,
|
|
361
|
-
runId: string,
|
|
362
|
-
taskId: string,
|
|
363
|
-
entries: CacheKeyDiff['entries'],
|
|
364
|
-
): RootCause[] {
|
|
365
|
-
const roots: RootCause[] = []
|
|
366
|
-
const seen = new Set([taskId])
|
|
367
|
-
const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
|
|
368
|
-
for (const e of moved) {
|
|
369
|
-
if (e.kind !== 'upstream' || seen.has(e.name)) continue
|
|
370
|
-
seen.add(e.name)
|
|
371
|
-
const next = [...chain, e.name]
|
|
372
|
-
const d = cacheKeyDiff(db, runId, e.name)
|
|
373
|
-
if (!d.found) continue
|
|
374
|
-
const own = d.entries.filter((x) => x.kind !== 'upstream')
|
|
375
|
-
if (own.length > 0) roots.push({ chain: next, entries: own })
|
|
376
|
-
walk(next, d.entries)
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
walk([taskId], entries)
|
|
380
|
-
return roots
|
|
381
|
-
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -849,7 +849,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
849
849
|
}
|
|
850
850
|
}
|
|
851
851
|
const reader = stream.getReader()
|
|
852
|
-
const decoder = new TextDecoder()
|
|
852
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
853
853
|
try {
|
|
854
854
|
while (true) {
|
|
855
855
|
const { value, done } = await reader.read()
|
|
@@ -1097,12 +1097,17 @@ export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
|
|
|
1097
1097
|
export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
|
|
1098
1098
|
|
|
1099
1099
|
const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
|
|
1100
|
+
const size = (n: number): string => (n < 1024 * 1024 ? `${n / 1024} KiB` : mib(n))
|
|
1100
1101
|
|
|
1101
1102
|
/** The line that stands where the dropped middle was. */
|
|
1102
|
-
export function droppedOutputLine(
|
|
1103
|
+
export function droppedOutputLine(
|
|
1104
|
+
dropped: number,
|
|
1105
|
+
headChars = CAPTURE_HEAD_CHARS,
|
|
1106
|
+
tailChars = CAPTURE_TAIL_CHARS,
|
|
1107
|
+
): string {
|
|
1103
1108
|
return (
|
|
1104
|
-
`\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${
|
|
1105
|
-
`and the last ${
|
|
1109
|
+
`\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${size(headChars)} ` +
|
|
1110
|
+
`and the last ${size(tailChars)} of a task's output\n`
|
|
1106
1111
|
)
|
|
1107
1112
|
}
|
|
1108
1113
|
|
|
@@ -1145,12 +1150,17 @@ export class BoundedCapture {
|
|
|
1145
1150
|
private tailLen = 0
|
|
1146
1151
|
private dropped = 0
|
|
1147
1152
|
|
|
1153
|
+
constructor(
|
|
1154
|
+
private readonly headChars = CAPTURE_HEAD_CHARS,
|
|
1155
|
+
private readonly tailChars = CAPTURE_TAIL_CHARS,
|
|
1156
|
+
) {}
|
|
1157
|
+
|
|
1148
1158
|
push(text: string, err = false): void {
|
|
1149
1159
|
// Once the tail has begun the head is closed, even a unit short of its
|
|
1150
1160
|
// bound (the surrogate case below): a later short chunk fit there and
|
|
1151
1161
|
// was retained ahead of the chunk before it.
|
|
1152
|
-
if (this.tailLen === 0 && this.headLen <
|
|
1153
|
-
let room =
|
|
1162
|
+
if (this.tailLen === 0 && this.headLen < this.headChars) {
|
|
1163
|
+
let room = this.headChars - this.headLen
|
|
1154
1164
|
if (text.length <= room) {
|
|
1155
1165
|
this.head.push({ text, err })
|
|
1156
1166
|
this.headLen += text.length
|
|
@@ -1165,9 +1175,9 @@ export class BoundedCapture {
|
|
|
1165
1175
|
}
|
|
1166
1176
|
this.tail.push({ text, err })
|
|
1167
1177
|
this.tailLen += text.length
|
|
1168
|
-
while (this.tailLen >
|
|
1178
|
+
while (this.tailLen > this.tailChars) {
|
|
1169
1179
|
const first = this.tail[0]!
|
|
1170
|
-
const excess = this.tailLen -
|
|
1180
|
+
const excess = this.tailLen - this.tailChars
|
|
1171
1181
|
if (first.text.length <= excess) {
|
|
1172
1182
|
this.tail.shift()
|
|
1173
1183
|
this.tailLen -= first.text.length
|
|
@@ -1185,7 +1195,14 @@ export class BoundedCapture {
|
|
|
1185
1195
|
chunks(): CapturedChunk[] {
|
|
1186
1196
|
return this.dropped === 0
|
|
1187
1197
|
? [...this.head, ...this.tail]
|
|
1188
|
-
: [
|
|
1198
|
+
: [
|
|
1199
|
+
...this.head,
|
|
1200
|
+
{
|
|
1201
|
+
text: droppedOutputLine(this.dropped, this.headChars, this.tailChars),
|
|
1202
|
+
err: false,
|
|
1203
|
+
},
|
|
1204
|
+
...this.tail,
|
|
1205
|
+
]
|
|
1189
1206
|
}
|
|
1190
1207
|
|
|
1191
1208
|
text(): string {
|
|
@@ -1213,7 +1230,7 @@ export async function streamToString(
|
|
|
1213
1230
|
if (!stream || typeof stream === 'number') return ''
|
|
1214
1231
|
const full = new BoundedCapture()
|
|
1215
1232
|
const reader = stream.getReader()
|
|
1216
|
-
const decoder = new TextDecoder()
|
|
1233
|
+
const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
1217
1234
|
// On abort, cancel the read so a pending `reader.read()` resolves
|
|
1218
1235
|
// `done` and we return whatever we captured. Needed for the timeout
|
|
1219
1236
|
// path: SIGTERMing `sh` doesn't close the pipe if an orphaned
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -140,6 +140,12 @@ export interface TaskOutcome {
|
|
|
140
140
|
* of reading it as a signal (item 268).
|
|
141
141
|
*/
|
|
142
142
|
timedOut?: true
|
|
143
|
+
/**
|
|
144
|
+
* On a `failed` outcome of a command that ran: the final attempt's
|
|
145
|
+
* output, both streams, as `encodeOutputLog` stores it, secrets masked,
|
|
146
|
+
* bounded to its first 8 KiB and last 56 KiB. The run's history keeps it.
|
|
147
|
+
*/
|
|
148
|
+
failedOutput?: string
|
|
143
149
|
/**
|
|
144
150
|
* On a failed persistent task: why it never became ready — the readiness
|
|
145
151
|
* deadline fired, the child exited first (`exitCode` is then its own), or
|
|
@@ -255,6 +261,15 @@ export interface ScheduleOptions {
|
|
|
255
261
|
* slot admits other work at once. Undefined: nothing owed.
|
|
256
262
|
*/
|
|
257
263
|
settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
|
|
264
|
+
/**
|
|
265
|
+
* The outcome of a task the caller settles in place, or undefined to
|
|
266
|
+
* dispatch it through `execute`. Asked of an exec-tier task the lane has
|
|
267
|
+
* room for, after the skip check; its outcome lands in the same tick, it
|
|
268
|
+
* holds no slot, and `onStart` / `onFinish` hear it as any other. An
|
|
269
|
+
* unkeyed group runs nothing, and the dispatch, the slot and the promise
|
|
270
|
+
* around it were ~10 µs of each of a warm run's 1,090 groups (X-192).
|
|
271
|
+
*/
|
|
272
|
+
settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
|
|
258
273
|
/**
|
|
259
274
|
* Optional priority override: callers pass their own per-node weight
|
|
260
275
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -749,6 +764,16 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
749
764
|
})
|
|
750
765
|
}
|
|
751
766
|
|
|
767
|
+
// A throw falls back to the dispatch, whose `execute` reports it.
|
|
768
|
+
const settleInPlace = (node: TaskNode, upstream: TaskOutcome[]): TaskOutcome | undefined => {
|
|
769
|
+
if (options.settleNow === undefined) return undefined
|
|
770
|
+
try {
|
|
771
|
+
return options.settleNow(node, upstream)
|
|
772
|
+
} catch {
|
|
773
|
+
return undefined
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
752
777
|
const tick = (): void => {
|
|
753
778
|
if (resolved) return
|
|
754
779
|
// Exec-tier tasks parked THIS tick on a refused admission. Within
|
|
@@ -844,6 +869,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
844
869
|
|
|
845
870
|
const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
|
|
846
871
|
readyAt.delete(id)
|
|
872
|
+
const now = inRestoreTier(id) ? undefined : settleInPlace(node, upstream)
|
|
873
|
+
if (now !== undefined) {
|
|
874
|
+
try {
|
|
875
|
+
onStart?.(node)
|
|
876
|
+
} catch (err) {
|
|
877
|
+
const m = err instanceof Error ? err.message : String(err)
|
|
878
|
+
process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
|
|
879
|
+
}
|
|
880
|
+
finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
|
|
881
|
+
continue
|
|
882
|
+
}
|
|
847
883
|
const leave = admit(id)
|
|
848
884
|
// Listed as running on dispatch, so the policy's next ask in this
|
|
849
885
|
// tick sees it; the completion callbacks unlist it.
|
package/src/graph/task-graph.ts
CHANGED
|
@@ -1670,7 +1670,7 @@ function detectCycle(nodes: Map<string, TaskNode>): void {
|
|
|
1670
1670
|
for (let i = 0; i < stack.length; i += 2) path.push(idsArr[stack[i]!]!)
|
|
1671
1671
|
const startInPath = path.indexOf(idsArr[depIdx]!)
|
|
1672
1672
|
const cycle = [...path.slice(startInPath), idsArr[depIdx]!].join(' -> ')
|
|
1673
|
-
throw new UserError(`Cycle detected in task graph: ${cycle}
|
|
1673
|
+
throw new UserError(`Cycle detected in task graph: ${cycle}`, 'VX_E_CYCLE')
|
|
1674
1674
|
}
|
|
1675
1675
|
color[depIdx] = GRAY
|
|
1676
1676
|
stack.push(depIdx, 0)
|
package/src/index.ts
CHANGED
|
@@ -239,8 +239,25 @@ export type {
|
|
|
239
239
|
// The serializable run result and its per-task views (what `run()` returns).
|
|
240
240
|
export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
|
|
241
241
|
|
|
242
|
-
// The
|
|
243
|
-
// answer, which `@vzn/vx-mcp` serves. The event bus /
|
|
244
|
-
// other history readers (`listRuns`, `getRun`, …) left the
|
|
245
|
-
// with no consumer; the telemetry seam above is the
|
|
246
|
-
|
|
242
|
+
// The run-history queries a reader outside the CLI takes today: `vx why`'s
|
|
243
|
+
// answer, a run's failures and a task's log, which `@vzn/vx-mcp` serves. The event bus /
|
|
244
|
+
// wire form and the other history readers (`listRuns`, `getRun`, …) left the
|
|
245
|
+
// façade 2026-09-10 with no consumer; the telemetry seam above is the
|
|
246
|
+
// canonical export path.
|
|
247
|
+
export {
|
|
248
|
+
cacheKeyDiff,
|
|
249
|
+
latestRunId,
|
|
250
|
+
resolveRunId,
|
|
251
|
+
rootCauses,
|
|
252
|
+
runFailures,
|
|
253
|
+
taskLog,
|
|
254
|
+
whyDidThisRerunQuery,
|
|
255
|
+
} from './orchestrator/index.js'
|
|
256
|
+
export type {
|
|
257
|
+
CacheKeyDiff,
|
|
258
|
+
OutputLocation,
|
|
259
|
+
RootCause,
|
|
260
|
+
RunFailures,
|
|
261
|
+
TaskFailure,
|
|
262
|
+
TaskLog,
|
|
263
|
+
} from './orchestrator/index.js'
|
|
@@ -32,11 +32,21 @@ export interface TaintTracker {
|
|
|
32
32
|
* bytes nothing vouches for. Its order-only deps count too.
|
|
33
33
|
*/
|
|
34
34
|
judge: (node: TaskNode, upstream: TaskOutcome[]) => boolean
|
|
35
|
+
/**
|
|
36
|
+
* For a task `judge` called tainted: the task at the root of it, a
|
|
37
|
+
* failed one or a dependency `--exclude-dependencies` dropped. Its run
|
|
38
|
+
* row names it (`blocked_by`), so no reader counts the row on its key.
|
|
39
|
+
*/
|
|
40
|
+
cause: (node: TaskNode, upstream: TaskOutcome[]) => string
|
|
35
41
|
/** Every settled outcome, the scheduler's `onFinish`. */
|
|
36
42
|
settled: (outcome: TaskOutcome) => void
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
const UNTAINTED: TaintTracker = {
|
|
45
|
+
const UNTAINTED: TaintTracker = {
|
|
46
|
+
judge: () => false,
|
|
47
|
+
cause: (node) => node.id,
|
|
48
|
+
settled: () => {},
|
|
49
|
+
}
|
|
40
50
|
|
|
41
51
|
/**
|
|
42
52
|
* Records which tasks ran behind a failure, or on the key of a dependency
|
|
@@ -61,6 +71,7 @@ export function taintTracker(
|
|
|
61
71
|
if (!continueAlways && seeds.size === 0) return UNTAINTED
|
|
62
72
|
const outcomes = new Map<string, TaskOutcome>()
|
|
63
73
|
const memo = new Map<string, boolean>()
|
|
74
|
+
const causes = new Map<string, string>()
|
|
64
75
|
const failed = (u: TaskOutcome): boolean =>
|
|
65
76
|
continueAlways && (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped')
|
|
66
77
|
const through = (u: TaskOutcome | undefined): boolean =>
|
|
@@ -113,6 +124,23 @@ export function taintTracker(
|
|
|
113
124
|
seeds.has(node.id) ||
|
|
114
125
|
upstream.some(through) ||
|
|
115
126
|
node.orderOnly?.some((d) => through(outcomes.get(d))) === true,
|
|
127
|
+
cause: (node, upstream) => {
|
|
128
|
+
// A restore-tier task's upstream holds holes (see `judge`).
|
|
129
|
+
const deps = upstream.filter((u) => u !== undefined)
|
|
130
|
+
for (const d of node.orderOnly ?? []) {
|
|
131
|
+
const u = outcomes.get(d)
|
|
132
|
+
if (u !== undefined) deps.push(u)
|
|
133
|
+
}
|
|
134
|
+
const root =
|
|
135
|
+
deps.find(failed) ?? deps.find((u) => taintOf(u.node.id)) ?? node.excludedUpstream?.[0]
|
|
136
|
+
let c: string
|
|
137
|
+
if (root === undefined) c = node.id
|
|
138
|
+
else if (failed(root))
|
|
139
|
+
c = root.status === 'skipped' ? (root.blockedBy ?? root.node.id) : root.node.id
|
|
140
|
+
else c = causes.get(root.node.id) ?? root.node.id
|
|
141
|
+
causes.set(node.id, c)
|
|
142
|
+
return c
|
|
143
|
+
},
|
|
116
144
|
settled: (outcome) => {
|
|
117
145
|
outcomes.set(outcome.node.id, outcome)
|
|
118
146
|
},
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// behind `^build` runs when `ui#build` (or what it depends on) is reached,
|
|
5
5
|
// and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
|
|
6
6
|
|
|
7
|
+
import path from 'node:path'
|
|
7
8
|
import type { WorkspaceRules } from '../config.js'
|
|
8
9
|
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
9
10
|
import {
|
|
@@ -54,6 +55,30 @@ function wholeProjects(
|
|
|
54
55
|
return whole
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Why `--affected` kept a requested task (`vx run --affected --dry`):
|
|
60
|
+
* `input`, a changed `file` is one of its declared inputs; `project`, its
|
|
61
|
+
* project changed and the task reads it whole (an uncached task, a path no
|
|
62
|
+
* cached task declares, or no single `file`: a lockfile claim, a manifest
|
|
63
|
+
* edge, a config import); `package`, a `^` edge passes through `project`, a
|
|
64
|
+
* changed package with no such task; `named`, asked as `pkg#task`;
|
|
65
|
+
* `selected`, another filter selected its project outright. `via` is the
|
|
66
|
+
* `dependsOn` chain, nearest first, to the task the change seeded.
|
|
67
|
+
*/
|
|
68
|
+
export interface AffectedReason {
|
|
69
|
+
kind: 'input' | 'project' | 'package' | 'named' | 'selected'
|
|
70
|
+
/** Workspace-relative. */
|
|
71
|
+
file?: string
|
|
72
|
+
project?: string
|
|
73
|
+
via?: string[]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Collects each kept task's `AffectedReason` when given; a run without one pays nothing. */
|
|
77
|
+
export interface AffectedExplain {
|
|
78
|
+
workspaceRoot: string
|
|
79
|
+
reasons: Map<string, AffectedReason>
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/**
|
|
58
83
|
* The task ids of `ids` whose closure the change reaches, in their order.
|
|
59
84
|
* A group seeds nothing (it runs nothing) unless it is keyed, as the default
|
|
@@ -69,8 +94,16 @@ export function affectedRoots(
|
|
|
69
94
|
changes: AffectedChanges,
|
|
70
95
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
71
96
|
packageGraph: PackageGraph,
|
|
97
|
+
explain?: AffectedExplain,
|
|
72
98
|
): string[] {
|
|
73
99
|
const whole = wholeProjects(changes, projects)
|
|
100
|
+
const seeds = explain === undefined ? null : new Map<string, AffectedReason>()
|
|
101
|
+
const through = explain === undefined ? null : new Map<string, string>()
|
|
102
|
+
const inProject = (name: string, rel: string | undefined): AffectedReason => {
|
|
103
|
+
if (rel === undefined) return { kind: 'project' }
|
|
104
|
+
const dir = path.relative(explain!.workspaceRoot, projects.get(name)!.dir)
|
|
105
|
+
return { kind: 'project', file: dir === '' ? rel : path.posix.join(dir, rel) }
|
|
106
|
+
}
|
|
74
107
|
// The builder's `^name` walk (task-graph.ts), keeping only the packages
|
|
75
108
|
// it passes through: a holder's own node is already in `deps`.
|
|
76
109
|
const passesChanged = (n: TaskNode): boolean => {
|
|
@@ -89,7 +122,10 @@ export function affectedRoots(
|
|
|
89
122
|
if (visited.has(target)) continue
|
|
90
123
|
visited.add(target)
|
|
91
124
|
if (holds(target)) continue
|
|
92
|
-
if (changes.projects.has(target))
|
|
125
|
+
if (changes.projects.has(target)) {
|
|
126
|
+
seeds?.set(n.id, { kind: 'package', project: target })
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
93
129
|
frontier.push(...packageGraph.directDeps(target))
|
|
94
130
|
}
|
|
95
131
|
}
|
|
@@ -99,22 +135,35 @@ export function affectedRoots(
|
|
|
99
135
|
const cache = n.config.cache
|
|
100
136
|
// Asked of every node, not only the changed projects' (the
|
|
101
137
|
// `workspaceFiles` owners): a `graph` hook may have given the glob.
|
|
102
|
-
if (
|
|
103
|
-
cache
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
138
|
+
if (cache !== undefined) {
|
|
139
|
+
const file = changes.changed.find((rel) => declaresInput(cache, null, rel))
|
|
140
|
+
const dir =
|
|
141
|
+
file === undefined
|
|
142
|
+
? (changes.nested ?? []).find((d) => workspaceFilesReachInto(cache, d))
|
|
143
|
+
: undefined
|
|
144
|
+
if (file !== undefined || dir !== undefined) {
|
|
145
|
+
seeds?.set(n.id, { kind: 'input', file: (file ?? dir)! })
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
108
148
|
}
|
|
109
149
|
if (!changes.projects.has(n.projectName)) return false
|
|
150
|
+
const rels = changes.paths.get(n.projectName) ?? []
|
|
110
151
|
// A group runs nothing; only the default `build` (projects.ts) is keyed.
|
|
111
152
|
// An uncached task reads its project: a root file another task of it
|
|
112
153
|
// declares (`workspaceFiles`) is no change there.
|
|
113
154
|
if (cache === undefined) {
|
|
114
|
-
|
|
155
|
+
const hit = !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
|
|
156
|
+
if (hit) seeds?.set(n.id, inProject(n.projectName, rels[0]))
|
|
157
|
+
return hit
|
|
158
|
+
}
|
|
159
|
+
if (whole.has(n.projectName)) {
|
|
160
|
+
seeds?.set(n.id, inProject(n.projectName, undefined))
|
|
161
|
+
return true
|
|
115
162
|
}
|
|
116
|
-
|
|
117
|
-
|
|
163
|
+
const rel = rels.find((r) => declaresInput(cache, r, null))
|
|
164
|
+
if (rel === undefined) return false
|
|
165
|
+
if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
|
|
166
|
+
return true
|
|
118
167
|
}
|
|
119
168
|
const reached = new Map<string, boolean>()
|
|
120
169
|
// Post-order on an explicit stack: a closure is as deep as the graph, and
|
|
@@ -145,6 +194,7 @@ export function affectedRoots(
|
|
|
145
194
|
frame[1] = i
|
|
146
195
|
if (i === deps.length || reached.get(deps[i]!) === true) {
|
|
147
196
|
reached.set(id, i < deps.length)
|
|
197
|
+
if (i < deps.length) through?.set(id, deps[i]!)
|
|
148
198
|
stack.pop()
|
|
149
199
|
continue
|
|
150
200
|
}
|
|
@@ -152,7 +202,20 @@ export function affectedRoots(
|
|
|
152
202
|
}
|
|
153
203
|
return reached.get(root)!
|
|
154
204
|
}
|
|
155
|
-
|
|
205
|
+
const kept = ids.filter(reaches)
|
|
206
|
+
if (explain !== undefined) {
|
|
207
|
+
for (const root of kept) {
|
|
208
|
+
const via: string[] = []
|
|
209
|
+
let id = root
|
|
210
|
+
for (let next = through!.get(id); next !== undefined; next = through!.get(id)) {
|
|
211
|
+
via.push(next)
|
|
212
|
+
id = next
|
|
213
|
+
}
|
|
214
|
+
const seed = seeds!.get(id)!
|
|
215
|
+
explain.reasons.set(root, via.length > 0 ? { ...seed, via } : seed)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return kept
|
|
156
219
|
}
|
|
157
220
|
|
|
158
221
|
/**
|
|
@@ -167,15 +230,21 @@ export function keptByAffected<R extends { project: string; task: string }>(
|
|
|
167
230
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
168
231
|
packageGraph: PackageGraph,
|
|
169
232
|
keep: { named?: ReadonlySet<string>; outright?: ReadonlySet<string> } = {},
|
|
233
|
+
explain?: AffectedExplain,
|
|
170
234
|
): R[] {
|
|
171
235
|
const ids = requested.map((r) => `${r.project}#${r.task}`)
|
|
172
|
-
const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph))
|
|
173
|
-
return requested.filter(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
keep.
|
|
177
|
-
|
|
178
|
-
|
|
236
|
+
const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph, explain))
|
|
237
|
+
return requested.filter((r, i) => {
|
|
238
|
+
const id = ids[i]!
|
|
239
|
+
const why: AffectedReason['kind'] | null =
|
|
240
|
+
keep.named?.has(id) === true
|
|
241
|
+
? 'named'
|
|
242
|
+
: keep.outright?.has(r.project) === true
|
|
243
|
+
? 'selected'
|
|
244
|
+
: null
|
|
245
|
+
if (why !== null) explain?.reasons.set(id, { kind: why })
|
|
246
|
+
return why !== null || reached.has(id)
|
|
247
|
+
})
|
|
179
248
|
}
|
|
180
249
|
|
|
181
250
|
/**
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import os from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { Cache, CACHE_VERSION, SCHEMA_VERSION } from '../cache/index.js'
|
|
11
|
-
import { PLUGIN_HOOKS } from '../config.js'
|
|
11
|
+
import { PLUGIN_HOOKS, type WorkspaceConfig } from '../config.js'
|
|
12
12
|
import {
|
|
13
13
|
cgroupCpuQuota,
|
|
14
14
|
cgroupMemoryLimitBytes,
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
machineMemoryBytes,
|
|
18
18
|
machineParallelism,
|
|
19
19
|
maskedLine,
|
|
20
|
+
parseSize,
|
|
20
21
|
relPosix,
|
|
21
22
|
} from '../util/index.js'
|
|
22
23
|
import { VERSION } from '../version.js'
|
|
@@ -93,6 +94,8 @@ export interface InfoFacts {
|
|
|
93
94
|
schemaVersion: string
|
|
94
95
|
cacheEntries: number
|
|
95
96
|
cacheBytes: number
|
|
97
|
+
/** The workspace's `cacheRetention`, with `maxSize` in bytes; null when none is declared. */
|
|
98
|
+
cacheRetention: { olderThan?: string; maxSize?: string; maxBytes?: number } | null
|
|
96
99
|
orphans: { artifacts: number; bytes: number }
|
|
97
100
|
runs24h: number
|
|
98
101
|
hits24h: number
|
|
@@ -250,6 +253,7 @@ async function collectWorkspaceInfo(
|
|
|
250
253
|
schemaVersion: SCHEMA_VERSION,
|
|
251
254
|
cacheEntries: stats.entryCount,
|
|
252
255
|
cacheBytes: stats.totalBytes,
|
|
256
|
+
cacheRetention: retentionFact(workspaceConfig?.cacheRetention),
|
|
253
257
|
// A row-less artifact is indexed again only when its task asks for its
|
|
254
258
|
// key; the rest only `vx cache prune` reclaims (after an upgrade's
|
|
255
259
|
// schema reset, most often).
|
|
@@ -407,3 +411,12 @@ async function countLoadableTasks(
|
|
|
407
411
|
errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
|
|
408
412
|
return { tasks, sandboxed, errors }
|
|
409
413
|
}
|
|
414
|
+
|
|
415
|
+
function retentionFact(r: WorkspaceConfig['cacheRetention']): InfoFacts['cacheRetention'] {
|
|
416
|
+
if (r === undefined) return null
|
|
417
|
+
return {
|
|
418
|
+
...(r.olderThan !== undefined ? { olderThan: r.olderThan } : {}),
|
|
419
|
+
// Validated at load, so it parses.
|
|
420
|
+
...(r.maxSize !== undefined ? { maxSize: r.maxSize, maxBytes: parseSize(r.maxSize)! } : {}),
|
|
421
|
+
}
|
|
422
|
+
}
|