@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.
Files changed (71) hide show
  1. package/README.md +10 -28
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/skills/vx/SKILL.md +1 -1
  5. package/src/bin.ts +1 -1
  6. package/src/cache/cache.ts +66 -16
  7. package/src/cache/git-inputs.ts +55 -10
  8. package/src/cache/index.ts +4 -0
  9. package/src/cache/inputs.ts +61 -11
  10. package/src/cache/layer.ts +25 -2
  11. package/src/cache/layered-cache.ts +32 -2
  12. package/src/cache/output-index.ts +25 -11
  13. package/src/cache/remote-lane.ts +13 -4
  14. package/src/cache/run-history.ts +13 -6
  15. package/src/cache/schema.ts +19 -2
  16. package/src/cli/core-alias.ts +5 -2
  17. package/src/cli/help.ts +4 -6
  18. package/src/cli/index.ts +0 -12
  19. package/src/cli/init.ts +43 -10
  20. package/src/cli/last.ts +4 -2
  21. package/src/cli/plugin-commands.ts +96 -3
  22. package/src/cli/plugin-templates.ts +1 -1
  23. package/src/cli/run.ts +2 -7
  24. package/src/cli/select.ts +5 -3
  25. package/src/cli/upgrade.ts +4 -5
  26. package/src/exec/executor.ts +112 -9
  27. package/src/exec/index.ts +2 -0
  28. package/src/exec/proc-sample.ts +67 -5
  29. package/src/exec/runner.ts +4 -0
  30. package/src/exec/sandbox-runtime.ts +11 -4
  31. package/src/graph/scheduler.ts +59 -8
  32. package/src/history/history.ts +119 -7
  33. package/src/history/metrics.ts +53 -16
  34. package/src/index.ts +15 -0
  35. package/src/orchestrator/execute-args.ts +3 -0
  36. package/src/orchestrator/execute-task.ts +28 -7
  37. package/src/orchestrator/held-servers.ts +86 -0
  38. package/src/orchestrator/hit-restore.ts +11 -4
  39. package/src/orchestrator/index.ts +17 -1
  40. package/src/orchestrator/local-floor.ts +104 -0
  41. package/src/orchestrator/local-shortcircuit.ts +26 -7
  42. package/src/orchestrator/miss-save.ts +10 -4
  43. package/src/orchestrator/persistent-task.ts +14 -0
  44. package/src/orchestrator/placement.ts +84 -9
  45. package/src/orchestrator/plugin-host.ts +21 -3
  46. package/src/orchestrator/plugin.ts +37 -0
  47. package/src/orchestrator/prepare.ts +11 -3
  48. package/src/orchestrator/run-keys.ts +164 -0
  49. package/src/orchestrator/run-records.ts +6 -0
  50. package/src/orchestrator/run.ts +93 -6
  51. package/src/orchestrator/sandbox-request.ts +9 -3
  52. package/src/orchestrator/task-artifacts.ts +142 -0
  53. package/src/orchestrator/task-hash.ts +11 -1
  54. package/src/orchestrator/task-process.ts +16 -0
  55. package/src/orchestrator/telemetry-host.ts +2 -1
  56. package/src/orchestrator/telemetry.ts +60 -17
  57. package/src/report/forecast.ts +6 -27
  58. package/src/report/framed-output.ts +41 -5
  59. package/src/report/logger.ts +60 -20
  60. package/src/report/status-line.ts +38 -18
  61. package/src/report/summary.ts +61 -7
  62. package/src/settings.ts +7 -0
  63. package/src/util/colors.ts +16 -1
  64. package/src/util/index.ts +1 -0
  65. package/src/util/paths.ts +30 -0
  66. package/src/util/ui.ts +4 -1
  67. package/src/workspace/config-bytecode.ts +276 -0
  68. package/src/workspace/index.ts +0 -1
  69. package/src/workspace/migration.ts +4 -32
  70. package/src/workspace/project-loader.ts +92 -14
  71. 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, or on any error, the rows
266
- * are cleared and the next hit keeps the walk.
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
- const walk = async (rel: string, isPrefix = false): Promise<boolean> => {
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 = await lstat(abs)
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) return false
307
+ if (rows.length > OUTPUT_DIRS_CAP) {
308
+ overCap = true
309
+ return false
310
+ }
303
311
  let entries
304
312
  try {
305
- entries = await readdir(abs, { withFileTypes: true })
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 (!(await walk(`${rel}/${e.name}`))) return false
321
+ if (!walk(child)) return false
312
322
  } else {
313
- files.push(`${rel}/${e.name}`)
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 (!(await walk(prefix, true))) {
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()
@@ -228,9 +228,15 @@ export class RemoteLane {
228
228
  return out
229
229
  }
230
230
 
231
- /** The artifact's body, or null on a miss, a known absence, a failure or a malformed answer. */
232
- async get(hash: string): Promise<{ body: Blob | Response; durationMs?: number } | null> {
233
- if (this.absent.has(hash)) return null
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
- return result as { body: Blob | Response; durationMs?: number }
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
  /**
@@ -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 23 columns `bindRun` fills. */
20
- const RUN_TUPLE = `(${Array(23).fill('?').join(', ')})`
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: 40 × 23 variables stays under 999, the oldest default
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 = 40
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 23 positional parameters. */
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
 
@@ -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);
@@ -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: () => Promise<Record<string, unknown>>): void {
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', async () => ({ exports: await load(), loader: 'object' }))
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] ?? 'npm'
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(pkgRoot: string | null, dry: boolean): Promise<string[]> {
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
- const WORKSPACE =
239
- "import { defineWorkspace } from '@vzn/vx'\n\nexport default defineWorkspace({ plugins: [] })\n"
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
- const installs = args.json === true || args.dry ? await install(pkgRoot, true) : []
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}`, WORKSPACE],
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 vx = VERSION === '0.0.0' ? vxInvocation(process.env['npm_config_user_agent'], false) : 'vx'
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 idW = Math.max(...ordered.map((t) => `${t.project}#${t.task}`.length), 4)
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 { BranchFacts, VxPlugin, PluginCommand, CommandContext } from '../orchestrator/index.js'
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(rawArgs: readonly string[], verb: 'run' | 'watch' = 'run'): RunArgs {
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 a plugin's (`setBranch`:
248
- * a pull request's base commit), else the merge base with `baseBranch`.
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 {