@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.
Files changed (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -5,10 +5,11 @@
5
5
 
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
7
  import { tallyOutcomes } from './tally.js'
8
- import { isCacheHit } from './telemetry.js'
8
+ import { isCacheHit } from '../graph/index.js'
9
9
  import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
10
10
  import { isGroupTask } from '../graph/index.js'
11
11
  import {
12
+ barWidth,
12
13
  ERROR,
13
14
  formatElapsed,
14
15
  gradientRule,
@@ -28,7 +29,6 @@ const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
28
29
  const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
29
30
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
30
31
 
31
- const BAR_WIDTH = 50
32
32
  /**
33
33
  * Stacked state meter: one ▰ run per bucket, proportional cells via
34
34
  * largest-remainder, every non-zero bucket guaranteed at least one
@@ -41,9 +41,10 @@ function segmentBar(
41
41
  ): string {
42
42
  const total = segments.reduce((sum, seg) => sum + seg.n, 0)
43
43
  if (total === 0) return ''
44
- const exact = segments.map((seg) => (seg.n / total) * BAR_WIDTH)
44
+ const width = barWidth(colors)
45
+ const exact = segments.map((seg) => (seg.n / total) * width)
45
46
  const cells = exact.map(Math.floor)
46
- let leftover = BAR_WIDTH - cells.reduce((sum, c) => sum + c, 0)
47
+ let leftover = width - cells.reduce((sum, c) => sum + c, 0)
47
48
  const order = exact
48
49
  .map((e, i) => ({ i, frac: e - Math.floor(e) }))
49
50
  .sort((x, y) => y.frac - x.frac)
@@ -61,9 +62,12 @@ function segmentBar(
61
62
  }
62
63
  return segments
63
64
  .map((seg, i) =>
64
- paint(seg.color, (seg.glyph ?? '\u25b0').repeat(cells[i]!), colors, {
65
- ...(seg.dim === true ? { dim: true } : {}),
66
- }),
65
+ // An empty segment paints nothing: no escape pair around no cell.
66
+ cells[i] === 0
67
+ ? ''
68
+ : paint(seg.color, (seg.glyph ?? '\u25b0').repeat(cells[i]!), colors, {
69
+ ...(seg.dim === true ? { dim: true } : {}),
70
+ }),
67
71
  )
68
72
  .join('')
69
73
  }
@@ -106,6 +110,67 @@ export interface SummaryStats {
106
110
  savedMs?: number
107
111
  /** A run of groups only: their names, so `0 tasks` says why. */
108
112
  emptyGroups?: readonly string[]
113
+ /** Each finished task's cache bucket by its key, so its remote traffic can move it. */
114
+ buckets?: ReadonlyMap<string, CacheBucket>
115
+ }
116
+
117
+ /** The cache bar segment a finished task fills before its remote traffic is weighed. */
118
+ export type CacheBucket =
119
+ | 'miss'
120
+ | 'noCache'
121
+ | 'upToDate'
122
+ | 'restoredLocal'
123
+ | 'restoredRemote'
124
+ | 'saved'
125
+
126
+ /** A finished task's own cache bucket; none for a group, a skip or an abort. */
127
+ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
128
+ if (isGroupTask(o.node)) return undefined
129
+ switch (o.status) {
130
+ case 'success':
131
+ return ranNoCache(o) ? 'noCache' : o.saved === true ? 'saved' : 'miss'
132
+ case 'failed':
133
+ return ranNoCache(o) ? 'noCache' : 'miss'
134
+ case 'cache-hit':
135
+ return o.restored === false ? 'upToDate' : 'restoredLocal'
136
+ case 'cache-hit-remote':
137
+ return o.restored === false ? 'upToDate' : 'restoredRemote'
138
+ default:
139
+ return undefined
140
+ }
141
+ }
142
+
143
+ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', number>
144
+
145
+ /**
146
+ * The cache bar's segments, each task in one (M, 2026-10-15). With layers
147
+ * named, a saved task leaves the misses, and a task with remote traffic
148
+ * leaves its own bucket for its strongest remote state: a failed read or
149
+ * write, then an upload still running, then one that landed. Without, the
150
+ * plain legend's buckets.
151
+ */
152
+ function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
153
+ const saved = layers === undefined ? 0 : (stats.saved ?? 0)
154
+ const b: Buckets = {
155
+ miss: stats.miss - saved,
156
+ noCache: stats.noCache ?? 0,
157
+ upToDate: stats.upToDate,
158
+ restoredLocal: stats.restoredLocal,
159
+ restoredRemote: stats.restoredRemote,
160
+ saved,
161
+ uploaded: 0,
162
+ uploading: 0,
163
+ remoteFailed: 0,
164
+ }
165
+ if (layers?.remote !== true || stats.buckets === undefined) return b
166
+ for (const [hash, k] of layers.traffic.keys) {
167
+ const from = stats.buckets.get(hash)
168
+ // Still running: it counts once it ends.
169
+ if (from === undefined) continue
170
+ b[from]--
171
+ b[k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded']++
172
+ }
173
+ return b
109
174
  }
