@vzn/vx 0.0.644 → 0.0.646
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/LICENSE +1 -1
- package/README.md +22 -48
- package/package.json +5 -5
- package/schemas/init.json +5 -0
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +12 -76
- package/src/cache/cache.ts +72 -42
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +30 -8
- package/src/cache/layered-cache.ts +138 -44
- package/src/cache/output-index.ts +25 -11
- package/src/cache/plugin-cache.ts +9 -14
- package/src/cache/remote-lane.ts +60 -22
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +8 -10
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +159 -25
- package/src/cli/last.ts +4 -2
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +98 -6
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +2 -8
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +5 -6
- package/src/config.ts +2 -4
- package/src/exec/executor.ts +112 -9
- package/src/exec/index.ts +2 -0
- package/src/exec/proc-sample.ts +67 -5
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +11 -4
- package/src/graph/scheduler.ts +59 -8
- package/src/history/history.ts +119 -7
- package/src/history/metrics.ts +53 -16
- package/src/index.ts +16 -1
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/execute-args.ts +3 -0
- package/src/orchestrator/execute-task.ts +28 -7
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +11 -4
- package/src/orchestrator/index.ts +18 -1
- package/src/orchestrator/local-floor.ts +104 -0
- package/src/orchestrator/local-shortcircuit.ts +26 -7
- package/src/orchestrator/miss-save.ts +10 -4
- package/src/orchestrator/options.ts +0 -8
- package/src/orchestrator/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +22 -7
- package/src/orchestrator/plugin.ts +48 -7
- package/src/orchestrator/prepare.ts +14 -49
- package/src/orchestrator/run-context.ts +39 -1
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +95 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +12 -4
- package/src/orchestrator/task-process.ts +16 -0
- package/src/orchestrator/telemetry-host.ts +2 -1
- package/src/orchestrator/telemetry.ts +60 -17
- package/src/report/forecast.ts +6 -27
- package/src/report/framed-output.ts +41 -5
- package/src/report/logger.ts +60 -20
- package/src/report/status-line.ts +38 -18
- package/src/report/summary.ts +75 -9
- package/src/settings.ts +8 -7
- package/src/util/colors.ts +16 -1
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +30 -0
- package/src/util/ui.ts +4 -1
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -2
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- package/src/cli/foreign-flags.ts +0 -727
package/src/report/summary.ts
CHANGED
|
@@ -10,10 +10,12 @@ import { isGroupTask } from '../graph/index.js'
|
|
|
10
10
|
import {
|
|
11
11
|
barWidth,
|
|
12
12
|
ERROR,
|
|
13
|
+
formatBytes,
|
|
13
14
|
formatElapsed,
|
|
14
15
|
gradientRule,
|
|
15
16
|
paint,
|
|
16
17
|
SUCCESS,
|
|
18
|
+
ui,
|
|
17
19
|
WARN,
|
|
18
20
|
type ColorSupport,
|
|
19
21
|
} from '../util/index.js'
|
|
@@ -26,6 +28,7 @@ const LOCAL = '#38bdf8' // sky-400
|
|
|
26
28
|
const REMOTE = '#2563eb' // blue-600
|
|
27
29
|
const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
|
|
28
30
|
const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
|
|
31
|
+
const RUNNING = '#06b6d4' // cyan-500: ui's info
|
|
29
32
|
const ACCENT = '#06b6d4' // cyan-500: the live region's running word
|
|
30
33
|
|
|
31
34
|
/**
|
|
@@ -84,6 +87,11 @@ export interface SummaryStats {
|
|
|
84
87
|
aborted?: number
|
|
85
88
|
/** Reached by the stop before they ran. Not in `total`. */
|
|
86
89
|
notRun?: number
|
|
90
|
+
/** Live: running now, and handed to an executor whose machine has not begun them. */
|
|
91
|
+
running?: number
|
|
92
|
+
scheduled?: number
|
|
93
|
+
/** Live: not started because a dependency has not finished. */
|
|
94
|
+
blocked?: number
|
|
87
95
|
total: number
|
|
88
96
|
upToDate: number
|
|
89
97
|
restoredLocal: number
|
|
@@ -119,13 +127,20 @@ export type CacheBucket =
|
|
|
119
127
|
| 'restoredLocal'
|
|
120
128
|
| 'restoredRemote'
|
|
121
129
|
| 'saved'
|
|
130
|
+
| 'notSaved'
|
|
122
131
|
|
|
123
132
|
/** A finished task's own cache bucket; none for a group, a skip or an abort. */
|
|
124
133
|
export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
125
134
|
if (isGroupTask(o.node)) return undefined
|
|
126
135
|
switch (o.status) {
|
|
127
136
|
case 'success':
|
|
128
|
-
return ranNoCache(o)
|
|
137
|
+
return ranNoCache(o)
|
|
138
|
+
? 'noCache'
|
|
139
|
+
: o.saved === true
|
|
140
|
+
? 'saved'
|
|
141
|
+
: ranUnsaved(o)
|
|
142
|
+
? 'notSaved'
|
|
143
|
+
: 'miss'
|
|
129
144
|
case 'failed':
|
|
130
145
|
return ranNoCache(o) ? 'noCache' : 'miss'
|
|
131
146
|
case 'cache-hit':
|
|
@@ -148,8 +163,12 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
|
|
|
148
163
|
*/
|
|
149
164
|
function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
|
|
150
165
|
const saved = layers === undefined ? 0 : (stats.saved ?? 0)
|
|
166
|
+
// Its own yellow segment, as its legend count is: in the misses' red it
|
|
167
|
+
// was a cell with no count under it (M, 2026-10-21).
|
|
168
|
+
const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
|
|
151
169
|
const b: Buckets = {
|
|
152
|
-
miss: stats.miss - saved,
|
|
170
|
+
miss: stats.miss - saved - notSaved,
|
|
171
|
+
notSaved,
|
|
153
172
|
noCache: stats.noCache ?? 0,
|
|
154
173
|
upToDate: stats.upToDate,
|
|
155
174
|
restoredLocal: stats.restoredLocal,
|
|
@@ -182,6 +201,10 @@ export interface RunContext {
|
|
|
182
201
|
packageCount: number
|
|
183
202
|
/** The run's whole id, as `vx last` takes it; shown on the `info` row. */
|
|
184
203
|
runId?: string | undefined
|
|
204
|
+
/** The executors' own footer rows (`TaskExecutor.footer`), above the `info` row. */
|
|
205
|
+
executorRows?: () => readonly (readonly [string, ui.Inline])[]
|
|
206
|
+
/** Where a running task runs, `null` while it waits for a machine (`TaskExecutor.running`): its live row. */
|
|
207
|
+
whereRunning?: (taskId: string) => string | null | undefined
|
|
185
208
|
/** Worker-pool size for this run; shown on the `info` row. */
|
|
186
209
|
concurrency?: number
|
|
187
210
|
remoteCacheEnabled: boolean
|
|
@@ -203,6 +226,9 @@ export interface RunContext {
|
|
|
203
226
|
readonly inFlight: number
|
|
204
227
|
readonly keys: ReadonlyMap<string, { uploading: number; failures: readonly string[] }>
|
|
205
228
|
}
|
|
229
|
+
/** Bytes the run's remote hits pulled, and its landed uploads pushed (live refs). */
|
|
230
|
+
downloads?: { readonly bytes: number }
|
|
231
|
+
uploads?: { readonly bytes: number }
|
|
206
232
|
/** Tasks whose outputs are still in the remote store. */
|
|
207
233
|
notDownloaded: () => number
|
|
208
234
|
}
|
|
@@ -222,7 +248,15 @@ export function formatSummarySection(
|
|
|
222
248
|
const left = stats.left ?? 0
|
|
223
249
|
const aborted = stats.aborted ?? 0
|
|
224
250
|
const notRun = stats.notRun ?? 0
|
|
225
|
-
const
|
|
251
|
+
const running = stats.running ?? 0
|
|
252
|
+
const scheduled = stats.scheduled ?? 0
|
|
253
|
+
const blocked = stats.blocked ?? 0
|
|
254
|
+
const remainder = {
|
|
255
|
+
n: Math.max(0, left - running - scheduled - blocked),
|
|
256
|
+
color: '',
|
|
257
|
+
glyph: '\u25b1',
|
|
258
|
+
dim: true,
|
|
259
|
+
}
|
|
226
260
|
|
|
227
261
|
const dim = (txt: string) => paint('', txt, colors, { dim: true })
|
|
228
262
|
const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
|
|
@@ -239,6 +273,9 @@ export function formatSummarySection(
|
|
|
239
273
|
if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
|
|
240
274
|
if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
|
|
241
275
|
if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
|
|
276
|
+
if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
|
|
277
|
+
if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
|
|
278
|
+
if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
|
|
242
279
|
if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
|
|
243
280
|
const taskBar = segmentBar(
|
|
244
281
|
[
|
|
@@ -247,6 +284,9 @@ export function formatSummarySection(
|
|
|
247
284
|
{ n: stats.skipped, color: WARN },
|
|
248
285
|
{ n: aborted, color: ABORTED },
|
|
249
286
|
{ n: notRun, color: NOT_RUN },
|
|
287
|
+
{ n: running, color: RUNNING },
|
|
288
|
+
{ n: scheduled, color: '', dim: true },
|
|
289
|
+
{ n: blocked, color: '', dim: true },
|
|
250
290
|
remainder,
|
|
251
291
|
],
|
|
252
292
|
colors,
|
|
@@ -306,6 +346,7 @@ export function formatSummarySection(
|
|
|
306
346
|
const cacheBar = segmentBar(
|
|
307
347
|
[
|
|
308
348
|
{ n: b.miss, color: ERROR },
|
|
349
|
+
{ n: b.notSaved, color: WARN },
|
|
309
350
|
{ n: b.noCache, color: '', dim: true },
|
|
310
351
|
{ n: b.upToDate, color: SUCCESS },
|
|
311
352
|
{ n: b.restoredLocal, color: LOCAL },
|
|
@@ -335,16 +376,24 @@ export function formatSummarySection(
|
|
|
335
376
|
]
|
|
336
377
|
// The layer words are dim; only the counts carry colour (M, 2026-10-15).
|
|
337
378
|
if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
|
|
379
|
+
// Each transfer count is followed by its bytes, dim: what the
|
|
380
|
+
// remote moved is the cost a hit or an upload paid.
|
|
381
|
+
const sized = (count: number, glyph: string, bytes: number | undefined): string[] =>
|
|
382
|
+
count === 0
|
|
383
|
+
? []
|
|
384
|
+
: bytes === undefined || bytes === 0
|
|
385
|
+
? n(count, glyph, REMOTE)
|
|
386
|
+
: [...n(count, glyph, REMOTE), dim(formatBytes(bytes))]
|
|
338
387
|
const remote = [
|
|
339
|
-
...
|
|
340
|
-
...
|
|
388
|
+
...sized(b.restoredRemote, '\u2193', layers.downloads?.bytes),
|
|
389
|
+
...sized(b.uploaded, '\u2191', layers.uploads?.bytes),
|
|
341
390
|
...n(b.remoteFailed, '\u2717', ERROR),
|
|
342
391
|
...n(b.uploading, '\u21e1', ACCENT),
|
|
343
392
|
]
|
|
344
393
|
if (layers.remote && remote.length > 0)
|
|
345
394
|
cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
|
|
346
|
-
if (
|
|
347
|
-
|
|
395
|
+
if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
|
|
396
|
+
if (b.notSaved > 0) cacheParts.push(paint(WARN, `${b.notSaved} not saved`, colors))
|
|
348
397
|
const notDownloaded = layers.notDownloaded()
|
|
349
398
|
if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
|
|
350
399
|
} else {
|
|
@@ -398,10 +447,27 @@ export function formatSummarySection(
|
|
|
398
447
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
399
448
|
// grouped with time under a blank line below the meters.
|
|
400
449
|
if (context !== undefined) {
|
|
450
|
+
const own = context.executorRows?.() ?? []
|
|
451
|
+
if (own.length > 0)
|
|
452
|
+
lines.push('', ...own.map(([label, text]) => row(label, ui.inline(text, colors))))
|
|
401
453
|
const info: string[] = []
|
|
402
|
-
|
|
454
|
+
// An executor that says where tasks ran says it better than the pool.
|
|
455
|
+
if (context.concurrency !== undefined && own.length === 0)
|
|
403
456
|
info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
|
|
404
|
-
|
|
457
|
+
const layers = context.cache
|
|
458
|
+
info.push(
|
|
459
|
+
layers === undefined
|
|
460
|
+
? context.remoteCacheEnabled
|
|
461
|
+
? 'local + remote cache'
|
|
462
|
+
: 'local cache'
|
|
463
|
+
: layers.local && layers.remote
|
|
464
|
+
? 'local + remote cache'
|
|
465
|
+
: layers.local
|
|
466
|
+
? 'local cache'
|
|
467
|
+
: layers.remote
|
|
468
|
+
? 'remote cache'
|
|
469
|
+
: 'no cache',
|
|
470
|
+
)
|
|
405
471
|
if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
|
|
406
472
|
// The plugin's hand on the run: a task a policy held with a worker
|
|
407
473
|
// free would otherwise have started; without the line nothing says
|
package/src/settings.ts
CHANGED
|
@@ -50,6 +50,13 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
50
50
|
default: "origin/HEAD's branch, then main, then master",
|
|
51
51
|
doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
|
|
52
52
|
},
|
|
53
|
+
{
|
|
54
|
+
path: 'affectedBase',
|
|
55
|
+
kind: { type: 'string' },
|
|
56
|
+
default: "a plugin's base commit, else the merge base with `baseBranch`",
|
|
57
|
+
doc: "The commit a bare `--affected` compares with, on the base branch too. `--affected=<base>` beats it; it beats a plugin's base commit. Env only: a committed base would be stale on the next push.",
|
|
58
|
+
config: false,
|
|
59
|
+
},
|
|
53
60
|
{
|
|
54
61
|
path: 'run.concurrency',
|
|
55
62
|
kind: { type: 'concurrency' },
|
|
@@ -127,7 +134,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
127
134
|
path: 'cache.scope',
|
|
128
135
|
kind: { type: 'string' },
|
|
129
136
|
default: "the branch's, `<owner>/<repo>:<branch>`, with a remote cache off `baseBranch`",
|
|
130
|
-
doc: "
|
|
137
|
+
doc: "The branch scope plugin entries are keyed under (`'vznjs/vx:feat-x'`); task keys never carry it. Empty: no scope.",
|
|
131
138
|
config: false,
|
|
132
139
|
},
|
|
133
140
|
{
|
|
@@ -192,12 +199,6 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
192
199
|
default: 'true',
|
|
193
200
|
doc: 'Keep a larger entry as its plain files, which a hit copies or clones out; off, as one `.tar.zst`.',
|
|
194
201
|
},
|
|
195
|
-
{
|
|
196
|
-
path: 'cache.store.verify',
|
|
197
|
-
kind: { type: 'enum', values: ['stat', 'hash', 'none'] },
|
|
198
|
-
default: 'stat',
|
|
199
|
-
doc: "What a hit on plain files checks first: each file's size and mtime, every byte, or nothing.",
|
|
200
|
-
},
|
|
201
202
|
{
|
|
202
203
|
path: 'cache.upload.failures',
|
|
203
204
|
kind: { type: 'enum', values: ['ci', 'local', 'always'] },
|
package/src/util/colors.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface PaintOptions {
|
|
|
34
34
|
* NO_COLOR non-empty → off (overrides FORCE_COLOR)
|
|
35
35
|
* FORCE_COLOR=0 or =false → off, even on a TTY
|
|
36
36
|
* FORCE_COLOR any other value → on (the empty string too)
|
|
37
|
+
* a CI whose log renders ANSI (`ciPaints`) → on
|
|
37
38
|
* else: on iff stdout is a TTY that is not `TERM=dumb`.
|
|
38
39
|
* `0` and `false` are the convention's "off" (supports-color, chalk,
|
|
39
40
|
* Node); reading every non-empty value as on painted a piped CI log that
|
|
@@ -45,7 +46,7 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
|
|
|
45
46
|
? false
|
|
46
47
|
: force !== undefined
|
|
47
48
|
? force !== '0' && force !== 'false'
|
|
48
|
-
: stream.isTTY === true && !dumbTerminal()
|
|
49
|
+
: ciPaints() || (stream.isTTY === true && !dumbTerminal())
|
|
49
50
|
return {
|
|
50
51
|
enabled,
|
|
51
52
|
get columns() {
|
|
@@ -54,6 +55,20 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A CI whose log viewer renders ANSI colour though the job's output is a
|
|
60
|
+
* pipe (supports-color reads the same variables): its log was plain.
|
|
61
|
+
*/
|
|
62
|
+
function ciPaints(): boolean {
|
|
63
|
+
const env = process.env
|
|
64
|
+
return (
|
|
65
|
+
env['GITHUB_ACTIONS'] === 'true' ||
|
|
66
|
+
env['GITLAB_CI'] === 'true' ||
|
|
67
|
+
env['BUILDKITE'] === 'true' ||
|
|
68
|
+
env['CIRCLECI'] === 'true'
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
73
|
* `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
|
|
59
74
|
* nor move the cursor: vx prints to it as off a terminal.
|
package/src/util/index.ts
CHANGED
package/src/util/paths.ts
CHANGED
|
@@ -117,6 +117,36 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
|
|
|
117
117
|
return [...new Set(out)]
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* The directories whose snapshot covers every path the positive `globs`
|
|
122
|
+
* can match, or `null` when one reaches outside the project. A
|
|
123
|
+
* whole-subtree glob or a literal is its own tree
|
|
124
|
+
* ({@link wholeSubtreePrefixes}); any other glob is the tree under its
|
|
125
|
+
* static prefix, and a root-anchored one (`**\/dist/**`, Turbo's shape) is
|
|
126
|
+
* the whole project, `''`. Sound for the same reason: a file added or
|
|
127
|
+
* removed anywhere under a recorded directory bumps a recorded mtime. The
|
|
128
|
+
* snapshot's file list is then held to the entry's rows through the globs
|
|
129
|
+
* themselves, so a source file beside the outputs is no stray.
|
|
130
|
+
*/
|
|
131
|
+
export function outputDirPrefixes(globs: readonly string[]): string[] | null {
|
|
132
|
+
if (globs.length === 0) return null
|
|
133
|
+
const dirs: string[] = []
|
|
134
|
+
for (const g of globs) {
|
|
135
|
+
const whole = wholeSubtreePrefixes([g])?.[0]
|
|
136
|
+
if (whole !== undefined) {
|
|
137
|
+
dirs.push(whole)
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
const prefix = staticPrefix(g)
|
|
141
|
+
if (prefix === '.') return ['']
|
|
142
|
+
if (prefix.startsWith('/') || prefix.startsWith('!') || prefix.split('/').includes('..'))
|
|
143
|
+
return null
|
|
144
|
+
dirs.push(prefix)
|
|
145
|
+
}
|
|
146
|
+
const sorted = [...new Set(dirs)].sort()
|
|
147
|
+
return sorted.filter((d, i) => !sorted.slice(0, i).some((a) => d.startsWith(`${a}/`)))
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
/**
|
|
121
151
|
* The spellings a reader, Turbo and `.gitignore` all accept but a matcher
|
|
122
152
|
* fed the raw string turns into NOTHING — and a task keyed on nothing
|
package/src/util/ui.ts
CHANGED
|
@@ -40,10 +40,13 @@ export const path = (text: string): Inline => ({ style: 'bold', text })
|
|
|
40
40
|
export const cmd = (text: string): Inline => ({ style: 'bold', text })
|
|
41
41
|
/** A secondary fact. */
|
|
42
42
|
export const dim = (text: string): Inline => ({ style: 'dim', text })
|
|
43
|
+
/** Where a task runs (`small#1`): its name's hue in every row and block; `local` plain. */
|
|
44
|
+
export const place = (text: string): Inline => (text === 'local' ? text : id(text))
|
|
43
45
|
/** Text in a tone's color. */
|
|
44
46
|
export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
|
|
45
47
|
|
|
46
|
-
|
|
48
|
+
/** One inline as text, colored for `c`. */
|
|
49
|
+
export function inline(x: Inline, c: ColorSupport): string {
|
|
47
50
|
if (typeof x === 'string') return x
|
|
48
51
|
if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
|
|
49
52
|
const { style, text } = x as { style: Style; text: string }
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
gitSpawnRefusal,
|
|
15
15
|
isExecutableMissing,
|
|
16
16
|
isInstalledPath,
|
|
17
|
-
isUserError,
|
|
18
17
|
notAWorkTree,
|
|
19
18
|
relPosix,
|
|
20
19
|
UserError,
|
|
@@ -741,44 +740,6 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
|
|
|
741
740
|
return undefined
|
|
742
741
|
}
|
|
743
742
|
|
|
744
|
-
/**
|
|
745
|
-
* What a branch changed, for which of its tasks read its cache scope
|
|
746
|
-
* (owner, 2026-10-17): from `base` (a plugin's: a pull request's base
|
|
747
|
-
* commit), else from the merge base with the base branch. No base, or
|
|
748
|
-
* one git cannot resolve (a CI checkout at `fetch-depth: 1`), is no
|
|
749
|
-
* change: every task reads the default key, which costs hits, never
|
|
750
|
-
* correctness, since a key must still match.
|
|
751
|
-
*/
|
|
752
|
-
export async function branchChanges(
|
|
753
|
-
workspaceRoot: string,
|
|
754
|
-
base: string | undefined,
|
|
755
|
-
baseBranch: string | undefined,
|
|
756
|
-
projects: Promise<readonly ProjectMeta[]>,
|
|
757
|
-
/** The run's own enumeration, so the diff walks nothing a second time. */
|
|
758
|
-
walked: Pick<AffectedArgs, 'untracked' | 'links'> = {},
|
|
759
|
-
): Promise<AffectedChanges> {
|
|
760
|
-
const none: AffectedChanges = {
|
|
761
|
-
projects: new Set(),
|
|
762
|
-
changed: [],
|
|
763
|
-
paths: new Map(),
|
|
764
|
-
whole: new Set(),
|
|
765
|
-
}
|
|
766
|
-
const since = base ?? (await baseBranchRef(workspaceRoot, baseBranch))?.ref
|
|
767
|
-
if (since === undefined) return none
|
|
768
|
-
try {
|
|
769
|
-
return await affectedChanges({
|
|
770
|
-
workspaceRoot,
|
|
771
|
-
since,
|
|
772
|
-
projects,
|
|
773
|
-
configImports: false,
|
|
774
|
-
...walked,
|
|
775
|
-
})
|
|
776
|
-
} catch (err) {
|
|
777
|
-
if (!isUserError(err)) throw err
|
|
778
|
-
return none
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
|
|
782
743
|
/**
|
|
783
744
|
* The base branch as a ref: `origin/<name>`, else the local branch (a
|
|
784
745
|
* clone with no remote), and whether HEAD is on it. The name is the
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// Every run evaluates `.vx/workspace.ts`, and with it every plugin package
|
|
2
|
+
// it imports: Bun parses and compiles that TypeScript on each run, and a
|
|
3
|
+
// plugin's functions compile again on their first call, while core itself
|
|
4
|
+
// runs from the binary's bytecode. On a solid-sized workspace that was
|
|
5
|
+
// ~4 ms of a ~60 ms warm run (the `discover` stage alone 9 → 5 ms,
|
|
6
|
+
// 2026-10-22). So the config's module graph is bundled once into CommonJS
|
|
7
|
+
// with Bun's bytecode under `.vx/cache/compiled/`, and a later run whose
|
|
8
|
+
// every input still has the bytes it was built from requires that instead.
|
|
9
|
+
//
|
|
10
|
+
// The bundle is built from the bytes it records, file by file, and is used
|
|
11
|
+
// only while each recorded file still hashes the same and each bare import
|
|
12
|
+
// still resolves to the file it did; anything else loads the source as
|
|
13
|
+
// before and builds again. A graph the bundle could run differently is
|
|
14
|
+
// never built: an `import.meta` field beyond its location, a `require` or
|
|
15
|
+
// `import()` of a computed specifier (relative to the bundle, not the
|
|
16
|
+
// file), top-level await, a CommonJS config, a file this process already
|
|
17
|
+
// evaluated (a bundled copy would be a second state; run from source, core
|
|
18
|
+
// itself). The bundle goes
|
|
19
|
+
// into `.vx/cache` only once a run made it, so a reading verb on a fresh
|
|
20
|
+
// workspace writes nothing (item 900). `.vx/cache` is trusted as the rest
|
|
21
|
+
// of the cache is: what it holds is restored into the tree too.
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readdirSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
realpathSync,
|
|
29
|
+
renameSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
} from 'node:fs'
|
|
32
|
+
import { writeFile } from 'node:fs/promises'
|
|
33
|
+
import { createRequire } from 'node:module'
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { xxh3hex } from '../util/index.js'
|
|
36
|
+
|
|
37
|
+
/** Bumped when what a bundle holds, or how it is checked, changes. */
|
|
38
|
+
const COMPILED_VERSION = 1
|
|
39
|
+
|
|
40
|
+
/** What `import.meta` becomes in a bundled file: an identifier no source spells. */
|
|
41
|
+
const META = '__vxImportMeta'
|
|
42
|
+
const META_FIELDS = new Set(['dir', 'dirname', 'path', 'filename', 'file', 'url', 'main'])
|
|
43
|
+
const META_USE = new RegExp(`\\b${META}\\b(\\.[\\w$]+)?`, 'g')
|
|
44
|
+
const COMPUTED_LOAD = /\b(?:require|import)\(\s*(?![\s'"])|\brequire\.resolve\b/
|
|
45
|
+
|
|
46
|
+
interface Manifest {
|
|
47
|
+
version: number
|
|
48
|
+
/** The config file by real path; its bytes are in the bundle's name, so it is not read again (principle 9). */
|
|
49
|
+
entry: string
|
|
50
|
+
/** Every file the bundle was built from, by real path, and its bytes' hash. */
|
|
51
|
+
inputs: Record<string, string>
|
|
52
|
+
/** Each bare import: the importing file's directory, the specifier, the real path it resolved to. */
|
|
53
|
+
bare: [string, string, string][]
|
|
54
|
+
/** The graph cannot be bundled faithfully: load the source while the inputs stay as they are. */
|
|
55
|
+
skip?: true
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function compiledDir(root: string): string {
|
|
59
|
+
return path.join(root, '.vx', 'cache', 'compiled')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function nameOf(configPath: string, bytes: Uint8Array): string {
|
|
63
|
+
return `workspace-${xxh3hex(`${COMPILED_VERSION}\0${Bun.revision}\0${configPath}\0${xxh3hex(bytes)}`)}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fresh(manifest: Manifest): boolean {
|
|
67
|
+
for (const [file, hash] of Object.entries(manifest.inputs)) {
|
|
68
|
+
if (file === manifest.entry) continue
|
|
69
|
+
let bytes: Uint8Array
|
|
70
|
+
try {
|
|
71
|
+
bytes = readFileSync(file)
|
|
72
|
+
} catch {
|
|
73
|
+
return false
|
|
74
|
+
}
|
|
75
|
+
if (xxh3hex(bytes) !== hash) return false
|
|
76
|
+
}
|
|
77
|
+
for (const [from, spec, to] of manifest.bare) {
|
|
78
|
+
try {
|
|
79
|
+
if (realpathSync(Bun.resolveSync(spec, from)) !== to) return false
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The workspace config's module namespace from its bundle: `undefined` when
|
|
89
|
+
* there is none for these bytes or an input moved (`build` then makes one),
|
|
90
|
+
* `null` when the graph is one no bundle runs faithfully.
|
|
91
|
+
*/
|
|
92
|
+
export function loadCompiled(
|
|
93
|
+
root: string,
|
|
94
|
+
configPath: string,
|
|
95
|
+
bytes: Uint8Array,
|
|
96
|
+
): { default?: unknown } | null | undefined {
|
|
97
|
+
// A repeat load in this process (the run after the CLI's selection pass,
|
|
98
|
+
// a `vx watch` cycle) of the same bytes: the loader already refused an
|
|
99
|
+
// import edited since the first, so the answer stands (principle 9).
|
|
100
|
+
const seen = answered.get(configPath)
|
|
101
|
+
if (seen !== undefined && Buffer.from(seen.bytes).equals(bytes)) return seen.ns
|
|
102
|
+
const name = nameOf(configPath, bytes)
|
|
103
|
+
const dir = compiledDir(root)
|
|
104
|
+
let manifest: Manifest
|
|
105
|
+
try {
|
|
106
|
+
manifest = JSON.parse(readFileSync(path.join(dir, `${name}.json`), 'utf8')) as Manifest
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
if (manifest.version !== COMPILED_VERSION || !fresh(manifest)) return undefined
|
|
111
|
+
const ns =
|
|
112
|
+
manifest.skip === true
|
|
113
|
+
? null
|
|
114
|
+
: (createRequire(path.join(dir, 'index.js'))(path.join(dir, `${name}.js`)) as {
|
|
115
|
+
default?: unknown
|
|
116
|
+
})
|
|
117
|
+
answered.set(configPath, { bytes, ns })
|
|
118
|
+
return ns
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What `loadCompiled` answered for each config, by the bytes it was asked with. */
|
|
122
|
+
const answered = new Map<string, { bytes: Uint8Array; ns: { default?: unknown } | null }>()
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The files this process has evaluated as modules. One the config imports
|
|
126
|
+
* by path (run from source, core's own `src/index.ts`) is shared with the
|
|
127
|
+
* process, and a bundle would run a second copy of its state.
|
|
128
|
+
*/
|
|
129
|
+
export function loadedModules(root: string): Set<string> {
|
|
130
|
+
// Not `import.meta`: core's planner also runs as a browser script (the playground).
|
|
131
|
+
return new Set(Object.keys(createRequire(path.join(root, 'index.js')).cache))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The loader Bun reads `file` with, or null for a file the bundle must not hold. */
|
|
135
|
+
function loaderOf(file: string): 'ts' | 'tsx' | 'js' | 'jsx' | null {
|
|
136
|
+
const ext = path.extname(file)
|
|
137
|
+
if (ext === '.ts' || ext === '.mts') return 'ts'
|
|
138
|
+
if (ext === '.tsx') return 'tsx'
|
|
139
|
+
if (ext === '.js' || ext === '.mjs') return 'js'
|
|
140
|
+
if (ext === '.jsx') return 'jsx'
|
|
141
|
+
return null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Bundle the workspace config's graph for the next run. A speed-up only:
|
|
146
|
+
* every failure leaves the next run loading the source.
|
|
147
|
+
*/
|
|
148
|
+
export async function buildCompiled(
|
|
149
|
+
root: string,
|
|
150
|
+
configPath: string,
|
|
151
|
+
bytes: Uint8Array,
|
|
152
|
+
/** `loadedModules()` before the config's source load. */
|
|
153
|
+
loaded: ReadonlySet<string>,
|
|
154
|
+
/** What that load evaluated: a repeat load in this process gets it, not a bundle or a second build. */
|
|
155
|
+
ns: { default?: unknown },
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
answered.set(configPath, { bytes, ns })
|
|
158
|
+
const dir = compiledDir(root)
|
|
159
|
+
if (!existsSync(path.dirname(dir))) return
|
|
160
|
+
const name = nameOf(configPath, bytes)
|
|
161
|
+
const entryFile = realpathSync(configPath)
|
|
162
|
+
const inputs: Record<string, string> = {}
|
|
163
|
+
let faithful = true
|
|
164
|
+
const transpilers = new Map<string, Bun.Transpiler>()
|
|
165
|
+
const tmp = path.join(dir, `.tmp-${process.pid}-${Date.now()}`)
|
|
166
|
+
try {
|
|
167
|
+
mkdirSync(tmp, { recursive: true })
|
|
168
|
+
const built = await Bun.build({
|
|
169
|
+
entrypoints: [configPath],
|
|
170
|
+
target: 'bun',
|
|
171
|
+
format: 'cjs',
|
|
172
|
+
bytecode: true,
|
|
173
|
+
external: ['@vzn/vx'],
|
|
174
|
+
outdir: tmp,
|
|
175
|
+
naming: `${name}.js`,
|
|
176
|
+
metafile: true,
|
|
177
|
+
throw: false,
|
|
178
|
+
plugins: [
|
|
179
|
+
{
|
|
180
|
+
name: 'vx-config-bytecode',
|
|
181
|
+
setup(build) {
|
|
182
|
+
build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
|
|
183
|
+
const file = realpathSync(args.path)
|
|
184
|
+
if (loaded.has(file)) {
|
|
185
|
+
faithful = false
|
|
186
|
+
inputs[file] = xxh3hex(await Bun.file(file).bytes())
|
|
187
|
+
return { contents: '', loader: 'js' }
|
|
188
|
+
}
|
|
189
|
+
const loader = loaderOf(file)
|
|
190
|
+
const source = file === entryFile ? bytes : await Bun.file(file).bytes()
|
|
191
|
+
inputs[file] = xxh3hex(source)
|
|
192
|
+
if (loader === null || /\.c[jt]s$/.test(file)) {
|
|
193
|
+
faithful = false
|
|
194
|
+
return { contents: source, loader: loader ?? 'js' }
|
|
195
|
+
}
|
|
196
|
+
const fileDir = path.dirname(file)
|
|
197
|
+
let t = transpilers.get(file)
|
|
198
|
+
if (t === undefined) {
|
|
199
|
+
t = new Bun.Transpiler({
|
|
200
|
+
loader,
|
|
201
|
+
define: {
|
|
202
|
+
'import.meta': META,
|
|
203
|
+
__dirname: JSON.stringify(fileDir),
|
|
204
|
+
__filename: JSON.stringify(file),
|
|
205
|
+
},
|
|
206
|
+
})
|
|
207
|
+
transpilers.set(file, t)
|
|
208
|
+
}
|
|
209
|
+
const js = t.transformSync(new TextDecoder().decode(source))
|
|
210
|
+
for (const m of js.matchAll(META_USE)) {
|
|
211
|
+
if (m[1] !== undefined && !META_FIELDS.has(m[1].slice(1))) faithful = false
|
|
212
|
+
}
|
|
213
|
+
if (COMPUTED_LOAD.test(js)) faithful = false
|
|
214
|
+
const meta = {
|
|
215
|
+
dir: fileDir,
|
|
216
|
+
dirname: fileDir,
|
|
217
|
+
path: file,
|
|
218
|
+
filename: file,
|
|
219
|
+
file: path.basename(file),
|
|
220
|
+
url: Bun.pathToFileURL(file).href,
|
|
221
|
+
main: false,
|
|
222
|
+
}
|
|
223
|
+
return { contents: `const ${META} = ${JSON.stringify(meta)};\n${js}`, loader: 'js' }
|
|
224
|
+
})
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
})
|
|
229
|
+
const out = path.join(tmp, `${name}.js`)
|
|
230
|
+
const bytecode = built.success && Bun.file(`${out}.jsc`).size > 0
|
|
231
|
+
const manifest: Manifest = {
|
|
232
|
+
version: COMPILED_VERSION,
|
|
233
|
+
entry: entryFile,
|
|
234
|
+
inputs,
|
|
235
|
+
bare: bareImports(built.metafile),
|
|
236
|
+
...(faithful && bytecode ? {} : { skip: true as const }),
|
|
237
|
+
}
|
|
238
|
+
// The metafile names every input; one no onLoad saw (a JSON import) is
|
|
239
|
+
// bytes the manifest does not check.
|
|
240
|
+
for (const [input, entry] of Object.entries(built.metafile?.inputs ?? {})) {
|
|
241
|
+
const file = realpathSync(path.resolve(input))
|
|
242
|
+
if (inputs[file] === undefined) manifest.skip = true
|
|
243
|
+
// A CommonJS config's export is `module.exports`, not a namespace's `default`.
|
|
244
|
+
if (file === entryFile && entry.format === 'cjs') manifest.skip = true
|
|
245
|
+
}
|
|
246
|
+
for (const old of readdirSync(dir)) {
|
|
247
|
+
if (old.startsWith('workspace-')) rmSync(path.join(dir, old), { force: true })
|
|
248
|
+
}
|
|
249
|
+
if (manifest.skip !== true) {
|
|
250
|
+
renameSync(`${out}.jsc`, path.join(dir, `${name}.js.jsc`))
|
|
251
|
+
renameSync(out, path.join(dir, `${name}.js`))
|
|
252
|
+
}
|
|
253
|
+
// Last: a manifest names a bundle already in place.
|
|
254
|
+
await writeFile(path.join(tmp, 'manifest.json'), JSON.stringify(manifest))
|
|
255
|
+
renameSync(path.join(tmp, 'manifest.json'), path.join(dir, `${name}.json`))
|
|
256
|
+
} catch {
|
|
257
|
+
// A read-only cache directory, a full disk, a graph Bun cannot bundle:
|
|
258
|
+
// the next run loads the source, as this one did.
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(tmp, { recursive: true, force: true })
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function bareImports(metafile: Bun.BuildMetafile | undefined): [string, string, string][] {
|
|
265
|
+
const out: [string, string, string][] = []
|
|
266
|
+
for (const [input, entry] of Object.entries(metafile?.inputs ?? {})) {
|
|
267
|
+
const from = path.dirname(realpathSync(path.resolve(input)))
|
|
268
|
+
for (const imp of entry.imports) {
|
|
269
|
+
const spec = imp.original
|
|
270
|
+
if (imp.external === true || spec === undefined) continue
|
|
271
|
+
if (spec.startsWith('.') || path.isAbsolute(spec)) continue
|
|
272
|
+
out.push([from, spec, realpathSync(path.resolve(imp.path))])
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return out
|
|
276
|
+
}
|
package/src/workspace/index.ts
CHANGED
|
@@ -6,7 +6,6 @@ export {
|
|
|
6
6
|
type AffectedChanges,
|
|
7
7
|
affectedProjects,
|
|
8
8
|
defaultAffectedBase,
|
|
9
|
-
branchChanges,
|
|
10
9
|
type FingerprintClaims,
|
|
11
10
|
refIsHead,
|
|
12
11
|
} from './affected.js'
|
|
@@ -78,5 +77,4 @@ export {
|
|
|
78
77
|
type GeneratedTask,
|
|
79
78
|
type MigrationPlan,
|
|
80
79
|
type RawExpr,
|
|
81
|
-
vxInvocation,
|
|
82
80
|
} from './migration.js'
|