@vzn/vx 0.0.631 → 0.0.633

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.
Files changed (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -0
@@ -0,0 +1,192 @@
1
+ // A failed run's task output and the files it names, so an agent reads why
2
+ // a task failed without the terminal (`vx last --format json`, vx-mcp's
3
+ // getFailures). One file per failed run, `<cacheDir>/failures/<runId>.json`,
4
+ // the newest 50 kept: files, not cache.db, so the index layout (and every
5
+ // shared store under it) stays as it was.
6
+
7
+ import type { Database } from 'bun:sqlite'
8
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
9
+ import path from 'node:path'
10
+ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
11
+ import { decodeOutputLog } from './output-log.js'
12
+ import { splitTaskId } from '../util/index.js'
13
+ import { fileLocations, fileMemo, type OutputLocation } from './path-links.js'
14
+
15
+ /** Failed runs whose output is kept; run ids are UUIDv7, so names sort by age. */
16
+ const KEPT = 50
17
+
18
+ /** A failed task of a run and what it said. */
19
+ export interface TaskFailure {
20
+ taskId: string
21
+ exitCode: number
22
+ timedOut?: true
23
+ /** Plain text, bounded, secrets masked; '' when the run kept none. */
24
+ output: string
25
+ locations: OutputLocation[]
26
+ }
27
+
28
+ export interface RunFailures {
29
+ runId: string
30
+ tasks: TaskFailure[]
31
+ }
32
+
33
+ interface StoredFailure extends TaskFailure {
34
+ project: string
35
+ task: string
36
+ }
37
+
38
+ const dirOf = (cacheDir: string): string => path.join(cacheDir, 'failures')
39
+
40
+ /** Each failed outcome's output as plain text and the files it names. */
41
+ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
42
+ const isFile = fileMemo()
43
+ const out: StoredFailure[] = []
44
+ for (const o of outcomes) {
45
+ if (o.failedOutput === undefined || isGroupTask(o.node)) continue
46
+ const output = Bun.stripANSI(
47
+ decodeOutputLog(o.failedOutput)
48
+ .map((c) => c.text)
49
+ .join(''),
50
+ )
51
+ out.push({
52
+ taskId: o.node.id,
53
+ project: o.node.projectName,
54
+ task: o.node.taskName,
55
+ exitCode: o.exitCode,
56
+ ...(o.timedOut === true ? { timedOut: true as const } : {}),
57
+ output,
58
+ locations: fileLocations(output, o.node.projectDir, isFile),
59
+ })
60
+ }
61
+ return out
62
+ }
63
+
64
+ /**
65
+ * Write the run's failures, when any outcome carries output, and drop all
66
+ * but the newest `KEPT` files. Atomic: a reader sees the whole file or none.
67
+ */
68
+ export function writeRunFailures(
69
+ cacheDir: string,
70
+ runId: string,
71
+ outcomes: readonly TaskOutcome[],
72
+ ): void {
73
+ const tasks = failuresOf(outcomes)
74
+ if (tasks.length === 0) return
75
+ const dir = dirOf(cacheDir)
76
+ mkdirSync(dir, { recursive: true })
77
+ const file = path.join(dir, `${runId}.json`)
78
+ const tmp = `${file}.${process.pid}.tmp`
79
+ writeFileSync(tmp, JSON.stringify({ runId, tasks }))
80
+ renameSync(tmp, file)
81
+ const names = readdirSync(dir)
82
+ .filter((n) => n.endsWith('.json'))
83
+ .sort()
84
+ for (const n of names.slice(0, -KEPT)) rmSync(path.join(dir, n), { force: true })
85
+ }
86
+
87
+ /**
88
+ * The failed tasks of `runId`, or of the latest failed run when omitted;
89
+ * null when there is no such run. A failed task the file lacks (an older
90
+ * run, a write the disk refused) reads `output: ''`, `locations: []`.
91
+ */
92
+ export function runFailures(cacheDir: string, db: Database, runId?: string): RunFailures | null {
93
+ const id =
94
+ runId ??
95
+ (
96
+ db
97
+ .query('SELECT run_id FROM invocations WHERE exit_ok = 0 ORDER BY rowid DESC LIMIT 1')
98
+ .get() as { run_id: string } | null
99
+ )?.run_id
100
+ if (id === undefined) return null
101
+ const kept = new Map<string, StoredFailure>()
102
+ try {
103
+ const stored = JSON.parse(
104
+ readFileSync(path.join(dirOf(cacheDir), `${id}.json`), 'utf8'),
105
+ ) as RunFailures & { tasks: StoredFailure[] }
106
+ for (const t of stored.tasks) kept.set(t.taskId, t)
107
+ } catch (err) {
108
+ // No file: the run kept no output.
109
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
110
+ }
111
+ const rows = db
112
+ .query(
113
+ `SELECT project, task, exit_code AS exitCode, timed_out AS timedOut FROM runs
114
+ WHERE run_id = ? AND status = 'failed' ORDER BY id`,
115
+ )
116
+ .all(id) as { project: string; task: string; exitCode: number; timedOut: number | null }[]
117
+ return {
118
+ runId: id,
119
+ tasks: rows.map((r) => {
120
+ const taskId = `${r.project}#${r.task}`
121
+ const k = kept.get(taskId)
122
+ return {
123
+ taskId,
124
+ exitCode: r.exitCode,
125
+ ...(r.timedOut === 1 ? { timedOut: true as const } : {}),
126
+ output: k?.output ?? '',
127
+ locations: k?.locations ?? [],
128
+ }
129
+ }),
130
+ }
131
+ }
132
+
133
+ /** One task's output in a recorded run, as `vx last --log` and getTaskLog read it. */
134
+ export interface TaskLog {
135
+ runId: string
136
+ taskId: string
137
+ status: string
138
+ /**
139
+ * Where the output came from: the run's kept failure output, the cache
140
+ * entry the task saved or hit (masked as it was replayed), or null when
141
+ * vx kept none (an uncached task that passed, a skip).
142
+ */
143
+ source: 'failure' | 'cache' | null
144
+ /** Plain text; '' when `source` is null. The files it names: `runFailures`. */
145
+ output: string
146
+ }
147
+
148
+ /**
149
+ * `taskId`'s output in `runId`, or in the latest run that recorded the
150
+ * task when omitted; null when no such run recorded it.
151
+ */
152
+ export function taskLog(
153
+ cacheDir: string,
154
+ db: Database,
155
+ taskId: string,
156
+ runId?: string,
157
+ ): TaskLog | null {
158
+ const [project, task] = splitTaskId(taskId)
159
+ if (project === '' || task === '') return null
160
+ const row = db
161
+ .query(
162
+ `SELECT run_id AS runId, hash, status FROM runs
163
+ WHERE project = ? AND task = ? AND run_id IS NOT NULL ${runId === undefined ? '' : 'AND run_id = ?'}
164
+ ORDER BY id DESC LIMIT 1`,
165
+ )
166
+ .get(...(runId === undefined ? [project, task] : [project, task, runId])) as {
167
+ runId: string
168
+ hash: string
169
+ status: string
170
+ } | null
171
+ if (row === null) return null
172
+ const base = { runId: row.runId, taskId, status: row.status }
173
+ if (row.status === 'failed') {
174
+ const kept = runFailures(cacheDir, db, row.runId)?.tasks.find((t) => t.taskId === taskId)
175
+ if (kept !== undefined && kept.output !== '') {
176
+ return { ...base, source: 'failure', output: kept.output }
177
+ }
178
+ } else if (row.hash !== '') {
179
+ const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
180
+ stdout: string
181
+ } | null
182
+ if (stored !== null) {
183
+ const output = Bun.stripANSI(
184
+ decodeOutputLog(stored.stdout)
185
+ .map((c) => c.text)
186
+ .join(''),
187
+ )
188
+ return { ...base, source: 'cache', output }
189
+ }
190
+ }
191
+ return { ...base, source: null, output: '' }
192
+ }
@@ -14,8 +14,11 @@ import { isCacheHit, taskTelemetryOf, type TaskTelemetry } from './telemetry.js'
14
14
 
