@vzn/vx 0.0.639 → 0.0.641
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 +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/handoff.ts +5 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +66 -27
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -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/src/workspace/project-loader.ts +5 -4
package/src/cache/layer.ts
CHANGED
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
// reference to `Cache` below is a type (the local handle a layer may wrap).
|
|
6
6
|
|
|
7
7
|
import type { Cache } from './cache.js'
|
|
8
|
+
import { fsClockLag, stampGrainMs } from './fs-clock.js'
|
|
8
9
|
|
|
9
10
|
export interface CacheKeyInput {
|
|
10
11
|
taskId: string
|
|
11
12
|
/**
|
|
12
13
|
* Hash of the resolved task config (post-evaluation). Folds in everything
|
|
13
14
|
* the user wrote — command, env declarations (passThrough names + define
|
|
14
|
-
* key/value pairs), dependsOn,
|
|
15
|
+
* key/value pairs), dependsOn, inputs declarations, outputs — including
|
|
15
16
|
* values that arrived via `import` at config-load time.
|
|
16
17
|
*/
|
|
17
18
|
taskConfigHash: string
|
|
@@ -22,12 +23,12 @@ export interface CacheKeyInput {
|
|
|
22
23
|
*/
|
|
23
24
|
envValues: Array<[name: string, value: string | undefined]>
|
|
24
25
|
/**
|
|
25
|
-
* Resolved `
|
|
26
|
+
* Resolved `inputs.runtime` commands as [command, output] pairs
|
|
26
27
|
* (output = trimmed stdout+stderr, resolved live at hash time). Folded
|
|
27
28
|
* into the key in a namespace distinct from workspaceRuntimeValues.
|
|
28
29
|
*/
|
|
29
30
|
runtimeValues?: Array<[command: string, output: string]>
|
|
30
|
-
/** Resolved `
|
|
31
|
+
/** Resolved `inputs.workspaceRuntime` pairs (root-cwd commands). */
|
|
31
32
|
workspaceRuntimeValues?: Array<[command: string, output: string]>
|
|
32
33
|
/** Absolute paths to input files. */
|
|
33
34
|
inputFiles: string[]
|
|
@@ -51,7 +52,7 @@ export interface CacheKeyInput {
|
|
|
51
52
|
* - It expands groups, which contribute a synthetic roll-up hash and no
|
|
52
53
|
* outputs of their own, so a dependent of one would otherwise describe
|
|
53
54
|
* an empty closure.
|
|
54
|
-
* - It ignores `
|
|
55
|
+
* - It ignores `inputs.tasks`. That filter says which upstream KEYS
|
|
55
56
|
* this task's key folds; what the task may READ is `dependsOn`, and
|
|
56
57
|
* locally every dependency's outputs are on disk before the command runs
|
|
57
58
|
* however the filter is written.
|
|
@@ -411,11 +412,11 @@ export const FILE_HASH_RACY_MS = 50
|
|
|
411
412
|
* field (A-38). Pass the UNFLOORED stamp: a kernel's nanosecond stamp lands
|
|
412
413
|
* on a whole second about once in 10^9, one set through a millisecond
|
|
413
414
|
* `utimes` once in a thousand, and either is then trusted up to two seconds
|
|
414
|
-
* later — never wrongly.
|
|
415
|
+
* later — never wrongly. And every window is widened by how far the file
|
|
416
|
+
* system's clock runs behind this process's (`fsClockLag`, fs-clock.ts).
|
|
415
417
|
*/
|
|
416
418
|
export function racyWindowMs(stampMs: number, windowMs: number): number {
|
|
417
|
-
|
|
418
|
-
return stampMs % 1000 === 0 ? windowMs + 1000 : windowMs
|
|
419
|
+
return windowMs + stampGrainMs(stampMs) + fsClockLag()
|
|
419
420
|
}
|
|
420
421
|
|
|
421
422
|
/**
|
|
@@ -676,7 +677,13 @@ export interface CacheLayer {
|
|
|
676
677
|
* into the workspace root. Callers restoring entries that may carry
|
|
677
678
|
* workspace outputs must pass `workspaceRoot`.
|
|
678
679
|
*/
|
|
679
|
-
restoreOutputs(
|
|
680
|
+
restoreOutputs(
|
|
681
|
+
hash: string,
|
|
682
|
+
projectDir: string,
|
|
683
|
+
workspaceRoot?: string,
|
|
684
|
+
/** The task's declared outputs: an entry outside them fails the restore, nothing written. */
|
|
685
|
+
outputs?: DeclaredOutputs,
|
|
686
|
+
): Promise<void>
|
|
680
687
|
save(args: {
|
|
681
688
|
hash: string
|
|
682
689
|
/**
|
|
@@ -21,6 +21,7 @@ import type { Cache } from './cache.js'
|
|
|
21
21
|
import type {
|
|
22
22
|
CacheEntry,
|
|
23
23
|
CacheGetContext,
|
|
24
|
+
DeclaredOutputs,
|
|
24
25
|
CacheKeyInput,
|
|
25
26
|
CacheLayer,
|
|
26
27
|
CacheStats,
|
|
@@ -61,11 +62,6 @@ export interface RemoteCacheLayer {
|
|
|
61
62
|
* dropped before it is printed.
|
|
62
63
|
*/
|
|
63
64
|
readonly endpoint?: string
|
|
64
|
-
/**
|
|
65
|
-
* What the wire's credential allows, when less than both (a read-only
|
|
66
|
-
* token): core clears the run's remote axis it lacks. Omitted → both.
|
|
67
|
-
*/
|
|
68
|
-
readonly access?: { readonly read?: boolean; readonly write?: boolean }
|
|
69
65
|
/** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
|
|
70
66
|
has(hash: string): Promise<boolean>
|
|
71
67
|
/**
|
|
@@ -330,8 +326,13 @@ export class LayeredCache implements CacheLayer {
|
|
|
330
326
|
return this.local.hashFile(filePath)
|
|
331
327
|
}
|
|
332
328
|
|
|
333
|
-
async restoreOutputs(
|
|
334
|
-
|
|
329
|
+
async restoreOutputs(
|
|
330
|
+
hash: string,
|
|
331
|
+
projectDir: string,
|
|
332
|
+
workspaceRoot?: string,
|
|
333
|
+
outputs?: DeclaredOutputs,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
await this.local.restoreOutputs(hash, projectDir, workspaceRoot, outputs)
|
|
335
336
|
}
|
|
336
337
|
|
|
337
338
|
async save(args: SaveArgs): Promise<void> {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// A symlinked input keys the content behind it, as Nx reads a linked file
|
|
2
|
+
// where it points (owner, 2026-10-16: "Do like nx"). Its own fold stays the
|
|
3
|
+
// blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
|
|
4
|
+
// moves the key; this is what follows it. The target string alone let an
|
|
5
|
+
// edit behind the link replay the old output (Growth, 2026-10-16), wherever
|
|
6
|
+
// the target lies: in the project, elsewhere in the workspace, outside it.
|
|
7
|
+
// A directory link keys every file beneath it, tracked or not, where Nx
|
|
8
|
+
// does not enter one: an edit there is an edit to what the task reads.
|
|
9
|
+
// `--affected` (workspace/affected.ts) and `vx watch` (cli/watch.ts) read
|
|
10
|
+
// the same targets.
|
|
11
|
+
|
|
12
|
+
import { readdirSync, realpathSync, statSync } from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { xxh3hex } from '../util/index.js'
|
|
15
|
+
import { absentOr } from './key-fold.js'
|
|
16
|
+
|
|
17
|
+
/** What a dangling link, or a loop of links, holds behind it: nothing to read. */
|
|
18
|
+
const NOTHING_BEHIND = ''
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Directories a linked directory's walk never enters, as Nx's walker never
|
|
22
|
+
* does (its HARDCODED_IGNORE_PATTERNS): a link to a workspace would
|
|
23
|
+
* otherwise key the cache each run writes.
|
|
24
|
+
*/
|
|
25
|
+
const SKIPPED = ['.git', 'node_modules', '.vx/cache', '.yarn/cache']
|
|
26
|
+
const skipped = (rel: string): boolean => SKIPPED.some((s) => rel === s || rel.endsWith(`/${s}`))
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The files under `dir` a linked directory keys, relative and `/`-separated,
|
|
30
|
+
* sorted: every regular file and link at any depth. A link is listed, never
|
|
31
|
+
* entered (`behindLink` follows it), so a loop of directories ends.
|
|
32
|
+
*/
|
|
33
|
+
export function filesBehind(dir: string): string[] {
|
|
34
|
+
const out: string[] = []
|
|
35
|
+
const walk = (abs: string, rel: string): void => {
|
|
36
|
+
let entries
|
|
37
|
+
try {
|
|
38
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
39
|
+
} catch {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
44
|
+
if (e.isDirectory()) {
|
|
45
|
+
if (!skipped(childRel)) walk(path.join(abs, e.name), childRel)
|
|
46
|
+
} else if (e.isFile() || e.isSymbolicLink()) out.push(childRel)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
walk(dir, '')
|
|
50
|
+
return out.sort()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The content behind the symlink at `link`, as one string: a file's identity
|
|
55
|
+
* (`hashFile` of its real path), a directory's files and theirs, or
|
|
56
|
+
* `NOTHING_BEHIND` for a dangling link or a loop. A special file (a FIFO, a
|
|
57
|
+
* device) is named, never read: a read can block. Never memoized: an upstream
|
|
58
|
+
* task may write behind the link between two keys.
|
|
59
|
+
*/
|
|
60
|
+
export async function behindLink(
|
|
61
|
+
link: string,
|
|
62
|
+
hashFile: (file: string) => Promise<string>,
|
|
63
|
+
seen: ReadonlySet<string> = new Set(),
|
|
64
|
+
): Promise<string> {
|
|
65
|
+
let real: string
|
|
66
|
+
let st
|
|
67
|
+
try {
|
|
68
|
+
real = realpathSync(link)
|
|
69
|
+
st = statSync(real)
|
|
70
|
+
} catch {
|
|
71
|
+
return NOTHING_BEHIND
|
|
72
|
+
}
|
|
73
|
+
if (seen.has(real)) return NOTHING_BEHIND
|
|
74
|
+
if (st.isFile()) return `file\0${await hashFile(real).catch(absentOr)}`
|
|
75
|
+
if (!st.isDirectory()) return 'special'
|
|
76
|
+
const inner = new Set(seen).add(real)
|
|
77
|
+
const parts = await Promise.all(
|
|
78
|
+
filesBehind(real).map(async (rel) => {
|
|
79
|
+
const abs = path.join(real, rel)
|
|
80
|
+
const id = await hashFile(abs).catch(absentOr)
|
|
81
|
+
return id.startsWith('120000:')
|
|
82
|
+
? `${rel}\0${id}\0${await behindLink(abs, hashFile, inner)}`
|
|
83
|
+
: `${rel}\0${id}`
|
|
84
|
+
}),
|
|
85
|
+
)
|
|
86
|
+
return `dir\0${xxh3hex(parts.join('\n'))}`
|
|
87
|
+
}
|
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
CacheEntry,
|
|
9
9
|
CacheGetContext,
|
|
10
10
|
CacheLayer,
|
|
11
|
+
DeclaredOutputs,
|
|
11
12
|
IngestMeta,
|
|
12
13
|
InvocationRecord,
|
|
13
14
|
OutputDirRow,
|
|
@@ -194,8 +195,13 @@ export class ScopedCache implements CacheLayer {
|
|
|
194
195
|
return (await this.inner.outputDirsCurrent?.(projectDir, rows)) ?? false
|
|
195
196
|
}
|
|
196
197
|
|
|
197
|
-
restoreOutputs(
|
|
198
|
-
|
|
198
|
+
restoreOutputs(
|
|
199
|
+
hash: string,
|
|
200
|
+
projectDir: string,
|
|
201
|
+
workspaceRoot?: string,
|
|
202
|
+
outputs?: DeclaredOutputs,
|
|
203
|
+
): Promise<void> {
|
|
204
|
+
return this.inner.restoreOutputs(this.keyOf(hash), projectDir, workspaceRoot, outputs)
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
save(args: SaveArgs): Promise<void> {
|
package/src/cli/foreign-flags.ts
CHANGED
|
@@ -343,7 +343,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
|
|
|
343
343
|
names: ['--global-deps'],
|
|
344
344
|
value: true,
|
|
345
345
|
outcome: 'refuse',
|
|
346
|
-
vx: "declare them in `
|
|
346
|
+
vx: "declare them in `inputs.workspaceFiles` (under `turbo()`, turbo.json's `globalDependencies`)",
|
|
347
347
|
},
|
|
348
348
|
{
|
|
349
349
|
runner: 'turbo',
|
package/src/cli/help.ts
CHANGED
|
@@ -117,7 +117,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
117
117
|
' vx cache clean [--older-than <duration>] [--max-size <size>] [--global] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
|
|
118
118
|
' vx lock [--check] [--format pretty|json]',
|
|
119
119
|
' vx init [--dry [--format pretty|json]] [--force] [--mjs] [--native|--keep] [--plugin <seam>]',
|
|
120
|
-
' vx upgrade [TAG]',
|
|
120
|
+
' vx upgrade [TAG] [-g|--global]',
|
|
121
121
|
' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
|
|
122
122
|
' vx info [--format pretty|json] [--cache-dir <path>]',
|
|
123
123
|
' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
|
|
@@ -274,6 +274,12 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
274
274
|
' test (executor, cache, telemetry, schedule, admit, commands,',
|
|
275
275
|
' project, graph, key).',
|
|
276
276
|
'',
|
|
277
|
+
'Upgrade:',
|
|
278
|
+
' vx upgrade [TAG] Bring vx to TAG (default: latest) the way it was installed.',
|
|
279
|
+
" In a project: the project's vx, its @vzn/vx-* plugins and",
|
|
280
|
+
' the global vx that ran it, to one release.',
|
|
281
|
+
' -g, --global Only the global vx: no project, no plugin.',
|
|
282
|
+
'',
|
|
277
283
|
'Shell completions:',
|
|
278
284
|
' vx completions <shell> Print a completion script for bash, zsh or fish: the',
|
|
279
285
|
' verbs (plugin verbs of this workspace included) and',
|
package/src/cli/init.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { gitTop, workspaceFileWritten } from '../handoff.js'
|
|
|
13
13
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
14
14
|
import { lockAfterMigration } from './lock.js'
|
|
15
15
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
16
|
-
import { isCompiledBinary } from './upgrade.js'
|
|
16
|
+
import { isCompiledBinary, targetVersion } from './upgrade.js'
|
|
17
17
|
import { VERSION } from '../version.js'
|
|
18
18
|
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
|
|
19
19
|
import {
|
|
@@ -217,8 +217,11 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
/**
|
|
220
|
-
* A repo with a package.json is `@vzn/vx-migrate`'s
|
|
221
|
-
*
|
|
220
|
+
* A repo with a package.json is `@vzn/vx-migrate`'s, at the latest
|
|
221
|
+
* release: it installs @vzn/vx and every @vzn/vx-* plugin the root lists
|
|
222
|
+
* at its own version, so init always lands on latest with all of them
|
|
223
|
+
* together, as `vx upgrade` moves them (M, 2026-10-16: an installed 0.0.637
|
|
224
|
+
* pinned core back from 0.0.639). It finds the root itself, asks a
|
|
222
225
|
* terminal native or keep in a Turbo or Nx repo, and installs what it writes.
|
|
223
226
|
*/
|
|
224
227
|
async function migrate(dirs: readonly string[], args: InitArgs): Promise<number> {
|
|
@@ -229,21 +232,30 @@ async function migrate(dirs: readonly string[], args: InitArgs): Promise<number>
|
|
|
229
232
|
args.mode !== undefined && `--${args.mode}`,
|
|
230
233
|
args.json === true && '--format=json',
|
|
231
234
|
].filter((f): f is string => typeof f === 'string')
|
|
232
|
-
|
|
235
|
+
// An exact version: `bun x` may answer `@latest` from its cache.
|
|
236
|
+
const latest = VERSION === '0.0.0' ? 'latest' : await targetVersion(undefined, 'vx init')
|
|
237
|
+
return Bun.spawn(migrateCommand(dirs, flags, latest), {
|
|
233
238
|
stdio: ['inherit', 'inherit', 'inherit'],
|
|
234
239
|
// A compiled vx is a Bun runtime only when told so.
|
|
235
240
|
env: isCompiledBinary() ? { ...process.env, BUN_BE_BUN: '1' } : process.env,
|
|
236
241
|
}).exited
|
|
237
242
|
}
|
|
238
243
|
|
|
239
|
-
export function migrateCommand(
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
244
|
+
export function migrateCommand(
|
|
245
|
+
dirs: readonly string[],
|
|
246
|
+
flags: readonly string[],
|
|
247
|
+
latest: string,
|
|
248
|
+
running = VERSION,
|
|
249
|
+
): string[] {
|
|
250
|
+
// A source checkout (0.0.0, which npm never had) runs the vx-migrate
|
|
251
|
+
// beside it; a release never runs an installed one.
|
|
252
|
+
if (running === '0.0.0') {
|
|
253
|
+
for (const d of dirs) {
|
|
254
|
+
const installed = path.join(d, 'node_modules', '@vzn', 'vx-migrate', 'src', 'bin.ts')
|
|
255
|
+
if (existsSync(installed)) return [process.execPath, '--no-install', installed, ...flags]
|
|
256
|
+
}
|
|
243
257
|
}
|
|
244
|
-
// A source checkout is 0.0.0, which npm never had.
|
|
245
|
-
const version = VERSION === '0.0.0' ? 'latest' : VERSION
|
|
246
258
|
// Bun's own install output ("Slow filesystem detected…") in a pnpm or npm
|
|
247
259
|
// repo reads as a stranger speaking (Growth's first-run funnel F2).
|
|
248
|
-
return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${
|
|
260
|
+
return [process.execPath, 'x', '--silent', `@vzn/vx-migrate@${latest}`, ...flags]
|
|
249
261
|
}
|
package/src/cli/last.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
exitSignal,
|
|
23
23
|
resolveRunId,
|
|
24
24
|
runFailures,
|
|
25
|
+
type RunStep,
|
|
26
|
+
runSteps,
|
|
25
27
|
shortRunId,
|
|
26
28
|
taskLog,
|
|
27
29
|
} from '../orchestrator/index.js'
|
|
@@ -328,6 +330,22 @@ function reasonParts(t: RunSummaryRow): string {
|
|
|
328
330
|
/** Hit rows shown before the rest fold — the slowest restores. */
|
|
329
331
|
const HITS_SHOWN = 16
|
|
330
332
|
|
|
333
|
+
/**
|
|
334
|
+
* The steps the run did not wait on (`vx:prefetch`), which its own output
|
|
335
|
+
* never showed (M, 2026-10-16): here for debugging, marked `internal`.
|
|
336
|
+
*/
|
|
337
|
+
function formatStepRows(steps: readonly RunStep[]): string[] {
|
|
338
|
+
if (steps.length === 0) return []
|
|
339
|
+
const idW = Math.max(...steps.map((s) => s.id.length), 4)
|
|
340
|
+
return [
|
|
341
|
+
'',
|
|
342
|
+
...steps.map(
|
|
343
|
+
(s) =>
|
|
344
|
+
` ${(s.ok ? 'success' : 'failed').padEnd(17)} ${s.id.padEnd(idW)} ${fmtMs(s.durationMs).padStart(8)} internal`,
|
|
345
|
+
),
|
|
346
|
+
]
|
|
347
|
+
}
|
|
348
|
+
|
|
331
349
|
/**
|
|
332
350
|
* The per-task rows: failures first, then what executed or was skipped,
|
|
333
351
|
* then cache hits. Hits are the noise of a warm run — 996 of a thousand
|
|
@@ -336,6 +354,7 @@ const HITS_SHOWN = 16
|
|
|
336
354
|
* count; the ones shown are the slowest restores, the one thing a hit's
|
|
337
355
|
* row tells. `--format json` lists every row.
|
|
338
356
|
*/
|
|
357
|
+
|
|
339
358
|
export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
340
359
|
if (tasks.length === 0) return []
|
|
341
360
|
const failed = tasks.filter((t) => t.status === 'failed')
|
|
@@ -545,7 +564,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
545
564
|
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
546
565
|
}
|
|
547
566
|
})
|
|
548
|
-
|
|
567
|
+
const steps = runSteps(cacheDir, inv.runId)
|
|
568
|
+
process.stdout.write(
|
|
569
|
+
`${JSON.stringify({ invocation: inv, tasks, ...(steps.length > 0 ? { steps } : {}) })}\n`,
|
|
570
|
+
)
|
|
549
571
|
return 0
|
|
550
572
|
}
|
|
551
573
|
|
|
@@ -563,6 +585,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
563
585
|
`${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''}`,
|
|
564
586
|
)
|
|
565
587
|
lines.push(...formatTaskRows(detail?.tasks ?? []))
|
|
588
|
+
lines.push(...formatStepRows(runSteps(cacheDir, inv.runId)))
|
|
566
589
|
// The next command after reading a failed run, with the arguments the
|
|
567
590
|
// run forwarded to the tasks that got them (a failure under `-- --shard
|
|
568
591
|
// 2` is that shard's); a dependency never got them, so its line has none.
|
|
@@ -11,7 +11,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
11
11
|
cache: {
|
|
12
12
|
plugin:
|
|
13
13
|
"// `cache`: where artifacts live. A directory stands in for a remote store;\n// core puts it behind the local cache, so a remote error is a miss and a\n// warning, never a failed run.\nimport { link, mkdir, unlink } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then linked: on a shared directory a rename can swap\n // the file under a reader, while a link never replaces an existing one.\n // EEXIST is success: the same hash means the same bytes.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n try {\n await link(tmp, path.join(this.endpoint, hash))\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err\n } finally {\n await unlink(tmp)\n }\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache() {\n return new DirRemote(dir)\n },\n })\n}\n",
|
|
14
|
-
test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n
|
|
14
|
+
test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx', 'cache'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
|
|
15
15
|
},
|
|
16
16
|
commands: {
|
|
17
17
|
plugin:
|
|
@@ -31,7 +31,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
31
31
|
key: {
|
|
32
32
|
plugin:
|
|
33
33
|
"// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
|
|
34
|
-
test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n
|
|
34
|
+
test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n inputs: { files: ['package.json'] }, cache: { outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
|
|
35
35
|
},
|
|
36
36
|
project: {
|
|
37
37
|
plugin:
|
package/src/cli/select.ts
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
import { resolveSetting } from '../settings.js'
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* The projects whose tasks declare
|
|
54
|
+
* The projects whose tasks declare an `inputs.workspaceFiles` glob
|
|
55
55
|
* matching a changed path, one inside another project included (item
|
|
56
56
|
* 954): `--affected` selects them, since the glob is that task's input. Through the run
|
|
57
57
|
* path's staged load, so a glob a `project` plugin gave a config-less
|
|
@@ -71,10 +71,8 @@ export async function workspaceGlobOwners(
|
|
|
71
71
|
): Promise<string[]> {
|
|
72
72
|
const declaresMatch = (config: ProjectConfig): boolean => {
|
|
73
73
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
if (changed.some((rel) => declaresInput(cache, null, rel))) return true
|
|
77
|
-
if (nested.some((dir) => workspaceFilesReachInto(cache, dir))) return true
|
|
74
|
+
if (changed.some((rel) => declaresInput(task, null, rel))) return true
|
|
75
|
+
if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
|
|
78
76
|
}
|
|
79
77
|
return false
|
|
80
78
|
}
|
|
@@ -335,7 +333,19 @@ export async function resolveFilters(
|
|
|
335
333
|
workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
|
|
336
334
|
workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
|
|
337
335
|
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
338
|
-
...(git !== undefined
|
|
336
|
+
...(git !== undefined
|
|
337
|
+
? {
|
|
338
|
+
untracked: async () => (await git.start()).untracked,
|
|
339
|
+
// A scoped enumeration lists only its projects: null asks git.
|
|
340
|
+
links: async () => {
|
|
341
|
+
const g = await git.start()
|
|
342
|
+
if (!g.scopes.includes('.')) return null
|
|
343
|
+
const out: string[] = []
|
|
344
|
+
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
345
|
+
return out
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
: {}),
|
|
339
349
|
}
|
|
340
350
|
let names: Set<string>
|
|
341
351
|
if (f.raw === affected) {
|
package/src/cli/show.ts
CHANGED
|
@@ -466,14 +466,17 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
|
|
|
466
466
|
)
|
|
467
467
|
}
|
|
468
468
|
add('interactive', exec?.interactive === undefined ? undefined : exec.interactive ? 'yes' : 'no')
|
|
469
|
+
const inputs = task.inputs
|
|
470
|
+
if (inputs !== undefined) {
|
|
471
|
+
add('inputs.files', list(inputs.files))
|
|
472
|
+
add('inputs.workspaceFiles', list(inputs.workspaceFiles))
|
|
473
|
+
add('inputs.env', list(inputs.env))
|
|
474
|
+
add('inputs.tasks', list(inputs.tasks))
|
|
475
|
+
add('inputs.runtime', list(inputs.runtime))
|
|
476
|
+
add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
|
|
477
|
+
}
|
|
469
478
|
const cache = task.cache
|
|
470
479
|
if (cache !== undefined) {
|
|
471
|
-
add('inputs.files', list(cache.inputs.files))
|
|
472
|
-
add('inputs.workspaceFiles', list(cache.inputs.workspaceFiles))
|
|
473
|
-
add('inputs.env', list(cache.inputs.env))
|
|
474
|
-
add('inputs.tasks', list(cache.inputs.tasks))
|
|
475
|
-
add('inputs.runtime', list(cache.inputs.runtime))
|
|
476
|
-
add('inputs.workspaceRuntime', list(cache.inputs.workspaceRuntime))
|
|
477
480
|
add('outputs.files', list(cache.outputs.files))
|
|
478
481
|
add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
|
|
479
482
|
}
|