@vzn/vx 0.0.638 → 0.0.640
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
|
@@ -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 '
|
|
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
|
|
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 =
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
201
|
-
Math.max(1, Math.round((context.packageCount / wsTotal) *
|
|
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(
|
|
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:
|
|
238
|
-
{ n:
|
|
239
|
-
{ n:
|
|
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):
|
|
249
|
-
// either layer) and
|
|
250
|
-
// layer, `↑` saved to it
|
|
251
|
-
|
|
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(
|
|
254
|
-
...n(noCache, '\
|
|
255
|
-
...(layers.local ? n(
|
|
256
|
-
...(layers.localWrite ? n(
|
|
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(
|
|
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:
|
|
397
|
-
//
|
|
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.
|
|
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`,
|
|
418
|
-
export function runRules(workspace: unknown): {
|
|
419
|
-
exclusiveOutputs
|
|
420
|
-
|
|
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
|
}
|
package/src/util/colors.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
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 {
|
|
28
|
-
|
|
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,
|