@vzn/vx 0.0.645 → 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/README.md +10 -28
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/cache.ts +66 -16
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +4 -0
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +25 -2
- package/src/cache/layered-cache.ts +32 -2
- package/src/cache/output-index.ts +25 -11
- package/src/cache/remote-lane.ts +13 -4
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +4 -6
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +43 -10
- package/src/cli/last.ts +4 -2
- package/src/cli/plugin-commands.ts +96 -3
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +2 -7
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +4 -5
- 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 +15 -0
- 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 +17 -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/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plugin-host.ts +21 -3
- package/src/orchestrator/plugin.ts +37 -0
- package/src/orchestrator/prepare.ts +11 -3
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +93 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +11 -1
- 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 +61 -7
- package/src/settings.ts +7 -0
- 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/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -1
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -18,11 +18,11 @@ const FILE_PREFIX_MTIME = -2
|
|
|
18
18
|
import type { Database } from 'bun:sqlite'
|
|
19
19
|
import { inHashes, lazyStatement } from './schema.js'
|
|
20
20
|
import { modeMatches } from './umask.js'
|
|
21
|
-
import { lstatSync, readlinkSync, statSync } from 'node:fs'
|
|
22
|
-
import { lstat, readdir } from 'node:fs/promises'
|
|
21
|
+
import { lstatSync, readdirSync, readlinkSync, statSync } from 'node:fs'
|
|
23
22
|
import path from 'node:path'
|
|
24
23
|
import {
|
|
25
24
|
OUTPUT_DIRS_CAP,
|
|
25
|
+
OUTPUT_DIRS_OVER_CAP,
|
|
26
26
|
OUTPUT_DIRS_RACY_MS,
|
|
27
27
|
type OutputDirRow,
|
|
28
28
|
isIndexFull,
|
|
@@ -262,8 +262,8 @@ export class OutputIndex {
|
|
|
262
262
|
* process may have written into them: `holds` is asked with the files
|
|
263
263
|
* the walk saw, and a tree it refuses records nothing (item 1087).
|
|
264
264
|
* Symlinked directories are not descended (the output walk refuses them
|
|
265
|
-
* too). Over `OUTPUT_DIRS_CAP` directories
|
|
266
|
-
* are cleared
|
|
265
|
+
* too). Over `OUTPUT_DIRS_CAP` directories the one row left says so; on
|
|
266
|
+
* any error the rows are cleared. Either way the next hit keeps the walk.
|
|
267
267
|
*/
|
|
268
268
|
async recordOutputDirs(
|
|
269
269
|
hash: string,
|
|
@@ -273,11 +273,16 @@ export class OutputIndex {
|
|
|
273
273
|
): Promise<void> {
|
|
274
274
|
const rows: Array<[string, number]> = []
|
|
275
275
|
const files: string[] = []
|
|
276
|
-
|
|
276
|
+
let overCap = false
|
|
277
|
+
// Synchronous, like the stats of `isOutputsCurrent`: an async lstat and
|
|
278
|
+
// readdir per directory are two thread-pool round trips, and a
|
|
279
|
+
// root-anchored glob's snapshot of solid's 47 directories took 4 ms of
|
|
280
|
+
// the run's end that way (2026-10-22).
|
|
281
|
+
const walk = (rel: string, isPrefix = false): boolean => {
|
|
277
282
|
const abs = path.join(projectDir, rel)
|
|
278
283
|
let st
|
|
279
284
|
try {
|
|
280
|
-
st =
|
|
285
|
+
st = lstatSync(abs)
|
|
281
286
|
} catch {
|
|
282
287
|
// A declared prefix the task never produced (`build/**` beside
|
|
283
288
|
// `dist/**`, medusa's `.medusa/**`) is recorded ABSENT: the check
|
|
@@ -299,29 +304,38 @@ export class OutputIndex {
|
|
|
299
304
|
return true
|
|
300
305
|
}
|
|
301
306
|
rows.push([rel, st.mtimeMs])
|
|
302
|
-
if (rows.length > OUTPUT_DIRS_CAP)
|
|
307
|
+
if (rows.length > OUTPUT_DIRS_CAP) {
|
|
308
|
+
overCap = true
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
303
311
|
let entries
|
|
304
312
|
try {
|
|
305
|
-
entries =
|
|
313
|
+
entries = readdirSync(abs, { withFileTypes: true })
|
|
306
314
|
} catch {
|
|
307
315
|
return false
|
|
308
316
|
}
|
|
309
317
|
for (const e of entries) {
|
|
318
|
+
// The project itself is the prefix `''` of a root-anchored glob.
|
|
319
|
+
const child = rel === '' ? e.name : `${rel}/${e.name}`
|
|
310
320
|
if (e.isDirectory() && !e.isSymbolicLink()) {
|
|
311
|
-
if (!
|
|
321
|
+
if (!walk(child)) return false
|
|
312
322
|
} else {
|
|
313
|
-
files.push(
|
|
323
|
+
files.push(child)
|
|
314
324
|
}
|
|
315
325
|
}
|
|
316
326
|
return true
|
|
317
327
|
}
|
|
318
328
|
let ok = true
|
|
319
329
|
for (const prefix of prefixes) {
|
|
320
|
-
if (!
|
|
330
|
+
if (!walk(prefix, true)) {
|
|
321
331
|
ok = false
|
|
322
332
|
break
|
|
323
333
|
}
|
|
324
334
|
}
|
|
335
|
+
if (overCap) {
|
|
336
|
+
this.pendingDirs.set(hash, [[prefixes[0]!, OUTPUT_DIRS_OVER_CAP]])
|
|
337
|
+
return
|
|
338
|
+
}
|
|
325
339
|
// All or nothing: a racy directory dropped alone would leave its
|
|
326
340
|
// parent trusted while an addition inside it bumps only the dropped one.
|
|
327
341
|
const now = Date.now()
|
package/src/cache/remote-lane.ts
CHANGED
|
@@ -228,9 +228,15 @@ export class RemoteLane {
|
|
|
228
228
|
return out
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
/**
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
/**
|
|
232
|
+
* The artifact's body, or null on a miss, a known absence (unless
|
|
233
|
+
* `fresh`: saved since the probe), a failure or a malformed answer.
|
|
234
|
+
*/
|
|
235
|
+
async get(
|
|
236
|
+
hash: string,
|
|
237
|
+
fresh = false,
|
|
238
|
+
): Promise<{ body: Blob | Response; durationMs?: number } | null> {
|
|
239
|
+
if (!fresh && this.absent.has(hash)) return null
|
|
234
240
|
let result: unknown
|
|
235
241
|
try {
|
|
236
242
|
result = await this.remote.get(hash)
|
|
@@ -253,7 +259,10 @@ export class RemoteLane {
|
|
|
253
259
|
)
|
|
254
260
|
return null
|
|
255
261
|
}
|
|
256
|
-
|
|
262
|
+
// It holds the key: a save of it after this restore uploads nothing.
|
|
263
|
+
const got = result as { body: Blob | Response; durationMs?: number }
|
|
264
|
+
this.held.set(hash, got.durationMs === undefined ? {} : { durationMs: got.durationMs })
|
|
265
|
+
return got
|
|
257
266
|
}
|
|
258
267
|
|
|
259
268
|
/**
|
package/src/cache/run-history.ts
CHANGED
|
@@ -13,11 +13,12 @@ const INSERT_RUNS = `
|
|
|
13
13
|
started_at, ended_at,
|
|
14
14
|
run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
|
|
15
15
|
cache_hit, attempts, cached,
|
|
16
|
-
blocked_by, timed_out, sandbox_violations, not_ready, restored, remote_error
|
|
16
|
+
blocked_by, timed_out, sandbox_violations, not_ready, restored, remote_error,
|
|
17
|
+
peak_cores, avg_rss_bytes, placement, placement_cores, placement_memory, keyless_by
|
|
17
18
|
)
|
|
18
19
|
VALUES `
|
|
19
|
-
/** One `runs` row's placeholders: the
|
|
20
|
-
const RUN_TUPLE = `(${Array(
|
|
20
|
+
/** One `runs` row's placeholders: the 29 columns `bindRun` fills. */
|
|
21
|
+
const RUN_TUPLE = `(${Array(29).fill('?').join(', ')})`
|
|
21
22
|
|
|
22
23
|
/** An INSERT of `n` `runs` rows. */
|
|
23
24
|
function insertRunsSql(n: number): string {
|
|
@@ -25,11 +26,11 @@ function insertRunsSql(n: number): string {
|
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* Rows per INSERT:
|
|
29
|
+
* Rows per INSERT: 34 × 29 variables stays under 999, the oldest default
|
|
29
30
|
* ceiling. A statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
|
|
30
31
|
* (2026-10-03).
|
|
31
32
|
*/
|
|
32
|
-
const RUNS_PER_INSERT =
|
|
33
|
+
const RUNS_PER_INSERT = 34
|
|
33
34
|
|
|
34
35
|
/** Rows per `task_durations` INSERT: 6 variables each, under 999. */
|
|
35
36
|
const DURATIONS_PER_INSERT = 160
|
|
@@ -236,7 +237,7 @@ export class RunHistory {
|
|
|
236
237
|
}
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
/** Bind a RunRecord to one `RUN_TUPLE`'s
|
|
240
|
+
/** Bind a RunRecord to one `RUN_TUPLE`'s 29 positional parameters. */
|
|
240
241
|
function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
|
|
241
242
|
return [
|
|
242
243
|
// The ONE place the no-key sentinel is applied, so the column's
|
|
@@ -267,6 +268,12 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
|
|
|
267
268
|
run.notReady ?? null,
|
|
268
269
|
run.restored === undefined ? null : run.restored ? 1 : 0,
|
|
269
270
|
run.remoteError ?? null,
|
|
271
|
+
run.peakCores ?? null,
|
|
272
|
+
run.avgRssBytes ?? null,
|
|
273
|
+
run.placement ?? null,
|
|
274
|
+
run.placementCores ?? null,
|
|
275
|
+
run.placementMemoryBytes ?? null,
|
|
276
|
+
run.keylessBy ?? null,
|
|
270
277
|
]
|
|
271
278
|
}
|
|
272
279
|
|
package/src/cache/schema.ts
CHANGED
|
@@ -101,7 +101,18 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
101
101
|
restored INTEGER,
|
|
102
102
|
-- v40: each remote read or write of the key that failed, as
|
|
103
103
|
-- 'upload: <message>', '; '-joined; NULL when none did.
|
|
104
|
-
remote_error TEXT
|
|
104
|
+
remote_error TEXT,
|
|
105
|
+
-- v43: the most cores one sampling interval burned and the mean
|
|
106
|
+
-- resident memory over the looks; NULL when nothing sampled.
|
|
107
|
+
peak_cores REAL,
|
|
108
|
+
avg_rss_bytes INTEGER,
|
|
109
|
+
-- v44: where an executor ran it (ExecuteResult.where) and that
|
|
110
|
+
-- machine's cores and memory; NULL = here.
|
|
111
|
+
placement TEXT,
|
|
112
|
+
placement_cores INTEGER,
|
|
113
|
+
placement_memory INTEGER,
|
|
114
|
+
-- v46: the task with no inputs its key folded (TaskOutcome.keylessBy).
|
|
115
|
+
keyless_by TEXT
|
|
105
116
|
);
|
|
106
117
|
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
107
118
|
-- same run_id and a started_at newer than everything before it, so
|
|
@@ -280,7 +291,13 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
280
291
|
source TEXT NOT NULL,
|
|
281
292
|
sha TEXT,
|
|
282
293
|
duration_ms INTEGER NOT NULL,
|
|
283
|
-
recorded_at INTEGER NOT NULL
|
|
294
|
+
recorded_at INTEGER NOT NULL,
|
|
295
|
+
-- v45: a 'remote' row's measured need (TaskResources); NULL on the
|
|
296
|
+
-- rest, whose need the run history holds.
|
|
297
|
+
cpu_parallelism REAL,
|
|
298
|
+
peak_rss_bytes INTEGER,
|
|
299
|
+
saturated_cores INTEGER,
|
|
300
|
+
saturated_memory_bytes INTEGER
|
|
284
301
|
);
|
|
285
302
|
CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
|
|
286
303
|
ON task_durations(project, task, source, id);
|
package/src/cli/core-alias.ts
CHANGED
|
@@ -14,12 +14,15 @@
|
|
|
14
14
|
* so a verb that never loads a plugin never loads the façade. The
|
|
15
15
|
* source form (`bun src/bin.ts`) resolves the specifier to the same file
|
|
16
16
|
* anyway; the alias makes that a guarantee rather than a layout fact.
|
|
17
|
+
* The loader is synchronous: the workspace config's bytecode bundle
|
|
18
|
+
* (workspace/config-bytecode.ts) is CommonJS, and `require` refuses a
|
|
19
|
+
* module whose loader is async.
|
|
17
20
|
*/
|
|
18
|
-
export function registerCoreAlias(load: () =>
|
|
21
|
+
export function registerCoreAlias(load: () => Record<string, unknown>): void {
|
|
19
22
|
Bun.plugin({
|
|
20
23
|
name: 'vx-core-alias',
|
|
21
24
|
setup(build) {
|
|
22
|
-
build.module('@vzn/vx',
|
|
25
|
+
build.module('@vzn/vx', () => ({ exports: load(), loader: 'object' }))
|
|
23
26
|
},
|
|
24
27
|
})
|
|
25
28
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -199,12 +199,6 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
199
199
|
' depends on none of them; a plain `vx run` never requires a plugin.',
|
|
200
200
|
' See the plugin guide: https://vznjs.github.io/vx/guides/plugins/',
|
|
201
201
|
'',
|
|
202
|
-
'Turbo and Nx spellings (for run):',
|
|
203
|
-
' What turbo run / nx run-many / nx affected take works here, or is refused',
|
|
204
|
-
' naming the vx spelling: -t build,test, -p app, --exclude docs, --parallel 4,',
|
|
205
|
-
' and --base main, --dry-run, --skip-nx-cache. `vx build` names `vx run build`.',
|
|
206
|
-
' Every flag: https://vznjs.github.io/vx/cli/#turbo-and-nx-flags',
|
|
207
|
-
'',
|
|
208
202
|
'Argument forwarding (for run):',
|
|
209
203
|
" Anything after `--` is forwarded (shell-quoted) to the task's exec",
|
|
210
204
|
' command. The forwarded args are folded into the cache key.',
|
|
@@ -356,6 +350,10 @@ export function flagHint(verb: string, arg: string): string {
|
|
|
356
350
|
// `--json` is gh's and Nx's spelling; a verb with `--format` reads it
|
|
357
351
|
// there, and no edit budget reaches `--format json` from `--json`.
|
|
358
352
|
if (name === '--json' && flags.includes('--format')) return ' (did you mean --format json?)'
|
|
353
|
+
// `vx run` prints no JSON of its own, and edit distance reached `--force`
|
|
354
|
+
// from `--format`: a run with its cache reads turned off.
|
|
355
|
+
if ((name === '--format' || name === '--json') && flags.includes('--dry'))
|
|
356
|
+
return ' (the run as JSON is `vx last --format json` after it; the plan is --dry=json)'
|
|
359
357
|
// A flag cut short at a word (`--dry` for prune's `--dry-run`, which
|
|
360
358
|
// `vx run` spells `--dry`) is too many edits away for either budget.
|
|
361
359
|
const cut = flags.filter((f) => f.startsWith(`${name}-`))
|
package/src/cli/index.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
import { VERSION } from '../version.js'
|
|
5
5
|
import { checkSettingsEnv } from '../settings.js'
|
|
6
6
|
import { CORE_VERBS, flagHint, printHelp, refusedWord, seeHelp } from './help.js'
|
|
7
|
-
import { FOREIGN_VERBS } from './foreign-flags.js'
|
|
8
7
|
import {
|
|
9
8
|
errorDocument,
|
|
10
9
|
hideEnvFromTasks,
|
|
@@ -99,11 +98,6 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
99
98
|
// for the whole reference: it printed 145 lines and no hint.
|
|
100
99
|
const resolved = await (await plugins()).resolvePluginCommand(verb)
|
|
101
100
|
const unknown = resolved === null || 'declaredVerbs' in resolved
|
|
102
|
-
const pointer = FOREIGN_VERBS[verb]
|
|
103
|
-
if (unknown && pointer !== undefined) {
|
|
104
|
-
sayNotice('info', 'vx', pointer)
|
|
105
|
-
return 1
|
|
106
|
-
}
|
|
107
101
|
if (unknown && verb.startsWith('-')) {
|
|
108
102
|
sayNotice(
|
|
109
103
|
'error',
|
|
@@ -217,12 +211,6 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
217
211
|
resolved !== null && 'loadError' in resolved
|
|
218
212
|
? `\n (plugin verbs could not be looked up: .vx/workspace failed to load: ${resolved.loadError})`
|
|
219
213
|
: ''
|
|
220
|
-
// Another tool's verb: the pointer, but only after the plugins had
|
|
221
|
-
// their chance — a workspace that declares a plugin verb of that name keeps it.
|
|
222
|
-
const moved = FOREIGN_VERBS[command]
|
|
223
|
-
if (moved !== undefined) {
|
|
224
|
-
return unknown(`${moved}${loadNote}\n`, undefined, 'info')
|
|
225
|
-
}
|
|
226
214
|
// A task typed where the verb goes (`turbo build`, `nx build app`),
|
|
227
215
|
// `turbo dev` included: a repo's own `dev` task beats the no-service note.
|
|
228
216
|
const declaredVerbs =
|
package/src/cli/init.ts
CHANGED
|
@@ -26,7 +26,6 @@ import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from
|
|
|
26
26
|
import {
|
|
27
27
|
findWorkspaceRoot,
|
|
28
28
|
PROJECT_CONFIG_FILENAMES,
|
|
29
|
-
vxInvocation,
|
|
30
29
|
WORKSPACE_CONFIG_FILENAMES,
|
|
31
30
|
} from '../workspace/index.js'
|
|
32
31
|
|
|
@@ -137,10 +136,11 @@ export function initInstalls(
|
|
|
137
136
|
pkgRoot: string | null,
|
|
138
137
|
globalAt: string | undefined,
|
|
139
138
|
execPath = process.execPath,
|
|
139
|
+
plugins: readonly string[] = [],
|
|
140
140
|
): { argv: string[]; cwd: string | null }[] {
|
|
141
141
|
const out: { argv: string[]; cwd: string | null }[] = []
|
|
142
142
|
if (globalAt !== version) {
|
|
143
|
-
const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? '
|
|
143
|
+
const runner = /^(npm|pnpm|yarn|bun)\//.exec(userAgent ?? '')?.[1] ?? 'bun'
|
|
144
144
|
const manager = runner as ManagedInstall['manager']
|
|
145
145
|
out.push({ argv: upgradeArgv({ manager, scope: 'global', cwd: '' }, version), cwd: null })
|
|
146
146
|
}
|
|
@@ -150,6 +150,7 @@ export function initInstalls(
|
|
|
150
150
|
scope: 'project',
|
|
151
151
|
cwd: pkgRoot,
|
|
152
152
|
field: 'devDependencies',
|
|
153
|
+
plugins: [...plugins],
|
|
153
154
|
}
|
|
154
155
|
out.push({ argv: upgradeArgv(install, version), cwd: pkgRoot })
|
|
155
156
|
}
|
|
@@ -172,7 +173,11 @@ function globalVersion(): string | undefined {
|
|
|
172
173
|
* Run `initInstalls`, in order. A source checkout (0.0.0, which npm never
|
|
173
174
|
* had) installs nothing: the release would replace the code under test.
|
|
174
175
|
*/
|
|
175
|
-
async function install(
|
|
176
|
+
async function install(
|
|
177
|
+
pkgRoot: string | null,
|
|
178
|
+
dry: boolean,
|
|
179
|
+
plugins: readonly string[] = [],
|
|
180
|
+
): Promise<string[]> {
|
|
176
181
|
if (VERSION === '0.0.0') return []
|
|
177
182
|
const version = await targetVersion(undefined, 'vx init')
|
|
178
183
|
const steps = initInstalls(
|
|
@@ -180,6 +185,8 @@ async function install(pkgRoot: string | null, dry: boolean): Promise<string[]>
|
|
|
180
185
|
process.env['npm_config_user_agent'],
|
|
181
186
|
pkgRoot,
|
|
182
187
|
globalVersion(),
|
|
188
|
+
process.execPath,
|
|
189
|
+
plugins,
|
|
183
190
|
)
|
|
184
191
|
if (dry) return steps.map((s) => s.argv.join(' '))
|
|
185
192
|
for (const s of steps) {
|
|
@@ -235,8 +242,26 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
|
|
|
235
242
|
return 0
|
|
236
243
|
}
|
|
237
244
|
|
|
238
|
-
|
|
239
|
-
|
|
245
|
+
/**
|
|
246
|
+
* The `@vzn/vx-lockfile` plugin the lockfile at `pkgRoot` calls for: without
|
|
247
|
+
* it a JS workspace has no project edges, and the first `^build` a user
|
|
248
|
+
* writes is refused at `vx lock`.
|
|
249
|
+
*/
|
|
250
|
+
function lockfilePlugin(pkgRoot: string | null): 'pnpm' | 'bun' | 'yarn' | 'npm' | null {
|
|
251
|
+
if (pkgRoot === null) return null
|
|
252
|
+
const has = (f: string): boolean => existsSync(path.join(pkgRoot, f))
|
|
253
|
+
if (has('pnpm-lock.yaml')) return 'pnpm'
|
|
254
|
+
if (has('bun.lock') || has('bun.lockb')) return 'bun'
|
|
255
|
+
if (has('yarn.lock')) return 'yarn'
|
|
256
|
+
if (has('package-lock.json') || has('npm-shrinkwrap.json')) return 'npm'
|
|
257
|
+
return null
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function workspaceSource(plugin: string | null): string {
|
|
261
|
+
if (plugin === null)
|
|
262
|
+
return "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
|
|
263
|
+
return `import { defineWorkspace } from '@vzn/vx'\nimport { ${plugin} } from '@vzn/vx-lockfile'\n\nexport default defineWorkspace({ plugins: [${plugin}()] })\n`
|
|
264
|
+
}
|
|
240
265
|
const PROJECT =
|
|
241
266
|
"import { defineProject } from '@vzn/vx'\n\nexport default defineProject({ tasks: {} })\n"
|
|
242
267
|
|
|
@@ -257,14 +282,23 @@ async function starter(
|
|
|
257
282
|
`vx init: --${args.mode} answers a Turbo or Nx repo's question, and there is no turbo.json or nx.json here`,
|
|
258
283
|
)
|
|
259
284
|
}
|
|
260
|
-
|
|
285
|
+
// A source checkout installs nothing, so it declares the plugin only where one is.
|
|
286
|
+
const found = lockfilePlugin(pkgRoot)
|
|
287
|
+
const plugin =
|
|
288
|
+
found !== null &&
|
|
289
|
+
(VERSION !== '0.0.0' ||
|
|
290
|
+
existsSync(path.join(pkgRoot!, 'node_modules', '@vzn', 'vx-lockfile', 'package.json')))
|
|
291
|
+
? found
|
|
292
|
+
: null
|
|
293
|
+
const plugins = plugin === null ? [] : ['@vzn/vx-lockfile']
|
|
294
|
+
const installs = args.json === true || args.dry ? await install(pkgRoot, true, plugins) : []
|
|
261
295
|
const ext = args.mjs ? 'mjs' : 'ts'
|
|
262
296
|
const config = `vx.config.${ext}`
|
|
263
297
|
const files: { path: string; contents: string }[] = []
|
|
264
298
|
const kept: string[] = []
|
|
265
299
|
const replaced: string[] = []
|
|
266
300
|
for (const [names, name, contents] of [
|
|
267
|
-
[WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`,
|
|
301
|
+
[WORKSPACE_CONFIG_FILENAMES, `.vx/workspace.${ext}`, workspaceSource(plugin)],
|
|
268
302
|
[PROJECT_CONFIG_FILENAMES, config, PROJECT],
|
|
269
303
|
] as const) {
|
|
270
304
|
const existing = names.find((n) => existsSync(path.join(root, n)))
|
|
@@ -281,8 +315,7 @@ async function starter(
|
|
|
281
315
|
] as const) {
|
|
282
316
|
if (!existsSync(path.join(root, name))) files.push({ path: name, contents })
|
|
283
317
|
}
|
|
284
|
-
const
|
|
285
|
-
const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then ${vx} lock && ${vx} run <task>`
|
|
318
|
+
const next = `${noGit ? 'git init, ' : ''}declare a task in ${kept.find((f) => f.startsWith('vx.config.')) ?? config}, then vx lock && vx run <task>`
|
|
286
319
|
if (args.json === true) {
|
|
287
320
|
process.stdout.write(
|
|
288
321
|
`${JSON.stringify({ dry: true, source: 'no turbo.json or nx.json', files, kept, replaced, todos: [], notes: [], next, installs })}\n`,
|
|
@@ -300,7 +333,7 @@ async function starter(
|
|
|
300
333
|
ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
|
|
301
334
|
return 0
|
|
302
335
|
}
|
|
303
|
-
const ran = await install(pkgRoot, false)
|
|
336
|
+
const ran = await install(pkgRoot, false, plugins)
|
|
304
337
|
if (ran.length > 0) rows.push(['installed', ...ran])
|
|
305
338
|
for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
|
|
306
339
|
for (const f of replaced) await unlink(path.join(root, f))
|
package/src/cli/last.ts
CHANGED
|
@@ -365,10 +365,12 @@ export function formatTaskRows(tasks: readonly RunSummaryRow[]): string[] {
|
|
|
365
365
|
? [...hits].sort((a, b) => b.durationMs - a.durationMs).slice(0, HITS_SHOWN)
|
|
366
366
|
: hits
|
|
367
367
|
const ordered = [...failed, ...rest, ...shownHits]
|
|
368
|
-
const
|
|
368
|
+
const at = (t: (typeof ordered)[number]): string =>
|
|
369
|
+
t.placement !== null ? `@${t.placement}` : ''
|
|
370
|
+
const idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}${at(t)}`.length), 4)
|
|
369
371
|
const lines = ['']
|
|
370
372
|
for (const t of ordered) {
|
|
371
|
-
const id = `${t.project}#${t.task}`
|
|
373
|
+
const id = `${t.project}#${t.task}${at(t)}`
|
|
372
374
|
// The terminal summary's own word for a task that runs every time
|
|
373
375
|
// by design; a reader must not take its row for a miss.
|
|
374
376
|
// A failure's row reads as the frame did — `failed (exit 137)` —
|
|
@@ -9,9 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
|
|
11
11
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
BranchFacts,
|
|
14
|
+
VxPlugin,
|
|
15
|
+
PluginCommand,
|
|
16
|
+
CommandContext,
|
|
17
|
+
CacheFacts,
|
|
18
|
+
TaskArtifacts,
|
|
19
|
+
} from '../orchestrator/index.js'
|
|
13
20
|
import { configuredBaseBranch, findWorkspaceRoot } from '../workspace/index.js'
|
|
14
|
-
import { machineParallelism, maskedLine } from '../util/index.js'
|
|
21
|
+
import { machineParallelism, maskedLine, SecretBroker } from '../util/index.js'
|
|
15
22
|
import { resolveSetting } from '../settings.js'
|
|
16
23
|
|
|
17
24
|
export interface ResolvedPluginCommand {
|
|
@@ -111,6 +118,8 @@ export async function resolvePluginCommand(
|
|
|
111
118
|
: [process.execPath, Bun.main],
|
|
112
119
|
warn,
|
|
113
120
|
cache: lazyCache(() => verbCache(ws, plugin.name)),
|
|
121
|
+
artifacts: lazyArtifacts(() => verbArtifacts(ws)),
|
|
122
|
+
artifactsFor: (facts) => lazyArtifacts(() => verbArtifacts(ws, facts)),
|
|
114
123
|
},
|
|
115
124
|
}
|
|
116
125
|
}
|
|
@@ -138,7 +147,7 @@ async function verbCache(
|
|
|
138
147
|
ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
|
|
139
148
|
plugin: string,
|
|
140
149
|
): Promise<PluginCache> {
|
|
141
|
-
const [{ Cache, pluginCache }, { cacheRefs, resolveCache, resolveCacheScope }] =
|
|
150
|
+
const [{ Cache, pluginCache }, { cacheRefs, openRemotes, resolveCache, resolveCacheScope }] =
|
|
142
151
|
await Promise.all([import('../cache/index.js'), import('../orchestrator/index.js')])
|
|
143
152
|
const scope = resolveCacheScope(
|
|
144
153
|
ws.workspaceRoot,
|
|
@@ -165,9 +174,93 @@ async function verbCache(
|
|
|
165
174
|
policy: VERB_POLICY,
|
|
166
175
|
remotes,
|
|
167
176
|
})
|
|
177
|
+
await openVerbRemotes(remotes, openRemotes)
|
|
168
178
|
return pluginCache(plugin, { db: localCache.dbHandle(), policy: VERB_POLICY, remotes, scope })
|
|
169
179
|
}
|
|
170
180
|
|
|
181
|
+
/**
|
|
182
|
+
* The verb's `ctx.artifacts`: the workspace's cache as a run opens it, its
|
|
183
|
+
* policy and scope, a laptop writing no remote unless the policy names it.
|
|
184
|
+
* With `facts` (`ctx.artifactsFor`), under another run's refs and lent access.
|
|
185
|
+
*/
|
|
186
|
+
async function verbArtifacts(
|
|
187
|
+
ws: Exclude<Awaited<ReturnType<typeof workspacePlugins>>, UnresolvedPluginCommand | null>,
|
|
188
|
+
facts?: () => CacheFacts | undefined,
|
|
189
|
+
): Promise<TaskArtifacts> {
|
|
190
|
+
const [
|
|
191
|
+
{ Cache, FULL_CACHE_POLICY },
|
|
192
|
+
{
|
|
193
|
+
cacheRefs,
|
|
194
|
+
namesRemote,
|
|
195
|
+
openRemotes,
|
|
196
|
+
resolveCache,
|
|
197
|
+
resolveCacheScope,
|
|
198
|
+
taskArtifacts,
|
|
199
|
+
workspaceCachePolicy,
|
|
200
|
+
},
|
|
201
|
+
{ onCI },
|
|
202
|
+
] = await Promise.all([
|
|
203
|
+
import('../cache/index.js'),
|
|
204
|
+
import('../orchestrator/index.js'),
|
|
205
|
+
import('./run.js'),
|
|
206
|
+
])
|
|
207
|
+
const asked = workspaceCachePolicy(ws.workspaceConfig) ?? FULL_CACHE_POLICY
|
|
208
|
+
const policy: CachePolicy =
|
|
209
|
+
onCI(process.env['CI']) || namesRemote(ws.workspaceConfig?.cache?.policy)
|
|
210
|
+
? asked
|
|
211
|
+
: { ...asked, remoteWrite: false }
|
|
212
|
+
const scope = resolveCacheScope(
|
|
213
|
+
ws.workspaceRoot,
|
|
214
|
+
ws.branch.trusted === true ? null : ws.branch.scope,
|
|
215
|
+
ws.plugins.some((p) => p.cache !== undefined),
|
|
216
|
+
configuredBaseBranch(ws.workspaceConfig),
|
|
217
|
+
)
|
|
218
|
+
const localCache = new Cache(
|
|
219
|
+
ws.cacheDir,
|
|
220
|
+
{ read: policy.localRead, write: policy.localWrite },
|
|
221
|
+
ws.workspaceRoot,
|
|
222
|
+
undefined,
|
|
223
|
+
'open',
|
|
224
|
+
await ws.storeRoot,
|
|
225
|
+
)
|
|
226
|
+
const remotes: ResolvedRemote[] = []
|
|
227
|
+
const cache = await resolveCache(ws.plugins, {
|
|
228
|
+
workspaceRoot: ws.workspaceRoot,
|
|
229
|
+
cacheDir: ws.cacheDir,
|
|
230
|
+
warn,
|
|
231
|
+
scope,
|
|
232
|
+
refs: facts?.()?.refs ?? cacheRefs(ws.workspaceRoot, configuredBaseBranch(ws.workspaceConfig)),
|
|
233
|
+
localCache,
|
|
234
|
+
policy,
|
|
235
|
+
remotes,
|
|
236
|
+
...(facts === undefined ? {} : { lentBy: () => facts()?.lent }),
|
|
237
|
+
})
|
|
238
|
+
await openVerbRemotes(remotes, openRemotes)
|
|
239
|
+
return taskArtifacts(cache, ws.workspaceRoot)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Each remote's `open`, as a run's: a remote that takes its token there
|
|
244
|
+
* (turboCache's OIDC exchange, a lent token) had none, so an agent's
|
|
245
|
+
* uploads went nowhere and its run could not restore them.
|
|
246
|
+
*/
|
|
247
|
+
async function openVerbRemotes(
|
|
248
|
+
remotes: readonly ResolvedRemote[],
|
|
249
|
+
open: (typeof import('../orchestrator/index.js'))['openRemotes'],
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const ci = (await import('./run.js')).onCI(process.env['CI'])
|
|
252
|
+
await open(remotes, ci, new SecretBroker(ci, warn))
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function lazyArtifacts(open: () => Promise<TaskArtifacts>): TaskArtifacts {
|
|
256
|
+
let opened: Promise<TaskArtifacts> | undefined
|
|
257
|
+
const artifacts = (): Promise<TaskArtifacts> => (opened ??= open())
|
|
258
|
+
return {
|
|
259
|
+
save: async (key, task, run) => (await artifacts()).save(key, task, run),
|
|
260
|
+
restore: async (key, task) => (await artifacts()).restore(key, task),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
171
264
|
function lazyCache(open: () => Promise<PluginCache>): PluginCache {
|
|
172
265
|
let opened: Promise<PluginCache> | undefined
|
|
173
266
|
const cache = (): Promise<PluginCache> => (opened ??= open())
|
|
@@ -16,7 +16,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
|
|
|
16
16
|
commands: {
|
|
17
17
|
plugin:
|
|
18
18
|
"// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n console.error(`vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n console.log(ctx.cacheDir)\n return 0\n },\n },\n },\n })\n}\n",
|
|
19
|
-
test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
|
|
19
|
+
test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n artifacts: { save: async () => {}, restore: async () => null },\n artifactsFor: () => ({ save: async () => {}, restore: async () => null }),\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
|
|
20
20
|
},
|
|
21
21
|
executor: {
|
|
22
22
|
plugin:
|
package/src/cli/run.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { isatty } from 'node:tty'
|
|
2
2
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
3
|
-
import { translateForeign } from './foreign-flags.js'
|
|
4
3
|
import { flagHint, refuse, seeHelp } from './help.js'
|
|
5
4
|
import {
|
|
6
5
|
planRun,
|
|
@@ -116,7 +115,7 @@ const SETTING_OPTIONS = [
|
|
|
116
115
|
|
|
117
116
|
/** `verb` is the one being parsed for: `vx watch` reads `vx run`'s flags, and
|
|
118
117
|
* its flag hints named `vx run` (M-58). The caller appends the help pointer. */
|
|
119
|
-
export function parseRunArgs(
|
|
118
|
+
export function parseRunArgs(args: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
|
|
120
119
|
const out: RunArgs = {
|
|
121
120
|
tasks: [],
|
|
122
121
|
filters: [],
|
|
@@ -132,10 +131,6 @@ export function parseRunArgs(rawArgs: readonly string[], verb: 'run' | 'watch' =
|
|
|
132
131
|
affected: undefined,
|
|
133
132
|
}
|
|
134
133
|
|
|
135
|
-
// A Turbo or Nx spelling becomes vx's, or a refusal naming vx's.
|
|
136
|
-
const translated = translateForeign(rawArgs)
|
|
137
|
-
if ('error' in translated) return { ...out, error: translated.error }
|
|
138
|
-
const args = translated
|
|
139
134
|
const sepIdx = args.indexOf('--')
|
|
140
135
|
const before = sepIdx === -1 ? args : args.slice(0, sepIdx)
|
|
141
136
|
out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
|
|
@@ -663,6 +658,6 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
663
658
|
}
|
|
664
659
|
|
|
665
660
|
/** CI=0 and CI=false are "not CI": several vendors set CI=true. */
|
|
666
|
-
function onCI(v: string | undefined): boolean {
|
|
661
|
+
export function onCI(v: string | undefined): boolean {
|
|
667
662
|
return v !== undefined && v !== '' && v !== '0' && v !== 'false'
|
|
668
663
|
}
|
package/src/cli/select.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// here goes through the staged load, so the answer is the run's.
|
|
5
5
|
|
|
6
6
|
import path from 'node:path'
|
|
7
|
+
import { settingEnv } from '../settings.js'
|
|
7
8
|
import fs from 'node:fs'
|
|
8
9
|
import {
|
|
9
10
|
affectedChanges,
|
|
@@ -244,12 +245,13 @@ function refusalOf(err: unknown): Refusal {
|
|
|
244
245
|
|
|
245
246
|
/**
|
|
246
247
|
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
247
|
-
* projects and their dependents. An empty base is
|
|
248
|
-
* a pull request's base commit), else the
|
|
248
|
+
* projects and their dependents. An empty base is `VX_AFFECTED_BASE`'s,
|
|
249
|
+
* else a plugin's (`setBranch`: a pull request's base commit), else the
|
|
250
|
+
* merge base with `baseBranch`.
|
|
249
251
|
*/
|
|
250
252
|
export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
|
|
251
253
|
const root = await findWorkspaceRoot(cwd)
|
|
252
|
-
let base = affected
|
|
254
|
+
let base = affected || ((settingEnv('affectedBase') as string | undefined) ?? '')
|
|
253
255
|
if (base === '') {
|
|
254
256
|
const ws = await loadCliWorkspace(root)
|
|
255
257
|
try {
|