110
175
 
111
176
  /**
@@ -116,7 +181,7 @@ export interface SummaryStats {
116
181
  */
117
182
  export interface RunContext {
118
183
  version: string
119
- /** Projects covered by the graph — the "affected" half of the bar. */
184
+ /** Projects with a real task in the graph — the "affected" half of the bar. */
120
185
  packageCount: number
121
186
  /** Worker-pool size for this run; shown on the `info` row. */
122
187
  concurrency?: number
@@ -131,8 +196,12 @@ export interface RunContext {
131
196
  localWrite: boolean
132
197
  /** A remote layer exists and the policy reads or writes it. */
133
198
  remote: boolean
134
- /** The run's uploads, filled as each lands (a live ref). */
135
- uploads: { count: number; failed: number }
199
+ /** The run's remote traffic per key, filled as it happens (a live ref). */
200
+ traffic: {
201
+ /** Uploads queued or running. */
202
+ readonly inFlight: number
203
+ readonly keys: ReadonlyMap<string, { uploading: number; failures: readonly string[] }>
204
+ }
136
205
  /** Tasks whose outputs are still in the remote store. */
137
206
  notDownloaded: () => number
138
207
  }
@@ -196,13 +265,14 @@ export function formatSummarySection(
196
265
  if (context !== undefined) {
197
266
  const wsTotal = context.workspaceProjectCount
198
267
  if (wsTotal !== undefined && wsTotal > 0) {
268
+ const width = barWidth(colors)
199
269
  const cells = Math.min(
200
- BAR_WIDTH,
201
- Math.max(1, Math.round((context.packageCount / wsTotal) * BAR_WIDTH)),
270
+ width,
271
+ Math.max(1, Math.round((context.packageCount / wsTotal) * width)),
202
272
  )
203
273
  const bar =
204
274
  paint(WARN, '▰'.repeat(cells), colors) +
205
- paint('', '▱'.repeat(BAR_WIDTH - cells), colors, { dim: true })
275
+ paint('', '▱'.repeat(width - cells), colors, { dim: true })
206
276
  lines.push(
207
277
  row('projects', bar),
208
278
  legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
@@ -230,37 +300,48 @@ export function formatSummarySection(
230
300
  // Cache meter + numbers: per layer when the run context names its
231
301
  // layers, else miss · no-cache · up-to-date · local · remote.
232
302
  if (miss + noCache + hits + left + stats.skipped > 0) {
303
+ const layers = context?.cache
304
+ const b = cacheBuckets(stats, layers)
233
305
  const cacheBar = segmentBar(
234
306
  [
235
- { n: miss, color: ERROR },
236
- { n: noCache, color: '', dim: true },
237
- { n: stats.upToDate, color: SUCCESS },
238
- { n: stats.restoredLocal, color: LOCAL },
239
- { n: stats.restoredRemote, color: REMOTE },
307
+ { n: b.miss, color: ERROR },
308
+ { n: b.noCache, color: '', dim: true },
309
+ { n: b.upToDate, color: SUCCESS },
310
+ { n: b.restoredLocal, color: LOCAL },
311
+ { n: b.saved, color: LOCAL },
312
+ { n: b.restoredRemote, color: REMOTE },
313
+ { n: b.uploaded, color: REMOTE },
314
+ { n: b.uploading, color: ACCENT },
315
+ { n: b.remoteFailed, color: ERROR },
240
316
  { n: stats.skipped, color: WARN },
241
317
  remainder,
242
318
  ],
243
319
  colors,
244
320
  )
245
321
  const cacheParts: string[] = []
246
- const layers = context?.cache
247
322
  if (layers !== undefined) {
248
- // Per layer (M, 2026-10-15): `●` fresh (outputs already on disk, from
249
- // either layer) and `○` no cache, both local; `↓` restored from a
250
- // layer, `↑` saved to it.
251
- const n = (count: number, glyph: string): string[] => (count > 0 ? [`${count}${glyph}`] : [])
323
+ // Per layer (M, 2026-10-15): `✓` fresh (outputs already on disk, from
324
+ // either layer) and `∅` no cache, both local; `↓` restored from a
325
+ // layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
326
+ // uploading. Each count is its bar segment's, glyph and number in its colour.
327
+ const n = (count: number, glyph: string, color: string, dim = false): string[] =>
328
+ count > 0 ? [paint(color, `${count}${glyph}`, colors, dim ? { dim: true } : {})] : []
252
329
  const local = [
253
- ...(layers.local ? n(stats.upToDate, '\u25cf') : []),
254
- ...n(noCache, '\u25cb'),
255
- ...(layers.local ? n(stats.restoredLocal, '\u2193') : []),
256
- ...(layers.localWrite ? n(stats.saved ?? 0, '\u2191') : []),
330
+ ...(layers.local ? n(b.upToDate, '\u2713', SUCCESS) : []),
331
+ ...n(b.noCache, '\u2205', '', true),
332
+ ...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
333
+ ...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
334
+ ]
335
+ // The layer words are dim; only the counts carry colour (M, 2026-10-15).
336
+ if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
337
+ const remote = [
338
+ ...n(b.restoredRemote, '\u2193', REMOTE),
339
+ ...n(b.uploaded, '\u2191', REMOTE),
340
+ ...n(b.remoteFailed, '\u2717', ERROR),
341
+ ...n(b.uploading, '\u21e1', ACCENT),
257
342
  ]
258
- if (local.length > 0) cacheParts.push(paint(LOCAL, `local ${local.join(' ')}`, colors))
259
- const remote = [...n(stats.restoredRemote, '\u2193'), ...n(layers.uploads.count, '\u2191')]
260
343
  if (layers.remote && remote.length > 0)
261
- cacheParts.push(paint(REMOTE, `remote ${remote.join(' ')}`, colors))
262
- if (layers.uploads.failed > 0)
263
- cacheParts.push(paint(ERROR, `${layers.uploads.failed} \u2191 failed`, colors))
344
+ cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
264
345
  if ((stats.notSaved ?? 0) > 0)
265
346
  cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
266
347
  const notDownloaded = layers.notDownloaded()
@@ -393,9 +474,8 @@ export function formatRunSummary(
393
474
  colors: ColorSupport = NO_COLOR,
394
475
  context?: RunContext,
395
476
  ): string[] {
396
- // Counted here, not in the Tally: that object is the `--summarize` payload
397
- // (ten documented fields) and a task with no `cache` block is a terminal
398
- // distinction — it never consulted the cache, so it is not a miss.
477
+ // Counted here, not in the Tally: a task with no `cache` block never
478
+ // consulted the cache, so it is not a miss.
399
479
  const noCache = outcomes.filter(
400
480
  (o) =>
401
481
  !isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
@@ -448,6 +528,7 @@ export function formatRunSummary(
448
528
  }
449
529
  : {}),
450
530
  ...(emptyGroups.length > 0 ? { emptyGroups } : {}),
531
+ buckets: bucketsOf(outcomes),
451
532
  },
452
533
  totalMs,
453
534
  colors,
@@ -455,6 +536,16 @@ export function formatRunSummary(
455
536
  )
456
537
  }
457
538
 
539
+ /** Each finished task's cache bucket by its key. */
540
+ function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
541
+ const out = new Map<string, CacheBucket>()
542
+ for (const o of outcomes) {
543
+ const bucket = o.hash === undefined ? undefined : cacheBucketOf(o)
544
+ if (bucket !== undefined) out.set(o.hash!, bucket)
545
+ }
546
+ return out
547
+ }
548
+
458
549
  /**
459
550
  * An `aborted` outcome the stop reached before dispatch. The scheduler gives
460
551
  * it an invented exit 1; only a started task carries a wall-clock start, so
@@ -0,0 +1,83 @@
1
+ // Outcome tally for the end-of-run footer (`summary.ts`): the bucket rules,
2
+ // group tasks excluded.
3
+
4
+ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
5
+
6
+ export interface Tally {
7
+ successful: number
8
+ failed: number
9
+ skipped: number
10
+ cachedLocal: number
11
+ /** Hits that materialized files from the local artifact. */
12
+ restoredLocal: number
13
+ /** Hits that materialized files pulled from the remote layer. */
14
+ restoredRemote: number
15
+ /** Cache hits whose tree already matched — nothing was written. */
16
+ upToDate: number
17
+ cachedRemote: number
18
+ /**
19
+ * Killed by a shutdown signal. No work, so it joins no outcome bucket and
20
+ * not `total` — but it is counted, because it makes the run red: a red run
21
+ * whose every counted task succeeded is undiagnosable without this.
22
+ */
23
+ aborted: number
24
+ /** Tasks counted toward `total`. Group tasks are excluded by default. */
25
+ total: number
26
+ }
27
+
28
+ function emptyTally(): Tally {
29
+ return {
30
+ successful: 0,
31
+ failed: 0,
32
+ skipped: 0,
33
+ cachedLocal: 0,
34
+ restoredLocal: 0,
35
+ restoredRemote: 0,
36
+ upToDate: 0,
37
+ cachedRemote: 0,
38
+ aborted: 0,
39
+ total: 0,
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Walk live outcomes and return a `Tally` — the single owner of the bucket
45
+ * rules.
46
+ *
47
+ * Group tasks (no `exec`) aren't real work; including them inflates the
48
+ * per-run "N total" count in ways that mislead ("3 of 4 cached" when one was
49
+ * a group that ran nothing).
50
+ *
51
+ * Status bucket rules:
52
+ * `success` → +successful
53
+ * `cache-hit` → +successful, +cachedLocal
54
+ * `cache-hit-remote` → +successful, +cachedRemote
55
+ * `failed` → +failed
56
+ * `skipped` → +skipped
57
+ * `aborted` → +aborted only (no bucket, no total)
58
+ */
59
+ export function tallyOutcomes(outcomes: readonly TaskOutcome[]): Tally {
60
+ const t = emptyTally()
61
+ for (const o of outcomes) {
62
+ if (isGroupTask(o.node)) continue
63
+ if (o.status === 'aborted') {
64
+ t.aborted++
65
+ continue
66
+ }
67
+ t.total++
68
+ if (o.status === 'success') t.successful++
69
+ else if (o.status === 'cache-hit') {
70
+ t.successful++
71
+ t.cachedLocal++
72
+ if (o.restored === true) t.restoredLocal++
73
+ else t.upToDate++
74
+ } else if (o.status === 'cache-hit-remote') {
75
+ t.successful++
76
+ t.cachedRemote++
77
+ if (o.restored === true) t.restoredRemote++
78
+ else t.upToDate++
79
+ } else if (o.status === 'failed') t.failed++
80
+ else if (o.status === 'skipped') t.skipped++
81
+ }
82
+ return t
83
+ }
package/src/settings.ts CHANGED
@@ -102,39 +102,12 @@ export const SETTINGS: readonly Setting[] = [
102
102
  flag: { verb: 'run', name: '--output-logs' },
103
103
  config: false,
104
104
  },
105
- {
106
- path: 'run.killGrace',
107
- kind: { type: 'duration', timer: true },
108
- default: '2s',
109
- doc: 'How long a task that ignores SIGTERM gets before SIGKILL.',
110
- flag: { verb: 'run', name: '--kill-grace' },
111
- },
112
- {
113
- path: 'run.teardownTimeout',
114
- kind: { type: 'duration', timer: true, zero: true },
115
- default: '3s',
116
- doc: "The bound on each plugin's end-of-run flush and teardown.",
117
- flag: { verb: 'run', name: '--teardown-timeout' },
118
- },
119
- {
120
- path: 'run.readyNotice',
121
- kind: { type: 'duration', timer: true },
122
- default: '10s',
123
- doc: 'How long a persistent task may take to match `readyWhen` before vx says it waits.',
124
- flag: { verb: 'run', name: '--ready-notice' },
125
- },
126
105
  {
127
106
  path: 'run.rules.exclusiveOutputs',
128
107
  kind: { type: 'bool' },
129
108
  default: 'true',
130
109
  doc: 'Refuse two tasks whose outputs overlap even when an edge orders them.',
131
110
  },
132
- {
133
- path: 'run.rules.upfrontKeys',
134
- kind: { type: 'bool' },
135
- default: 'true',
136
- doc: "Refuse a task whose inputs can match another task's outputs.",
137
- },
138
111
  {
139
112
  path: 'cache.dir',
140
113
  kind: { type: 'string' },
@@ -197,6 +170,13 @@ export const SETTINGS: readonly Setting[] = [
197
170
  task: 'cache.outputs.clean',
198
171
  env: false,
199
172
  },
173
+ {
174
+ path: 'cache.upload.failures',
175
+ kind: { type: 'enum', values: ['ci', 'local', 'always'] },
176
+ default: 'ci',
177
+ doc: "Where a failed task's log and outputs are kept in the remote: CI runs, local runs, or both.",
178
+ env: false,
179
+ },
200
180
  {
201
181
  path: 'lock.reviewFiles',
202
182
  kind: { type: 'bool' },
@@ -249,7 +229,7 @@ export function setting(path: string): Setting {
249
229
  return BY_PATH.get(path)!
250
230
  }
251
231
 
252
- /** `run.killGrace` → `VX_RUN_KILL_GRACE`. */
232
+ /** `run.affectedBase` → `VX_RUN_AFFECTED_BASE`. */
253
233
  export function envName(path: string): string {
254
234
  return `VX_${path
255
235
  .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
@@ -414,15 +394,8 @@ export function resolveSetting(
414
394
  return { value: undefined, source: 'default' }
415
395
  }
416
396
 
417
- /** `run.rules`, each from its variable, else the workspace; unset ones stay on. */
418
- export function runRules(workspace: unknown): {
419
- exclusiveOutputs?: boolean
420
- upfrontKeys?: boolean
421
- } {
422
- const out: { exclusiveOutputs?: boolean; upfrontKeys?: boolean } = {}
423
- for (const rule of ['exclusiveOutputs', 'upfrontKeys'] as const) {
424
- const { value } = resolveSetting(`run.rules.${rule}`, workspace)
425
- if (value !== undefined) out[rule] = value as boolean
426
- }
427
- return out
397
+ /** `run.rules`, from its variable, else the workspace; unset stays on. */
398
+ export function runRules(workspace: unknown): { exclusiveOutputs?: boolean } {
399
+ const { value } = resolveSetting('run.rules.exclusiveOutputs', workspace)
400
+ return value === undefined ? {} : { exclusiveOutputs: value as boolean }
428
401
  }
@@ -20,6 +20,8 @@ export const ERROR = '#ef4444'
20
20
 
21
21
  export interface ColorSupport {
22
22
  enabled: boolean
23
+ /** The terminal's width, read live; none off a terminal. Bars and rules fit it. */
24
+ readonly columns?: number | undefined
23
25
  }
24
26
 
25
27
  export interface PaintOptions {
@@ -32,16 +34,32 @@ export interface PaintOptions {
32
34
  * NO_COLOR non-empty → off (overrides FORCE_COLOR)
33
35
  * FORCE_COLOR=0 or =false → off, even on a TTY
34
36
  * FORCE_COLOR any other value → on (the empty string too)
35
- * else: on iff stdout is a TTY.
37
+ * else: on iff stdout is a TTY that is not `TERM=dumb`.
36
38
  * `0` and `false` are the convention's "off" (supports-color, chalk,
37
39
  * Node); reading every non-empty value as on painted a piped CI log that
38
40
  * set `FORCE_COLOR=0` (nx#35292's bug).
39
41
  */
40
42
  export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
41
- if (process.env['NO_COLOR']) return { enabled: false }
42
43
  const force = process.env['FORCE_COLOR']
43
- if (force !== undefined) return { enabled: force !== '0' && force !== 'false' }
44
- return { enabled: stream.isTTY === true }
44
+ const enabled = process.env['NO_COLOR']
45
+ ? false
46
+ : force !== undefined
47
+ ? force !== '0' && force !== 'false'
48
+ : stream.isTTY === true && !dumbTerminal()
49
+ return {
50
+ enabled,
51
+ get columns() {
52
+ return stream.isTTY === true ? stream.columns : undefined
53
+ },
54
+ }
55
+ }
56
+
57
+ /**
58
+ * `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
59
+ * nor move the cursor: vx prints to it as off a terminal.
60
+ */
61
+ export function dumbTerminal(): boolean {
62
+ return process.env['TERM'] === 'dumb'
45
63
  }
46
64
 
47
65
  // Bun.color() is pure for a given (hex, mode) pair but does FFI work
@@ -71,3 +89,25 @@ export function paint(
71
89
  if (prefix.length === 0) return text
72
90
  return `${prefix}${text}${RESET}`
73
91
  }
92
+
93
+ /**
94
+ * Identity hues for the two halves of a `project#task` id, never a status
95
+ * colour: one name, one hue, in every run, list and row (M, 2026-10-15:
96
+ * every `#build` matches every other `#build`).
97
+ */
98
+ const IDENTITY_PALETTE = [
99
+ '#a78bfa', // violet-400
100
+ '#60a5fa', // blue-400
101
+ '#e879f9', // fuchsia-400
102
+ '#818cf8', // indigo-400
103
+ '#c084fc', // purple-400
104
+ '#93c5fd', // blue-300
105
+ '#f472b6', // pink-400
106
+ ] as const
107
+
108
+ /** A project's or a target's hue: a fold of the name into the palette. */
109
+ export function identityColor(name: string): string {
110
+ let h = 0
111
+ for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
112
+ return IDENTITY_PALETTE[h % IDENTITY_PALETTE.length]!
113
+ }
@@ -0,0 +1,143 @@
1
+ // Whether git ignores everything an output glob can write (M, 2026-10-15):
2
+ // a key reads only what git lists, so such an output never reaches a
3
+ // reader's key. Asked of git, never read from a `.gitignore`: git alone
4
+ // knows every rule source (nested files, `info/exclude`, `core.excludesFile`)
5
+ // and that a directory holding a tracked file is not ignored.
6
+
7
+ import { gitEnv } from './hide-env.js'
8
+ import { isLiteralPattern, normalizeGlob } from './paths.js'
9
+ import { executablePath } from './which.js'
10
+
11
+ /** A directory name no ignore rule names, standing for one a task may create. */
12
+ const FRESH = '__vx_probe__'
13
+
14
+ /**
15
+ * The paths git is asked about for a root-relative output glob: when git
16
+ * ignores each, it ignores every untracked path the glob can match. `deep`:
17
+ * the glob reaches below them (a `dist` at any depth is asked as `dist/` and
18
+ * a fresh `__vx_probe__/dist/`), so a listed file it matches must be looked
19
+ * for too (a tracked one, or one a `!` rule takes back). Null when no few paths
20
+ * stand for the glob (`*.js`): undecided, the output overlaps as before.
21
+ * `dir`: the glob's literal directory, where a listed file it matches lies.
22
+ */
23
+ export function ignoreProbes(glob: string): { paths: string[]; deep: boolean; dir: string } | null {
24
+ const segs = normalizeGlob(glob).split('/')
25
+ let i = 0
26
+ while (i < segs.length && isLiteralPattern(segs[i]!)) i++
27
+ const lit = segs.slice(0, i).join('/')
28
+ if (i === segs.length) return { paths: [lit], deep: false, dir: lit }
29
+ const at = lit === '' ? '' : `${lit}/`
30
+ const name = segs[i + 1]
31
+ if (segs[i] === '**' && name !== undefined && isLiteralPattern(name)) {
32
+ const leaf = i + 2 < segs.length ? `${name}/` : name
33
+ return { paths: [at + leaf, `${at}${FRESH}/${leaf}`], deep: true, dir: lit }
34
+ }
35
+ return lit === '' ? null : { paths: [`${lit}/`], deep: false, dir: lit }
36
+ }
37
+
38
+ const CHECK_IGNORE = ['check-ignore', '--stdin', '-z']
39
+
40
+ function ignoredOf(exitCode: number, stdout: Uint8Array): Set<string> | null {
41
+ // 0: some ignored; 1: none; anything else (no work tree): undecided.
42
+ if (exitCode === 1) return new Set()
43
+ if (exitCode !== 0) return null
44
+ return new Set(
45
+ new TextDecoder()
46
+ .decode(stdout)
47
+ .split('\0')
48
+ .filter((p) => p !== ''),
49
+ )
50
+ }
51
+
52
+ /** The `paths` (relative to `cwd`) git ignores; null when git cannot say. */
53
+ export async function checkIgnore(
54
+ cwd: string,
55
+ paths: readonly string[],
56
+ ): Promise<Set<string> | null> {
57
+ if (paths.length === 0) return new Set()
58
+ try {
59
+ const proc = Bun.spawn({
60
+ cmd: [executablePath('git'), ...CHECK_IGNORE],
61
+ env: gitEnv(),
62
+ cwd,
63
+ stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
64
+ stdout: 'pipe',
65
+ stderr: 'ignore',
66
+ })
67
+ const stdout = await new Response(proc.stdout).bytes()
68
+ return ignoredOf(await proc.exited, stdout)
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+
74
+ /** {@link checkIgnore} for `vx lock`, which builds its graph synchronously. */
75
+ export function checkIgnoreSync(cwd: string, paths: readonly string[]): Set<string> | null {
76
+ if (paths.length === 0) return new Set()
77
+ try {
78
+ const r = Bun.spawnSync({
79
+ cmd: [executablePath('git'), ...CHECK_IGNORE],
80
+ env: gitEnv(),
81
+ cwd,
82
+ stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
83
+ stdout: 'pipe',
84
+ stderr: 'ignore',
85
+ })
86
+ return ignoredOf(r.exitCode, r.stdout)
87
+ } catch {
88
+ return null
89
+ }
90
+ }
91
+
92
+ /**
93
+ * What git lists under `dirs` (relative to `cwd`): tracked files and the
94
+ * untracked ones it does not ignore, the set a key filters. Null when git
95
+ * cannot say. A run has it from its enumeration; this is `vx lock`'s.
96
+ */
97
+ export function listedSync(cwd: string, dirs: readonly string[]): string[] | null {
98
+ try {
99
+ const r = Bun.spawnSync({
100
+ cmd: [
101
+ executablePath('git'),
102
+ 'ls-files',
103
+ '-z',
104
+ '--cached',
105
+ '--others',
106
+ '--exclude-standard',
107
+ '--',
108
+ ...dirs.map((d) => (d === '' ? '.' : `:(literal)${d}`)),
109
+ ],
110
+ env: gitEnv(),
111
+ cwd,
112
+ stdout: 'pipe',
113
+ stderr: 'ignore',
114
+ })
115
+ if (r.exitCode !== 0) return null
116
+ return new TextDecoder()
117
+ .decode(r.stdout)
118
+ .split('\0')
119
+ .filter((p) => p !== '')
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Of `globs` (root-relative), those git ignores whole: each probe ignored
127
+ * and, for a deep glob, no listed file it matches (`listedMatch`).
128
+ */
129
+ export function wholeIgnored(
130
+ globs: Iterable<string>,
131
+ ignored: ReadonlySet<string> | null,
132
+ listedMatch: (glob: string) => boolean,
133
+ ): Set<string> {
134
+ const out = new Set<string>()
135
+ if (ignored === null) return out
136
+ for (const g of globs) {
137
+ const ask = ignoreProbes(g)
138
+ if (ask === null || !ask.paths.every((p) => ignored.has(p))) continue
139
+ if (ask.deep && listedMatch(g)) continue
140
+ out.add(g)
141
+ }
142
+ return out
143
+ }
package/src/util/index.ts CHANGED
@@ -24,8 +24,17 @@ export {
24
24
  wantsJson,
25
25
  } from './errors.js'
26
26
  export { configErrorFrame, configRefusal } from './config-frame.js'
27
- export { detectColors, ERROR, paint, SUCCESS, WARN, type ColorSupport } from './colors.js'
28
- export { gradientRule } from './report.js'
27
+ export {
28
+ detectColors,
29
+ dumbTerminal,
30
+ ERROR,
31
+ identityColor,
32
+ paint,
33
+ SUCCESS,
34
+ WARN,
35
+ type ColorSupport,
36
+ } from './colors.js'
37
+ export { barWidth, gradientRule } from './report.js'
29
38
  import * as ui from './ui.js'
30
39
  export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
31
40
  export { ui }
@@ -67,10 +76,9 @@ export {
67
76
  claimExitForSignal,
68
77
  exitClaimedBySignal,
69
78
  killGraceMs,
70
- setSettleBounds,
71
- type SettleBound,
72
79
  settleWithin,
73
80
  teardownTimeoutMs,
81
+ testTimerMs,
74
82
  } from './settle.js'
75
83
  export { formatBytes, parseDuration, parseSize, treeBytes } from './size.js'
76
84
  export {
@@ -91,6 +99,13 @@ export { procfsIsOwn } from './procfs.js'
91
99
  export { hangupIgnored } from './hangup.js'
92
100
  export { ESSENTIAL_ENV } from './essential-env.js'
93
101
  export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
102
+ export {
103
+ checkIgnore,
104
+ checkIgnoreSync,
105
+ ignoreProbes,
106
+ listedSync,
107
+ wholeIgnored,
108
+ } from './git-ignored.js'
94
109
  export { realPath, realpathOf } from './real-path.js'
95
110
  export {
96
111
  maskedCommand,