15
15
  export interface RunRecordsInput {
16
16
  outcomes: readonly TaskOutcome[]
17
- /** Tasks that ran behind a failed dependency (`--continue=always`). */
18
- tainted?: ReadonlySet<string>
17
+ /**
18
+ * Tasks that ran behind a failed dependency (`--continue=always`) or one
19
+ * `--exclude-dependencies` dropped, each with the task at the root of it.
20
+ */
21
+ tainted?: ReadonlyMap<string, string>
19
22
  runId: string
20
23
  /** Wall-clock ms at run start; every per-task ns offset is anchored to it. */
21
24
  startedAtMs: number
@@ -67,12 +70,9 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
67
70
  // aborted (killed by a shutdown signal) isn't a real run.
68
71
  if (o.status === 'aborted') continue
69
72
  if (input.withTelemetry) telemetryTasks.push(taskTelemetryOf(o))
73
+ const blockedBy = o.blockedBy ?? input.tainted?.get(o.node.id)
70
74
  runs.push({
71
- // A failure behind a failed dependency is recorded keyless (`''`, which
72
- // the key readers skip): the flaky list asks a key whether it failed.
73
- ...(o.hash !== undefined && !(o.status === 'failed' && input.tainted?.has(o.node.id) === true)
74
- ? { hash: o.hash }
75
- : {}),
75
+ ...(o.hash !== undefined ? { hash: o.hash } : {}),
76
76
  project: o.node.projectName,
77
77
  task: o.node.taskName,
78
78
  status: o.status,
@@ -107,7 +107,9 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
107
107
  ...(isCacheHit(o.status) ? { restored: o.restored === true } : {}),
108
108
  ...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
109
109
  cached: o.node.config.cache !== undefined,
110
- ...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
110
+ // A task that ran behind a failure names it too, so the flakiness
111
+ // projection counts its outcome on no key (failure-mode.ts).
112
+ ...(blockedBy !== undefined ? { blockedBy } : {}),
111
113
  ...(o.timedOut === true ? { timedOut: true as const } : {}),
112
114
  ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
113
115
  ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
@@ -48,13 +48,14 @@ import {
48
48
  machineParallelism,
49
49
  teardownTimeoutMs,
50
50
  secretMask,
51
+ isFsRefusal,
51
52
  } from '../util/index.js'
52
53
  import { keyedProjects } from './keyed-projects.js'
53
54
  import { isDefaultBuild } from './projects.js'
54
55
  import { prepareSandbox } from './sandbox-request.js'
55
56
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
56
57
  import { admitTasks, taintTracker } from './admission.js'
57
- import type { ExecuteArgs } from './execute-task.js'
58
+ import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
58
59
  import { excludedTaint } from './excluded-keys.js'
59
60
  import { keyUpstream } from './upstream.js'
60
61
  import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
@@ -70,6 +71,7 @@ import { formatPersistentList } from './framed-output.js'
70
71
  import { createForecast } from './forecast.js'
71
72
  import { LocalHistoryProvider } from './history.js'
72
73
  import { plan, type RunPlan } from './plan.js'
74
+ import type { AffectedReason } from './affected-tasks.js'
73
75
  import { prepareRun, type PreparedRun } from './prepare.js'
74
76
  import { acquireRunLock } from './run-lock.js'
75
77
  import {
@@ -103,6 +105,7 @@ import {
103
105
  } from './local-shortcircuit.js'
104
106
  import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
105
107
 
108
+ import { writeRunFailures } from './run-failures.js'
106
109
  import { assembleRunRecords } from './run-records.js'
107
110
  import {
108
111
  hasEnded,
@@ -111,7 +114,12 @@ import {
111
114
  shutdownPersistent,
112
115
  takeHeldServers,
113
116
  } from './persistent.js'
114
- import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
117
+ import {
118
+ runSummaryJson,
119
+ writeRunProfile,
120
+ writeRunSummary,
121
+ type RunSummaryJson,
122
+ } from './run-artifacts.js'
115
123
  import { createSaveLane } from './save-lane.js'
116
124
  import { formatOutcomeTable, formatRunSummary } from './summary.js'
117
125
  import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
@@ -316,11 +324,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
316
324
  // See docs/design/event-stream-2026-06.md.
317
325
  const terminal =
318
326
  options.log === undefined
319
- ? defaultLogger(colors, resolveOutputView(options), process.stdout, {
320
- coalesce: true,
321
- ...(options.tty === true ? { tty: true } : {}),
322
- ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
323
- })
327
+ ? defaultLogger(
328
+ colors,
329
+ resolveOutputView(options),
330
+ options.json === true ? process.stderr : process.stdout,
331
+ {
332
+ coalesce: true,
333
+ ...(options.tty === true ? { tty: true } : {}),
334
+ ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
335
+ },
336
+ )
324
337
  : null
325
338
  const sink = options.log ?? terminal!
326
339
  // An injected bus already has surfaces subscribed; we add the terminal
@@ -970,7 +983,8 @@ async function runOnBus(
970
983
  // dispatch, and the tracker carries it to what is built on it.
971
984
  const taintSeeds = new Set(excluded.seeds)
972
985
  const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
973
- const taintedRan = new Set<string>()
986
+ // Each task that ran tainted, and the task at the root of it.
987
+ const taintedRan = new Map<string, string>()
974
988
  const dependedOn = new Set<string>()
975
989
  for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
976
990
 
@@ -995,13 +1009,16 @@ async function runOnBus(
995
1009
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
996
1010
  const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
997
1011
  const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
998
- if (
999
- options.continueMode === 'always' &&
1000
- node.deps.some((d) => deadServerBehind(nodes, serverDied, d) !== undefined)
1001
- )
1002
- taintSeeds.add(node.id)
1012
+ let deadServer: string | undefined
1013
+ if (options.continueMode === 'always') {
1014
+ for (const d of node.deps) {
1015
+ deadServer = deadServerBehind(nodes, serverDied, d)
1016
+ if (deadServer !== undefined) break
1017
+ }
1018
+ if (deadServer !== undefined) taintSeeds.add(node.id)
1019
+ }
1003
1020
  const tainted = taint.judge(node, upstream)
1004
- if (tainted) taintedRan.add(node.id)
1021
+ if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
1005
1022
  const a: ExecuteArgs = {
1006
1023
  node,
1007
1024
  upstream,
@@ -1130,6 +1147,21 @@ async function runOnBus(
1130
1147
  ? Promise.resolve(kept)
1131
1148
  : executeWithDedup(node, keyUpstream(node, upstream))
1132
1149
  },
1150
+ // An unkeyed group runs nothing: settled in place, unless a taint
1151
+ // may ride it, which `buildExecuteArgs` records on dispatch.
1152
+ ...(options.continueMode !== 'always' && taintSeeds.size === 0
1153
+ ? {
1154
+ settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1155
+ isGroupTask(node) && node.config.cache === undefined
1156
+ ? unkeyedGroupOutcome(
1157
+ node,
1158
+ keyUpstream(node, upstream),
1159
+ shortCircuit.groupKeys.get(node.id),
1160
+ runStartHrTimeNs,
1161
+ )
1162
+ : undefined,
1163
+ }
1164
+ : {}),
1133
1165
  // A `schedule` plugin's weights; the scheduler keeps its structural
1134
1166
  // baseline as the tie-break. Empty map → baseline only.
1135
1167
  ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
@@ -1232,7 +1264,20 @@ async function runOnBus(
1232
1264
  // change the run's exit code — the run already happened.
1233
1265
  // Written again after the keep-alive wait: a kept server's crash or a
1234
1266
  // Ctrl-C there is the process's exit, and the first write said ok.
1267
+ let json: RunSummaryJson | undefined
1235
1268
  const summarize = async (runOk: boolean, final = list): Promise<void> => {
1269
+ if (options.json === true) {
1270
+ json = runSummaryJson({
1271
+ runId,
1272
+ startedAtMs: endedAtMsAtStart,
1273
+ endedAtMs,
1274
+ totalMs,
1275
+ ok: runOk,
1276
+ ...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
1277
+ outcomes: final,
1278
+ flaky,
1279
+ })
1280
+ }
1236
1281
  if (options.summarize === undefined) return
1237
1282
  // The rewrite after the keep-alive wait says nothing: the footer is
1238
1283
  // the run's last word.
@@ -1319,8 +1364,21 @@ async function runOnBus(
1319
1364
  // cache closes: its history is written once the wait has decided, or it
1320
1365
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1321
1366
  const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
1322
- if (stoppedBy === undefined && !historyAfterWait)
1367
+ // A failed task's output, for `vx last` and an agent: files beside the
1368
+ // index, so a refused write (a full disk, a read-only cache dir) costs
1369
+ // only that output.
1370
+ const recordFailures = (final: readonly TaskOutcome[]): void => {
1371
+ try {
1372
+ writeRunFailures(prepared.cacheDir, runId, final)
1373
+ } catch (err) {
1374
+ if (!isFsRefusal(err)) throw err
1375
+ log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
1376
+ }
1377
+ }
1378
+ if (stoppedBy === undefined && !historyAfterWait) {
1323
1379
  recordHistory(() => cache.recordRunBundle(records))
1380
+ recordFailures(list)
1381
+ }
1324
1382
  mark('record history')
1325
1383
  // Drain any still-in-flight background prefetches before closing the
1326
1384
  // cache handle — a prefetch ingesting into a closed SQLite DB would
@@ -1533,11 +1591,12 @@ async function runOnBus(
1533
1591
  local.close()
1534
1592
  }
1535
1593
  })
1594
+ recordFailures(final)
1536
1595
  }
1537
- return { ok: ok && first.code === 0, outcomes: final }
1596
+ return { ok: ok && first.code === 0, outcomes: final, ...(json && { json }) }
1538
1597
  }
1539
1598
 
1540
- return { ok, outcomes: list }
1599
+ return { ok, outcomes: list, ...(json && { json }) }
1541
1600
  } finally {
1542
1601
  // Idempotent. The status-line ticker starts in runStart, and every
1543
1602
  // call between it and the success path's runEnd is crash-isolated
@@ -1627,7 +1686,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1627
1686
  // The plan is the product and goes to stdout (`--dry=json` is parsed):
1628
1687
  // what a stage says on the way goes to stderr (C-6).
1629
1688
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1630
- const prepared = await prepareRun(options, log)
1689
+ const reasons = new Map<string, AffectedReason>()
1690
+ const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1631
1691
  try {
1632
1692
  if (prepared.unresolvedTasks.length > 0) {
1633
1693
  return {
@@ -1675,6 +1735,10 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1675
1735
  ...(await planRestorable(prepared, policy, options.forwardArgs)),
1676
1736
  })
1677
1737
  mark('plan')
1738
+ for (const t of planned.tasks) {
1739
+ const why = reasons.get(t.node.id)
1740
+ if (why !== undefined) t.affected = why
1741
+ }
1678
1742
  return planned
1679
1743
  } finally {
1680
1744
  // The plan called the cache and executor factories; teardown releases
@@ -1861,7 +1925,7 @@ export function projectNamed(
1861
1925
  /** The executed, keyed outcomes of a run — what flakiness is judged on. */
1862
1926
  function flakyCandidates(
1863
1927
  outcomes: readonly TaskOutcome[],
1864
- tainted: ReadonlySet<string>,
1928
+ tainted: ReadonlyMap<string, string>,
1865
1929
  ): FlakyCandidate[] {
1866
1930
  const out: FlakyCandidate[] = []
1867
1931
  for (const o of outcomes) {
@@ -1871,9 +1935,10 @@ function flakyCandidates(
1871
1935
  // and runs every time, so one bad network day would read as a flake for
1872
1936
  // thirty days. Groups do no work.
1873
1937
  if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
1874
- // Behind a failed dependency the failure is the dependency's: the key it
1875
- // had passed on read as a flake for thirty days.
1876
- if (o.status === 'failed' && tainted.has(o.node.id)) continue
1938
+ // Behind a failed dependency the outcome is not the key's: a failure is
1939
+ // the dependency's, and a pass built on bytes the key never named, so a
1940
+ // later failure relapsed from it and read as a flake for thirty days.
1941
+ if (tainted.has(o.node.id)) continue
1877
1942
  out.push({
1878
1943
  project: o.node.projectName,
1879
1944
  task: o.node.taskName,
@@ -411,8 +411,8 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
411
411
  * in place. Its one behavioural case is a config with NO `exec`, where
412
412
  * the spread would add `exec: {}` — and nothing reaches here that way,
413
413
  * proven by throwing on a no-exec config and running the whole suite:
414
- * a group takes `computeGroupKey`, and the one keyed group (the default
415
- * `build`) declares no `remote`. `timeout`/`retries` stay folded — their keys are
414
+ * a group takes `computeGroupKey`, and a keyed group has no `exec`, so no
415
+ * `remote`. `timeout`/`retries` stay folded — their keys are
416
416
  * distinct by design (see the decision log); stripping them retroactively
417
417
  * would bump CACHE_VERSION.
418
418
  */
@@ -423,8 +423,8 @@ function hashableConfig(cfg: TaskConfig): unknown {
423
423
  }
424
424
 
425
425
  /**
426
- * A group's key: its upstream, and for a keyed group — the default
427
- * `build` (projects.ts), the one group with `cache` — its own inputs too,
426
+ * A group's key: its upstream, and for a keyed group — one with `cache`,
427
+ * the default `build` (projects.ts) or a config's — its own inputs too,
428
428
  * the way a task's key folds them.
429
429
  */
430
430
  export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
@@ -43,3 +43,19 @@ export function notifyEscape(text: string): string {
43
43
  // eslint-disable-next-line no-control-regex -- the control range is the point
44
44
  return `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`
45
45
  }
46
+
47
+ /**
48
+ * Whether the terminal `env` names opens OSC 8 links. A terminal that does
49
+ * not know the sequence may print it, so only ones known to read it get it;
50
+ * tmux and screen pass it through only when configured to.
51
+ */
52
+ export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
53
+ if (env['TMUX'] !== undefined || env['STY'] !== undefined) return false
54
+ const program = env['TERM_PROGRAM']
55
+ if (program === 'tmux' || program === 'screen') return false
56
+ if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
57
+ if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
58
+ if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
59
+ if (env['KONSOLE_VERSION'] !== undefined) return true
60
+ return Number(env['VTE_VERSION'] ?? 0) >= 5000
61
+ }
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  DependencySpecError,
3
3
  compileTaskPattern,
4
+ isGroupTask,
4
5
  isTaskPattern,
5
6
  parseDependencySpec,
6
7
  type DependencySpec,
@@ -76,6 +77,69 @@ export function filterUpstreamHashes(
76
77
  ])
77
78
  }
78
79
 
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
+
79
143
  /**
80
144
  * One dependency as the key fold sees it: the task, and the value the
81
145
  * fold dedups by — its hash on the hash path, a structural stand-in for
@@ -123,16 +187,7 @@ export function selectFoldedDeps(
123
187
  ): FoldCandidate[] {
124
188
  if (filter === undefined) return [...deps]
125
189
 
126
- const specs: DependencySpec[] = filter.map((raw) => {
127
- try {
128
- return parseDependencySpec(raw)
129
- } catch (err) {
130
- if (err instanceof DependencySpecError) {
131
- throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
132
- }
133
- throw err
134
- }
135
- })
190
+ const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
136
191
 
137
192
  // Per-spec predicate, compiled once (exact compares + patterns).
138
193
  const matchers = specs.map((spec) => specMatcher(spec))
@@ -7,12 +7,66 @@ import { realpathSync } from 'node:fs'
7
7
  import os from 'node:os'
8
8
 
9
9
  export class UserError extends Error {
10
- constructor(message: string) {
10
+ /**
11
+ * What an agent branches on: `VX_E_…`, printed by a verb that answers
12
+ * JSON (`errorDocument`). The message is prose and may change; a code
13
+ * does not. `VX_E_REFUSED` is any refusal without a code of its own.
14
+ */
15
+ readonly code: string
16
+ // Two signatures, not an optional parameter: the package-api record
17
+ // keeps the one-argument form every plugin already calls.
18
+ constructor(message: string)
19
+ constructor(message: string, code: string)
20
+ constructor(message: string, code = 'VX_E_REFUSED') {
11
21
  super(message)
12
22
  this.name = 'UserError'
23
+ this.code = code
13
24
  }
14
25
  }
15
26
 
27
+ // A refusal that opens with the config that caused it: the schema's
28
+ // `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
29
+ const CONFIG_REFUSAL = /^\/\S*\/vx\.(?:config|workspace)\.[cm]?[jt]s\b/
30
+
31
+ /**
32
+ * The stable code of anything `bin.ts` catches. A UserError's own, but
33
+ * `VX_E_CONFIG` for a codeless one that names its config file; then the
34
+ * environment's refusals; anything else is a defect, `VX_E_INTERNAL`. A
35
+ * UserError from another copy of core has no `code` when that copy
36
+ * predates it.
37
+ */
38
+ export function errorCode(err: unknown): string {
39
+ if (isUserError(err)) {
40
+ const code = (err as { code?: unknown }).code
41
+ if (typeof code === 'string' && code !== 'VX_E_REFUSED') return code
42
+ return CONFIG_REFUSAL.test(err.message) ? 'VX_E_CONFIG' : 'VX_E_REFUSED'
43
+ }
44
+ if (isOutOfFds(err)) return 'VX_E_FDS'
45
+ if (isFsRefusal(err)) return 'VX_E_FS'
46
+ return 'VX_E_INTERNAL'
47
+ }
48
+
49
+ /**
50
+ * Does this argv ask for JSON (`--format json`, `--format=json`,
51
+ * `--dry=json`)? Then a refusal is a JSON document on stdout too, so a
52
+ * reader of stdout gets an answer, not nothing. Args after `--` are the
53
+ * task's, not vx's.
54
+ */
55
+ export function wantsJson(argv: readonly string[]): boolean {
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i]!
58
+ if (a === '--') return false
59
+ if (a === '--format=json' || a === '--dry=json') return true
60
+ if (a === '--format' && argv[i + 1] === 'json') return true
61
+ }
62
+ return false
63
+ }
64
+
65
+ /** The JSON a verb that answers JSON prints for a refusal: one line. */
66
+ export function errorDocument(code: string, message: string): string {
67
+ return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
68
+ }
69
+
16
70
  /**
17
71
  * `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
18
72
  * core. A compiled `vx` binary carries core inside it while a plugin in the
package/src/util/index.ts CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  export {
5
5
  DISK_FULL_HINT,
6
+ errorCode,
7
+ errorDocument,
6
8
  fsRefusalHint,
7
9
  isDiskFull,
8
10
  isFsRefusal,
@@ -17,6 +19,7 @@ export {
17
19
  isOutOfFds,
18
20
  notAWorkTree,
19
21
  OUT_OF_FDS_HINT,
22
+ wantsJson,
20
23
  } from './errors.js'
21
24
  export { configErrorFrame } from './config-frame.js'
22
25
  export { xxh3, xxh3hex } from './hash.js'