@vzn/vx 0.0.491 → 0.0.493

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.491",
3
+ "version": "0.0.493",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.491",
25
- "@vzn/vx-linux-arm64": "0.0.491",
26
- "@vzn/vx-darwin-x64": "0.0.491",
27
- "@vzn/vx-darwin-arm64": "0.0.491"
24
+ "@vzn/vx-linux-x64": "0.0.493",
25
+ "@vzn/vx-linux-arm64": "0.0.493",
26
+ "@vzn/vx-darwin-x64": "0.0.493",
27
+ "@vzn/vx-darwin-arm64": "0.0.493"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -16,6 +16,7 @@ export {
16
16
  type InvocationRecord,
17
17
  KEYED_RUNS_SQL,
18
18
  parseCachePolicy,
19
+ scopeCachePolicy,
19
20
  type RunRecord,
20
21
  type TaskInputRow,
21
22
  WORKSPACE_OUTPUT_PREFIX,
@@ -33,6 +33,7 @@ import type {
33
33
  SaveArgs,
34
34
  } from './cache.js'
35
35
  import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
36
+ import { xxh3hex } from '../util/index.js'
36
37
 
37
38
  /**
38
39
  * What a remote cache layer must provide — THE plugin seam for remote
@@ -157,6 +158,68 @@ function batchOf(hashes: readonly string[]): { batch: string } {
157
158
  return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
158
159
  }
159
160
 
161
+ /**
162
+ * The remote as an untrusted scope sees it: reads try the scope's key,
163
+ * where a PR's own earlier runs left it, then the trusted one; writes go to the scope's key only. A scope's key is
164
+ * derived from the task key, so every wire stores it unchanged and the
165
+ * trusted keyspace never holds a scoped write. What a scoped read
166
+ * returns is ingested under the TASK key, and ingest refuses an artifact
167
+ * recording another key, so a derived-key collision degrades to a miss.
168
+ */
169
+ class ScopedRemote implements RemoteCacheLayer {
170
+ readonly endpoint?: string
171
+ readonly hasMany?: (hashes: readonly string[]) => Promise<Set<string> | null>
172
+ /** Task keys a batch probe found under the trusted key alone: their GET skips the scope's. */
173
+ private readonly trustedOnly = new Set<string>()
174
+
175
+ constructor(
176
+ private readonly remote: RemoteCacheLayer,
177
+ private readonly scope: string,
178
+ ) {
179
+ if (remote.endpoint !== undefined) this.endpoint = remote.endpoint
180
+ if (remote.hasMany !== undefined) this.hasMany = (hashes) => this.scopedHasMany(hashes)
181
+ }
182
+
183
+ private scoped(hash: string): string {
184
+ return scopedKey(this.scope, hash)
185
+ }
186
+
187
+ async has(hash: string): Promise<boolean> {
188
+ return (await this.remote.has(this.scoped(hash))) || this.remote.has(hash)
189
+ }
190
+
191
+ private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
192
+ const scoped = hashes.map((h) => this.scoped(h))
193
+ const [trusted, own] = await Promise.all([
194
+ this.remote.hasMany!(hashes),
195
+ this.remote.hasMany!(scoped),
196
+ ])
197
+ if (trusted == null || own == null) return null
198
+ const found = new Set(trusted)
199
+ for (let i = 0; i < hashes.length; i++) {
200
+ if (own.has(scoped[i]!)) found.add(hashes[i]!)
201
+ else if (trusted.has(hashes[i]!)) this.trustedOnly.add(hashes[i]!)
202
+ }
203
+ return found
204
+ }
205
+
206
+ async get(
207
+ hash: string,
208
+ ): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
209
+ if (this.trustedOnly.has(hash)) return this.remote.get(hash)
210
+ return (await this.remote.get(this.scoped(hash))) ?? this.remote.get(hash)
211
+ }
212
+
213
+ put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
214
+ return this.remote.put(this.scoped(hash), body, meta)
215
+ }
216
+ }
217
+
218
+ /** The key an untrusted scope stores `hash` under: task-key shaped, 16 hex. */
219
+ function scopedKey(scope: string, hash: string): string {
220
+ return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
221
+ }
222
+
160
223
  export interface LayeredCacheOptions {
161
224
  /**
162
225
  * Called for the remote failures the layer degrades to a miss, once per
@@ -222,12 +285,17 @@ export class LayeredCache implements CacheLayer {
222
285
  */
223
286
  private readonly reported = new Map<string, { cause: string; repeats: number }>()
224
287
 
288
+ /** The remote as this run's scope sees it: `remote` itself when trusted. */
289
+ private readonly wire: RemoteCacheLayer
290
+
225
291
  constructor(
226
292
  readonly local: Cache,
227
293
  private readonly remote: RemoteCacheLayer,
228
294
  private readonly options: LayeredCacheOptions = {},
229
295
  ) {
230
296
  this.policy = options.policy ?? FULL_CACHE_POLICY
297
+ const scope = this.policy.remoteScope
298
+ this.wire = scope === undefined ? remote : new ScopedRemote(remote, scope)
231
299
  this.endpoint = printableEndpoint(remote.endpoint)
232
300
  }
233
301
 
@@ -254,11 +322,11 @@ export class LayeredCache implements CacheLayer {
254
322
  * degrades to "no batch info" and the caller falls back to per-hash.
255
323
  */
256
324
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
257
- if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
325
+ if (!this.policy.remoteRead || this.wire.hasMany === undefined) return null
258
326
  try {
259
327
  // `return await`, deliberately: the catch below is the never-fail
260
328
  // contract, and a returned promise's rejection would sail past it.
261
- const found: unknown = await this.remote.hasMany(hashes)
329
+ const found: unknown = await this.wire.hasMany(hashes)
262
330
  if (found !== null && found !== undefined && !(found instanceof Set)) {
263
331
  this.reportRemoteError(
264
332
  'probe',
@@ -337,7 +405,7 @@ export class LayeredCache implements CacheLayer {
337
405
  if ((await this.local.has(hash)) === 'local') return 'local'
338
406
  if (!this.policy.remoteRead) return null
339
407
  try {
340
- return (await this.remote.has(hash)) ? 'remote' : null
408
+ return (await this.wire.has(hash)) ? 'remote' : null
341
409
  } catch (err) {
342
410
  this.reportRemoteError('probe', hash, err)
343
411
  return null
@@ -373,7 +441,7 @@ export class LayeredCache implements CacheLayer {
373
441
 
374
442
  let remoteResult: unknown
375
443
  try {
376
- remoteResult = await this.remote.get(hash)
444
+ remoteResult = await this.wire.get(hash)
377
445
  } catch (err) {
378
446
  this.reportRemoteError('download', hash, err)
379
447
  return false
@@ -478,7 +546,7 @@ export class LayeredCache implements CacheLayer {
478
546
  try {
479
547
  const body =
480
548
  packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
481
- await this.remote.put(hash, body, { durationMs })
549
+ await this.wire.put(hash, body, { durationMs })
482
550
  } catch (err) {
483
551
  this.reportRemoteError('upload', hash, err)
484
552
  }
@@ -20,6 +20,12 @@ export interface CachePolicy {
20
20
  localWrite: boolean
21
21
  remoteRead: boolean
22
22
  remoteWrite: boolean
23
+ /**
24
+ * An untrusted scope (`cacheScope: 'pr-123'`): remote reads try this
25
+ * scope's keys, then the trusted ones; remote writes go to this scope's
26
+ * keys only. Absent = trusted. See {@link scopeCachePolicy}.
27
+ */
28
+ remoteScope?: string
23
29
  }
24
30
 
25
31
  /** All four axes on — the default when no cache flag is passed. */
@@ -84,3 +90,17 @@ export function parseCachePolicy(spec: string, base: CachePolicy = FULL_CACHE_PO
84
90
  }
85
91
  return out
86
92
  }
93
+
94
+ /**
95
+ * Apply a workspace's `cacheScope` to the run's policy. `'trusted'` (or
96
+ * none) changes nothing; `'read-only'` turns remote writes off; any other
97
+ * name is an untrusted scope. A clamp, never a widening: `--cache` still
98
+ * decides which axes are on. A client-side convention, not a boundary —
99
+ * any run holding a write credential can write a trusted key; only the
100
+ * cache server, by token, can refuse it (docs/security.md).
101
+ */
102
+ export function scopeCachePolicy(policy: CachePolicy, scope: string | undefined): CachePolicy {
103
+ if (scope === undefined || scope === 'trusted') return policy
104
+ if (scope === 'read-only') return { ...policy, remoteWrite: false }
105
+ return { ...policy, remoteScope: scope }
106
+ }
package/src/cli/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { isatty } from 'node:tty'
3
4
  import { translateForeign } from './foreign-flags.js'
4
5
  import { flagHint, seeHelp } from './help.js'
5
6
  import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
@@ -422,6 +423,11 @@ export function detectFlow(
422
423
  : 'focused'
423
424
  }
424
425
 
426
+ /** `CI=0` / `CI=false` are not CI, as the output flow reads them. */
427
+ function onCI(v: string | undefined): boolean {
428
+ return v !== undefined && v !== '' && v !== '0' && v !== 'false'
429
+ }
430
+
425
431
  /**
426
432
  * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
427
433
  * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
@@ -535,12 +541,20 @@ export async function resolveRunOptions(
535
541
  tasks: [...tasks],
536
542
  cache: parsed.cache,
537
543
  ...(parsed.remoteRequested ? { remoteRequested: true } : {}),
544
+ // A laptop reads the shared cache and never writes it (owner,
545
+ // 2026-10-03); naming the remote in `--cache` is asking to.
546
+ ...(!parsed.remoteRequested && !onCI(process.env['CI'])
547
+ ? { defaultCacheScope: 'read-only' }
548
+ : {}),
538
549
  flow: detectFlow(parsed),
539
550
  ...(parsed.frozen ? { frozen: true } : {}),
540
551
  ...(parsed.outputLogs !== undefined ? { outputLogs: parsed.outputLogs } : {}),
541
552
  ...(parsed.download !== undefined ? { download: parsed.download } : {}),
542
553
  ...(parsed.continueMode !== undefined ? { continueMode: parsed.continueMode } : {}),
543
554
  forwardArgs: parsed.forwardArgs,
555
+ // isatty, not process.stdin: touching the stream opens it, and a config
556
+ // that throws then reports an extra native stack frame.
557
+ ...(isatty(0) ? { tty: true } : {}),
544
558
  }
545
559
  if (parsed.excludeDependencies === 'all') {
546
560
  opts.excludeDependencies = 'all'
package/src/cli/select.ts CHANGED
@@ -254,6 +254,16 @@ export async function resolveFilters(
254
254
  return { error: err instanceof Error ? err.message : String(err) }
255
255
  }
256
256
  }
257
+ // A tag lives in the config, so a `tag:` selector needs the staged load
258
+ // (a `project` plugin may have given or edited the tags).
259
+ let tags: Map<string, readonly string[]> | undefined
260
+ if (parsed.some((f) => f.tag === true)) {
261
+ try {
262
+ tags = new Map([...(await stagedOnce()).values()].map((p) => [p.name, p.config.tags ?? []]))
263
+ } catch (err) {
264
+ return { error: err instanceof Error ? err.message : String(err) }
265
+ }
266
+ }
257
267
  const graph = buildPackageGraph(projects, edges)
258
268
  // The graph a run reusing this discovery builds is this one when no task
259
269
  // edge went into it.
@@ -290,6 +300,7 @@ export async function resolveFilters(
290
300
  projects,
291
301
  graph,
292
302
  affectedByFilter,
303
+ ...(tags !== undefined ? { tags } : {}),
293
304
  // A `[<since>]` selector matching nothing is the ordinary "nothing
294
305
  // changed" outcome, reported below; only a name/path pattern that
295
306
  // matched nothing is worth flagging as a probable typo.
@@ -332,14 +343,14 @@ export async function resolveFilters(
332
343
  // the patterns are in the error, and the nearest project name is the
333
344
  // hint a typo needs.
334
345
  return {
335
- error: `no projects matched filter(s): ${raw.join(', ')}${didYouMeanProject(unmatched, projects)}`,
346
+ error: `no projects matched filter(s): ${raw.join(', ')}${didYouMean(unmatched, projects, tags)}`,
336
347
  }
337
348
  }
338
349
  // Something matched, so the run proceeds; a pattern that matched nothing
339
350
  // alongside it is still worth a line — it is probably a typo.
340
351
  for (const f of unmatched)
341
352
  process.stderr.write(
342
- `vx: filter "${f}" matched no projects${didYouMeanProject([f], projects)}\n`,
353
+ `vx: filter "${f}" matched no projects${didYouMean([f], projects, tags)}\n`,
343
354
  )
344
355
  let staged: Map<string, ProjectEntry> | undefined
345
356
  if (stagedPromise !== undefined) {
@@ -447,6 +458,20 @@ export async function pickTask(
447
458
  }
448
459
  }
449
460
 
461
+ /** The hint for the first unmatched pattern: a near tag for a `tag:` one, else a near project name. */
462
+ function didYouMean(
463
+ unmatched: readonly string[],
464
+ projects: Iterable<{ name: string }>,
465
+ tags: ReadonlyMap<string, readonly string[]> | undefined,
466
+ ): string {
467
+ const first = unmatched[0]?.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
468
+ if (first?.startsWith('tag:') !== true) return didYouMeanProject(unmatched, projects)
469
+ const known = [...new Set([...(tags?.values() ?? [])].flat())].sort()
470
+ if (known.length === 0) return '. No project declares tags'
471
+ const best = nearest(first.slice(4), known)
472
+ return best !== undefined ? `. Did you mean tag:${best}?` : `. Tags: ${listed(known)}`
473
+ }
474
+
450
475
  /** `. Did you mean @acme/app?` for the first unmatched pattern within two edits of a project name. */
451
476
  function didYouMeanProject(
452
477
  unmatched: readonly string[],
package/src/cli/show.ts CHANGED
@@ -224,12 +224,13 @@ function renderList(
224
224
  const rows = metas.map((meta) => ({
225
225
  name: meta.name,
226
226
  dir: projectDir(root, meta),
227
+ tags: projects.get(meta.name)?.config.tags ?? [],
227
228
  tasks: Object.keys(projects.get(meta.name)?.config.tasks ?? {}),
228
229
  configured: meta.configPath !== null,
229
230
  }))
230
231
  if (format === 'json') {
231
232
  return `${JSON.stringify(
232
- rows.map(({ name, dir, tasks }) => ({ name, dir, tasks })),
233
+ rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })),
233
234
  null,
234
235
  2,
235
236
  )}\n`
@@ -245,7 +246,8 @@ function renderList(
245
246
  : n > 0
246
247
  ? `${count} (no vx config; from plugins)`
247
248
  : '(no vx config)'
248
- return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}`
249
+ const tags = r.tags.length > 0 ? ` [${r.tags.join(', ')}]` : ''
250
+ return `${r.name.padEnd(nameW)} ${r.dir.padEnd(dirW)} ${tasks}${tags}`
249
251
  })
250
252
  return `${lines.join('\n')}\n`
251
253
  }
@@ -263,7 +265,8 @@ function renderProject(
263
265
  const shown = shownConfig(config)
264
266
  return `${JSON.stringify(JSON.parse(JSON.stringify({ name, dir, config: shown })), null, 2)}\n`
265
267
  }
266
- const head = `${name} — ${dir}`
268
+ const tags = config?.tags ?? []
269
+ const head = `${name} — ${dir}${tags.length > 0 ? `\n tags: ${tags.join(', ')}` : ''}`
267
270
  const tasks = Object.entries(config?.tasks ?? {})
268
271
  if (tasks.length === 0)
269
272
  return `${head}\n ${hasConfigFile ? '(no tasks declared)' : '(no vx config)'}\n`
package/src/config.ts CHANGED
@@ -29,6 +29,19 @@ export interface WorkspaceConfig {
29
29
  * HEAD, else `HEAD~1`. Never folded into a cache key.
30
30
  */
31
31
  affectedBase?: string
32
+ /**
33
+ * Where this run's remote cache writes land. `'trusted'` reads and
34
+ * writes the trusted keys, for the default branch's CI. `'read-only'`
35
+ * reads them and writes nothing; omitted, `vx run` off CI takes it, and
36
+ * on CI (or with `--cache` naming the remote) `'trusted'`. Any other
37
+ * name (`'pr-123'`) is an untrusted scope: reads try the scope's keys,
38
+ * then the trusted ones, and writes go to the scope's only, so a PR never
39
+ * writes what the default branch reads. A client-side convention, not a
40
+ * security boundary: only a cache server that scopes writes by token
41
+ * can refuse a run that ignores it. `VX_CACHE_SCOPE` beats it. Never
42
+ * folded into a cache key.
43
+ */
44
+ cacheScope?: string
32
45
  /**
33
46
  * Plugins registered for this workspace, consulted in this order once
34
47
  * per `vx run`: each fills any of the pipeline stages and capabilities
@@ -117,6 +130,11 @@ const _pluginHooksMatch: [HooksNotListed, HooksNotOnPlugin] extends [never, neve
117
130
  void _pluginHooksMatch
118
131
 
119
132
  export interface ProjectConfig {
133
+ /**
134
+ * Labels for selection: `--filter tag:<name>` picks the projects that
135
+ * carry one. Not in any cache key: a tag changes no task's behaviour.
136
+ */
137
+ tags?: readonly string[]
120
138
  /** Tasks declared by this project, keyed by task name. */
121
139
  tasks?: Record<string, TaskConfig>
122
140
  }
@@ -362,6 +380,16 @@ export interface ExecConfig {
362
380
  * to cache and the work continues after vx's run notion of "done".
363
381
  */
364
382
  persistent?: PersistentConfig
383
+ /**
384
+ * The task reads the terminal: a prompt, a REPL, a watch mode's keys.
385
+ * When vx's stdin is a TTY the task gets vx's stdin, stdout and stderr
386
+ * as they are, and no other task starts while it runs (a persistent one:
387
+ * until it is spawned). It runs in its own session, so `/dev/tty` still
388
+ * cannot be opened. Off a TTY (CI) it runs as any task. Always on this
389
+ * machine, never cached; not allowed with `cache`, `sandbox` or
390
+ * `persistent.readyWhen`.
391
+ */
392
+ interactive?: boolean
365
393
  /**
366
394
  * Confine this task's process: what it may read, write and reach.
367
395
  * Nothing is confined unless this is present.
@@ -5,7 +5,8 @@
5
5
  // every executor list — what a plugin executor declines runs here.
6
6
  // Persistent tasks (`exec.persistent`) never reach an executor: they are local by
7
7
  // construction (a worker cannot hand the submitter a listening port) and
8
- // stay on `runPersistent`.
8
+ // stay on `runPersistent`. An `exec.interactive` task reaches the local one
9
+ // only: no plugin can hand it this terminal.
9
10
  //
10
11
  // Lives in `exec/` (not `orchestrator/`) so the contract depends only on
11
12
  // process primitives — the module-boundary matrix forbids `exec` → `cache`,
@@ -152,6 +153,12 @@ export interface ExecuteRequest {
152
153
  /** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
153
154
  readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
154
155
  readonly sandbox?: ExecuteSandbox
156
+ /**
157
+ * The task holds vx's terminal (`exec.interactive` on a TTY): its stdin,
158
+ * stdout and stderr are vx's own, and the callbacks hear nothing. Only
159
+ * core's local executor is ever handed such a task (placement.ts).
160
+ */
161
+ readonly terminal?: true
155
162
  }
156
163
 
157
164
  export interface ExecuteResult extends RunResult {
@@ -45,7 +45,7 @@ export function localExecutor(): TaskExecutor {
45
45
  ...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
46
46
  }
47
47
  if (req.sandbox === undefined) {
48
- const res = await runCommand(common)
48
+ const res = await runCommand(req.terminal === true ? { ...common, terminal: true } : common)
49
49
  return { ...res, violations: [] }
50
50
  }
51
51
  return runSandboxed({
@@ -87,6 +87,19 @@ export interface RunOptions {
87
87
  * handler can SIGTERM everything still alive mid-run.
88
88
  */
89
89
  liveChildren?: Set<ReturnType<typeof Bun.spawn>>
90
+ /**
91
+ * Hand the child vx's own stdin, stdout and stderr (`exec.interactive` on
92
+ * a TTY). Nothing passes through vx: the callbacks hear nothing and the
93
+ * result's streams are empty.
94
+ */
95
+ terminal?: boolean
96
+ }
97
+
98
+ /** A child's descriptors 0–2: vx's own for a task that holds the terminal. */
99
+ function taskStdio(terminal: boolean | undefined, stdin: 'ignore' | 'pipe') {
100
+ return terminal === true
101
+ ? (['inherit', 'inherit', 'inherit'] as const)
102
+ : ([stdin, 'pipe', 'pipe'] as const)
90
103
  }
91
104
 
92
105
  export function shellQuote(arg: string): string {
@@ -492,20 +505,19 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
492
505
  // A pipe vx holds and never writes: stdin stays open while vx
493
506
  // lives and ends when it does. A dev server that exits on stdin
494
507
  // EOF (esbuild --watch, Vite's case in turborepo#8915) became ready
495
- // and exited 0 under 'ignore'. Not the terminal: several servers
496
- // would steal each other's keystrokes, and a CI's /dev/null stdin
497
- // is the same EOF. The one-shot spawn below keeps 'ignore', so a
498
- // task that reads stdin can never hang CI.
508
+ // and exited 0 under 'ignore'. Not the terminal unless the task
509
+ // asks (`terminal`, one per run): several servers would steal each
510
+ // other's keystrokes, and a CI's /dev/null stdin is the same EOF.
511
+ // The one-shot spawn below keeps 'ignore', so a task that reads
512
+ // stdin can never hang CI.
499
513
  stdio: [
500
- 'pipe',
501
- 'pipe',
502
- 'pipe',
514
+ ...taskStdio(opts.terminal, 'pipe'),
503
515
  ...(signalFd ? ['pipe' as const] : []),
504
516
  ...(guard === undefined ? [] : [guard]),
505
517
  ],
506
518
  // Its own session and process group, so a kill reaches what it
507
- // forked (kill-tree.ts). stdin is a pipe, so a background group
508
- // never stops on a terminal read.
519
+ // forked (kill-tree.ts). The terminal, when it is handed over, is
520
+ // not this session's controlling one, so no read stops the group.
509
521
  detached: true,
510
522
  },
511
523
  ),
@@ -712,10 +724,10 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
712
724
  ...SH_ARGV0,
713
725
  cwd: opts.cwd,
714
726
  env: opts.env as Record<string, string>,
715
- stdio: ['ignore', 'pipe', 'pipe', ...(guard === undefined ? [] : [guard])],
727
+ stdio: [...taskStdio(opts.terminal, 'ignore'), ...(guard === undefined ? [] : [guard])],
716
728
  // Its own session and process group, so a kill reaches what it
717
- // forked (kill-tree.ts). stdin is ignored, so a background group
718
- // never stops on a terminal read.
729
+ // forked (kill-tree.ts). A terminal handed over is not this
730
+ // session's controlling one, so no read stops the group.
719
731
  detached: true,
720
732
  }),
721
733
  )
@@ -877,11 +889,8 @@ export async function streamToString(
877
889
  signal?: AbortSignal,
878
890
  retain = true,
879
891
  ): Promise<string> {
880
- // Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`
881
- // — the `number` is for inheritance modes, only present when the caller
882
- // chose `'inherit'` instead of `'pipe'`. We only call this with `'pipe'`,
883
- // so the runtime value is always a ReadableStream; the `number` branch
884
- // is unreachable but typed.
892
+ // Bun.spawn types stdout/stderr as `ReadableStream | number | undefined`;
893
+ // a stream the child inherited (`terminal`) is not one, and holds nothing.
885
894
  if (!stream || typeof stream === 'number') return ''
886
895
  const full = new BoundedCapture()
887
896
  const reader = stream.getReader()
@@ -251,6 +251,13 @@ export interface ScheduleOptions {
251
251
  * pooled tasks hold no local resources and are never asked.
252
252
  */
253
253
  admit?: (id: string, running: ReadonlySet<string>) => boolean
254
+ /**
255
+ * Tasks that hold the terminal (`exec.interactive` on a TTY). One starts
256
+ * only once nothing runs, and nothing starts while it runs. When one is
257
+ * the next to start, dispatch stops so the running tasks drain. A
258
+ * persistent one holds it until `execute` resolves, at spawn.
259
+ */
260
+ exclusive?: ReadonlySet<string>
254
261
  }
255
262
 
256
263
  /**
@@ -477,7 +484,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
477
484
  if (pool === undefined) return execRoom()
478
485
  return (poolActive.get(pool.name) ?? 0) < pool.capacity
479
486
  }
487
+ const exclusive = options.exclusive
488
+ let holding = false
489
+ const busy = (): boolean => {
490
+ if (active > 0 || activeRestore > 0) return true
491
+ for (const n of poolActive.values()) if (n > 0) return true
492
+ return false
493
+ }
480
494
  const admit = (id: string): (() => void) => {
495
+ if (exclusive?.has(id) === true) {
496
+ holding = true
497
+ active++
498
+ return () => {
499
+ holding = false
500
+ active--
501
+ }
502
+ }
481
503
  if (inRestoreTier(id)) {
482
504
  activeRestore++
483
505
  return () => {
@@ -643,6 +665,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
643
665
  // task returns without asking (finishing it is free); restore
644
666
  // tasks are never asked, so they never park.
645
667
  const takeFitting = (): string | undefined => {
668
+ if (holding) return undefined
646
669
  // No pools, no policy and a full exec lane: nothing on the
647
670
  // exec queue can be admitted, so it is not scanned — scanning it
648
671
  // would pop and re-park every ready exec task on every tick, and
@@ -656,6 +679,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
656
679
  const seq = execReady.peekSeq()
657
680
  const id = execReady.pop() as string
658
681
  if (willSkip(id)) return id
682
+ if (exclusive?.has(id) === true) {
683
+ if (!busy() && (!admitActive || admits(id))) return id
684
+ parked.push([id, seq])
685
+ return undefined
686
+ }
659
687
  if (hasRoom(id)) {
660
688
  if (!admitActive || admits(id)) return id
661
689
  // A free worker, refused by the policy: the hold starts now.
@@ -227,6 +227,8 @@ export interface ExecuteArgs {
227
227
  * walk cost 1,000 read-only misses 1.62 → 1.78 s, item 750).
228
228
  */
229
229
  noDependants?: true
230
+ /** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
231
+ terminal?: true
230
232
  }
231
233
 
232
234
  /**
@@ -362,6 +364,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
362
364
  onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
363
365
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
364
366
  ...(signalChannel ? { signalChannel } : {}),
367
+ ...(args.terminal === true ? { terminal: true } : {}),
365
368
  }
366
369
  if (step.persistent.readyWhen !== undefined) {
367
370
  persistentOpts.readyWhen = step.persistent.readyWhen
@@ -1146,6 +1149,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1146
1149
  // through this flag — the policy gates above only cover vx's OWN cache.
1147
1150
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
1148
1151
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1152
+ ...(args.terminal === true ? { terminal: true as const } : {}),
1149
1153
  }
1150
1154
  if (!userSandbox) return base
1151
1155
  await args.armSandbox?.()
@@ -178,7 +178,7 @@ export function defaultLogger(
178
178
  colors: ColorSupport = detectColors(),
179
179
  view: OutputView = { mode: 'full' },
180
180
  out: StatusStream = process.stdout,
181
- opts: { forceFloorMs?: number; coalesce?: boolean } = {},
181
+ opts: { forceFloorMs?: number; coalesce?: boolean; tty?: boolean } = {},
182
182
  ): DefaultLogger {
183
183
  // Per-task buffers, split by stream. Splitting lets the framed-output
184
184
  // renderer put stdout under `├─ stdout` and stderr under `├─ stderr`.
@@ -440,8 +440,16 @@ export function defaultLogger(
440
440
  // group (see markSurfacedDeps) — is a "primary" node the focused
441
441
  // view shows. Groups never stream (no output of their own).
442
442
  const isPrimary = (node: TaskNode): boolean => node.requested || node.surfaced === true
443
+ // A task handed the terminal writes to it directly, so the status region
444
+ // goes for good (it would repaint over a prompt) and, in the views that
445
+ // frame executed work, a live frame opens before it and closes after;
446
+ // what vx itself says about it streams inside.
447
+ const holdsTerminal = (node: TaskNode): boolean =>
448
+ opts.tty === true && node.config.exec?.interactive === true
449
+ const framesTerminal = view.mode === 'full' || view.mode === 'focused' || view.mode === 'broad'
443
450
  const streamsLive = (node: TaskNode): boolean =>
444
- view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1
451
+ (framesTerminal && holdsTerminal(node)) ||
452
+ (view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1)
445
453
 
446
454
  return {
447
455
  settle() {
@@ -487,6 +495,11 @@ export function defaultLogger(
487
495
  },
488
496
  taskStart(node) {
489
497
  if (isGroupTask(node)) return
498
+ if (holdsTerminal(node)) {
499
+ killStatus()
500
+ // What is coalesced lands before the task writes past it.
501
+ writer.settle()
502
+ }
490
503
  // Focused flow: the status line exists for the dependency
491
504
  // phase only. The moment a requested node starts streaming,
492
505
  // clear it for good — its raw output owns the terminal now.
@@ -647,7 +660,10 @@ export function defaultLogger(
647
660
  // the terminal where a frame would have: a task that traps Ctrl-C
648
661
  // and exits 0 is aborted since item 962, and its frame was dropped
649
662
  // with the count.
650
- const framed = view.mode === 'full' || (view.mode === 'focused' && isPrimary(node))
663
+ const framed =
664
+ view.mode === 'full' ||
665
+ (view.mode === 'focused' && isPrimary(node)) ||
666
+ (framesTerminal && holdsTerminal(node))
651
667
  if (framed && !isGroupTask(node)) {
652
668
  if (streamsLive(node)) {
653
669
  if (streamMidLine) {
@@ -713,6 +729,14 @@ export function defaultLogger(
713
729
  refresh(true)
714
730
  }
715
731
  if (isGroupTask(node)) return
732
+ if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
733
+ if (streamMidLine) {
734
+ writer.write('\n')
735
+ streamMidLine = false
736
+ }
737
+ emitFrameClose(formatFrameClose(node, outcome, colors))
738
+ return
739
+ }
716
740
  const isHit = isCacheHit(outcome.status)
717
741
  switch (view.mode) {
718
742
  case 'none':
@@ -70,6 +70,12 @@ export interface RunOptions {
70
70
  * saying it is not.
71
71
  */
72
72
  remoteRequested?: boolean
73
+ /**
74
+ * The `cacheScope` a run takes when `vx.workspace` names none. The CLI
75
+ * passes `'read-only'` off CI (`CI` unset, `0` or `false`) unless
76
+ * `--cache` names the remote; omitted → trusted.
77
+ */
78
+ defaultCacheScope?: string
73
79
  /**
74
80
  * CI mode: load configs FROM the committed vx-lock.json instead of
75
81
  * evaluating them (frozen-env reproducibility). Requires the lock
@@ -189,6 +195,12 @@ export interface RunOptions {
189
195
  * is replaced only when the next cycle starts.
190
196
  */
191
197
  holdPersistent?: boolean
198
+ /**
199
+ * vx's stdin is a terminal: each `exec.interactive` task is handed it,
200
+ * alone (placement.ts `terminalHolders`). The CLI sets it from
201
+ * `isatty(0)`; absent, an interactive task runs as any task.
202
+ */
203
+ tty?: boolean
192
204
  log?: Logger
193
205
  /**
194
206
  * Inject the run's event bus. When provided, the orchestrator emits
@@ -2,12 +2,13 @@
2
2
  // executors, and the plan-mode view of it. Split from run.ts on 2026-09-10
3
3
  // (pure motion). A task is pinned to this machine when it is persistent,
4
4
  // depends on a persistent task, is sandboxed, says `exec.remote: false`, or
5
- // folds a runtime probe into its key;
5
+ // folds a runtime probe into its key; an `exec.interactive` task goes to the
6
+ // local floor directly, as only it can hand over this terminal;
6
7
  // everything else asks the executors in declaration order, and the local
7
8
  // floor takes what nothing claimed.
8
9
 
9
- import { machineParallelism } from '../util/index.js'
10
- import { selectExecutor, type TaskExecutor } from '../exec/index.js'
10
+ import { machineParallelism, UserError } from '../util/index.js'
11
+ import { isLocalExecutor, selectExecutor, type TaskExecutor } from '../exec/index.js'
11
12
  import { isGroupTask, type TaskNode } from '../graph/index.js'
12
13
  import { resolveDownloadModes } from './download-policy.js'
13
14
  import type { Logger } from './logger.js'
@@ -75,6 +76,49 @@ function withProbedRuntime(nodes: Map<string, TaskNode>, pinned: Set<string>): S
75
76
  return pinned
76
77
  }
77
78
 
79
+ /**
80
+ * The tasks that hold the terminal in a run on one: every
81
+ * `exec.interactive` task when vx's stdin is a TTY, none otherwise. A
82
+ * persistent one holds it until the run ends, so a run may have one, and
83
+ * every other interactive task must run before it (a dependency); anything
84
+ * else would have two tasks reading the same keys. Refused before any task
85
+ * runs.
86
+ */
87
+ export function terminalHolders(nodes: Map<string, TaskNode>, tty: boolean): Set<string> {
88
+ const holders = new Set<string>()
89
+ if (!tty) return holders
90
+ for (const node of nodes.values()) {
91
+ if (node.config.exec?.interactive === true) holders.add(node.id)
92
+ }
93
+ const servers = [...holders].filter((id) => nodes.get(id)!.config.exec!.persistent !== undefined)
94
+ if (servers.length > 1) {
95
+ throw new UserError(
96
+ `${servers.join(' and ')} are persistent and interactive: a server holds the terminal ` +
97
+ `until the run ends, so one run can hold one — run them in separate terminals`,
98
+ )
99
+ }
100
+ if (servers.length === 0) return holders
101
+ const server = servers[0]!
102
+ const before = new Set<string>()
103
+ const stack = [...nodes.get(server)!.deps]
104
+ while (stack.length > 0) {
105
+ const id = stack.pop()!
106
+ if (before.has(id)) continue
107
+ before.add(id)
108
+ stack.push(...nodes.get(id)!.deps)
109
+ }
110
+ const clash = [...holders].filter((id) => id !== server && !before.has(id))
111
+ if (clash.length > 0) {
112
+ throw new UserError(
113
+ `${server} is persistent and interactive, so it holds the terminal until the run ends, ` +
114
+ `and ${clash.join(', ')} would ask for it too: make ${server} depend on ` +
115
+ `${clash.length === 1 ? 'it' : 'them'}, or run ${clash.length === 1 ? 'it' : 'them'} on ` +
116
+ `${clash.length === 1 ? 'its' : 'their'} own`,
117
+ )
118
+ }
119
+ return holders
120
+ }
121
+
78
122
  export interface Placements {
79
123
  executors: Map<string, TaskExecutor>
80
124
  /**
@@ -118,8 +162,14 @@ export function placeTasks(
118
162
  remoteOnlyNoop: new Set(),
119
163
  remoteOnly: new Set(),
120
164
  }
165
+ // `resolveExecutors` puts it last in every list.
166
+ const floor = executors.find(isLocalExecutor)!
121
167
  for (const node of nodes.values()) {
122
168
  if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
169
+ if (node.config.exec?.interactive === true) {
170
+ placements.executors.set(node.id, floor)
171
+ continue
172
+ }
123
173
  const executor = selectExecutor(
124
174
  executors,
125
175
  {
@@ -16,6 +16,7 @@ import {
16
16
  type CacheLayer,
17
17
  type CachePolicy,
18
18
  FULL_CACHE_POLICY,
19
+ scopeCachePolicy,
19
20
  GitFilesCache,
20
21
  LayeredCache,
21
22
  applyGitEnumeration,
@@ -29,6 +30,8 @@ import {
29
30
  computeNestedProjectDirs,
30
31
  computeWorkspaceFingerprints,
31
32
  findWorkspaceRoot,
33
+ cacheScopeEnvError,
34
+ isCacheScope,
32
35
  type LoadReads,
33
36
  loadWorkspace,
34
37
  FROZEN_WITHOUT_LOCK,
@@ -90,6 +93,8 @@ export interface PreparedRun {
90
93
  * would be saved still cleans its outputs before executing.
91
94
  */
92
95
  hasRemoteLayer: boolean
96
+ /** `--cache` with the workspace's `cacheScope` applied: what the layers were built under. */
97
+ cachePolicy: CachePolicy
93
98
  /**
94
99
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
95
100
  * merged over the structural baseline by the scheduler). Empty when no
@@ -257,7 +262,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
257
262
 
258
263
  // The local cache opens BEFORE the configs load: it is also where their
259
264
  // cached evaluations live.
260
- const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
265
+ // `VX_CACHE_SCOPE` wins over the workspace (and `github()`'s guess):
266
+ // a CI job that knows its trust level says so without editing config.
267
+ const envScope = process.env['VX_CACHE_SCOPE'] || undefined
268
+ if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
269
+ const policy: CachePolicy = scopeCachePolicy(
270
+ options.cache ?? FULL_CACHE_POLICY,
271
+ envScope ?? workspaceConfig?.cacheScope ?? options.defaultCacheScope,
272
+ )
261
273
  const localCache = new Cache(
262
274
  cacheDir,
263
275
  { read: policy.localRead, write: policy.localWrite },
@@ -455,6 +467,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
455
467
  cache,
456
468
  localCache,
457
469
  hasRemoteLayer,
470
+ cachePolicy: policy,
458
471
  priorities: new Map(),
459
472
  nodes: new Map(),
460
473
  keyOnly: new Map(),
@@ -610,6 +623,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
610
623
  cache,
611
624
  localCache,
612
625
  hasRemoteLayer,
626
+ cachePolicy: policy,
613
627
  priorities,
614
628
  nodes,
615
629
  keyOnly,
@@ -5,12 +5,7 @@
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import path from 'node:path'
8
- import {
9
- type CacheLayer,
10
- type CachePolicy,
11
- FULL_CACHE_POLICY,
12
- stopRuntimeProbes,
13
- } from '../cache/index.js'
8
+ import { type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
14
9
  import { VERSION } from '../version.js'
15
10
  import {
16
11
  resetSandbox,
@@ -77,6 +72,7 @@ import {
77
72
  placeTasks,
78
73
  planExecutorOf,
79
74
  poolOfPlacement,
75
+ terminalHolders,
80
76
  UNPLACED_EXECUTOR,
81
77
  locallyPlaced,
82
78
  } from './placement.js'
@@ -300,7 +296,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
300
296
  // See docs/design/event-stream-2026-06.md.
301
297
  const terminal =
302
298
  options.log === undefined
303
- ? defaultLogger(colors, resolveOutputView(options), process.stdout, { coalesce: true })
299
+ ? defaultLogger(colors, resolveOutputView(options), process.stdout, {
300
+ coalesce: true,
301
+ ...(options.tty === true ? { tty: true } : {}),
302
+ })
304
303
  : null
305
304
  const sink = options.log ?? terminal!
306
305
  // An injected bus already has surfaces subscribed; we add the terminal
@@ -471,6 +470,7 @@ async function runOnBus(
471
470
  // worker slot. Group tasks run nothing; persistent tasks never reach an
472
471
  // executor (local by construction) — both stay off the map.
473
472
  const placements = await abandoning(() => placeTasks(nodes, executors))
473
+ const holders = await abandoning(() => terminalHolders(nodes, options.tty === true))
474
474
  // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
475
475
  // deliberate — on a machine with no remote pool the ambient state already
476
476
  // is what the task would have produced — but it must not be SILENT: a task
@@ -673,10 +673,7 @@ async function runOnBus(
673
673
  // actually governed the run. Reading the raw request here made tasks
674
674
  // clean their outputs before every exec for a save that never
675
675
  // happened.
676
- const policy: CachePolicy = effectiveCachePolicy(
677
- options.cache ?? FULL_CACHE_POLICY,
678
- prepared.hasRemoteLayer,
679
- )
676
+ const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
680
677
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
681
678
  log.status(
682
679
  'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
@@ -942,6 +939,7 @@ async function runOnBus(
942
939
  ...(upfrontKey !== undefined ? { upfrontKey } : {}),
943
940
  ...(tainted ? { taintedUpstream: true } : {}),
944
941
  ...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
942
+ ...(holders.has(node.id) ? { terminal: true as const } : {}),
945
943
  fingerprintWatch,
946
944
  ...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
947
945
  keyedProjects: keyed,
@@ -975,6 +973,7 @@ async function runOnBus(
975
973
  settledOf: (o) => deferredSaves.get(o.node.id),
976
974
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
977
975
  ...(admit !== undefined ? { admit } : {}),
976
+ ...(holders.size > 0 ? { exclusive: holders } : {}),
978
977
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
979
978
  serverDied,
980
979
  signal: stopRun.signal,
@@ -1452,10 +1451,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1452
1451
  workspaceRoot: prepared.workspaceRoot,
1453
1452
  workspaceFingerprint: prepared.workspaceFingerprint,
1454
1453
  cache: prepared.cache,
1455
- cachePolicy: effectiveCachePolicy(
1456
- options.cache ?? FULL_CACHE_POLICY,
1457
- prepared.hasRemoteLayer,
1458
- ),
1454
+ cachePolicy: effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer),
1459
1455
  forwardArgs: options.forwardArgs,
1460
1456
  nestedDirsByProject: prepared.nestedDirsByProject,
1461
1457
  gitFilesCache: prepared.gitFilesCache,
@@ -30,12 +30,26 @@ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
30
30
  // the same reason the task levels reject them: `plugin: [...]` (singular)
31
31
  // declared no plugins and ran the workspace bare, `cacheDirectory` left the
32
32
  // cache where it was — a config that loads and quietly does nothing it says.
33
+ const CACHE_SCOPE_RULE =
34
+ "must be 'trusted', 'read-only', or a scope name of letters, digits and . _ - / @ (at most 128), like 'pr-123'"
35
+
36
+ /** `cacheScope`'s rule, shared with `VX_CACHE_SCOPE`. */
37
+ export function isCacheScope(v: unknown): v is string {
38
+ return typeof v === 'string' && /^[\w.@/-]{1,128}$/.test(v)
39
+ }
40
+
41
+ /** Why `VX_CACHE_SCOPE` is refused, in `cacheScope`'s words. */
42
+ export function cacheScopeEnvError(): UserError {
43
+ return new UserError(`VX_CACHE_SCOPE ${CACHE_SCOPE_RULE}`)
44
+ }
45
+
33
46
  const WORKSPACE_FIELDS = new Set([
34
47
  'concurrency',
35
48
  'cacheDir',
36
49
  'timeout',
37
50
  'cacheRetention',
38
51
  'affectedBase',
52
+ 'cacheScope',
39
53
  'plugins',
40
54
  ])
41
55
 
@@ -112,6 +126,9 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
112
126
  throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
113
127
  }
114
128
  }
129
+ if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
130
+ throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
131
+ }
115
132
  if (config.plugins !== undefined) {
116
133
  if (!Array.isArray(config.plugins)) {
117
134
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -270,6 +287,13 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
270
287
  // The top level too: `task:` (singular) loaded as a project with no
271
288
  // tasks and every request against it said "no projects declare".
272
289
  assertKnownFields(config, PROJECT_FIELDS, configPath)
290
+ const tags: unknown = config.tags
291
+ if (
292
+ tags !== undefined &&
293
+ (!Array.isArray(tags) || tags.some((t) => typeof t !== 'string' || t.trim() === ''))
294
+ ) {
295
+ throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
296
+ }
273
297
  const tasks = config.tasks
274
298
  if (tasks === undefined) return
275
299
  if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
@@ -400,6 +424,34 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
400
424
  }
401
425
  }
402
426
  }
427
+ const interactive = (exec as { interactive?: unknown }).interactive
428
+ if (interactive !== undefined && typeof interactive !== 'boolean') {
429
+ throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
430
+ }
431
+ if (interactive === true) {
432
+ if (cache !== undefined) {
433
+ throw new UserError(
434
+ `${where}: \`cache\` is not allowed on an interactive task — what it does ` +
435
+ `depends on what is typed, which no key holds`,
436
+ )
437
+ }
438
+ // bwrap's --new-session keeps the terminal usable (probed), but the
439
+ // sandboxed spawn pipes the task's streams: its stderr is read for
440
+ // the tracer's own errors (sandbox-runtime.ts).
441
+ if ((exec as { sandbox?: unknown }).sandbox !== undefined) {
442
+ throw new UserError(
443
+ `${where}: \`sandbox\` is not allowed on an interactive task — a sandboxed ` +
444
+ `task's output passes through vx, and an interactive one writes to the terminal`,
445
+ )
446
+ }
447
+ const ready = (exec as { persistent?: { readyWhen?: unknown } }).persistent
448
+ if (typeof ready === 'object' && ready !== null && ready.readyWhen !== undefined) {
449
+ throw new UserError(
450
+ `${where}: \`persistent.readyWhen\` is not allowed on an interactive task — ` +
451
+ `its output goes to the terminal, so vx reads none of it; it is ready once spawned`,
452
+ )
453
+ }
454
+ }
403
455
  const persistent = (exec as { persistent?: unknown }).persistent
404
456
  if (persistent !== undefined) {
405
457
  if (typeof persistent !== 'object' || persistent === null) {
@@ -683,7 +735,9 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
683
735
  // discarded, so the task hashes as if the field were never written and vx
684
736
  // serves a stale artifact — the same reasoning `sandbox` already
685
737
  // encodes. A new field must be added here deliberately.
686
- const PROJECT_FIELDS = new Set(['tasks'])
738
+ // `tasks` first: `nearest` breaks a tie by order, and `taks` is one edit
739
+ // from both.
740
+ const PROJECT_FIELDS = new Set(['tasks', 'tags'])
687
741
  const TASK_FIELDS = new Set(['description', 'exec', 'dependsOn', 'cache'])
688
742
  const EXEC_FIELDS = new Set([
689
743
  'command',
@@ -691,6 +745,7 @@ const EXEC_FIELDS = new Set([
691
745
  'timeout',
692
746
  'retries',
693
747
  'persistent',
748
+ 'interactive',
694
749
  'remote',
695
750
  'sandbox',
696
751
  ])
@@ -767,8 +822,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
767
822
  outputLogs: 'the `--output-logs` flag of `vx run`',
768
823
  // Turbo's `interactive` / `with`, Nx's `cwd` / `parallelism` /
769
824
  // `configurations`: refused with no word on where they went (D-89).
770
- interactive:
771
- 'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `exec.persistent`)',
825
+ interactive: '`exec.interactive: true`',
772
826
  with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
773
827
  cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
774
828
  parallelism:
@@ -789,8 +843,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
789
843
  commands: 'one `command` (`a && b`, or `a & b; wait` to run them at once), or one task each',
790
844
  parallel: 'one task each, which vx runs at once, or `a & b; wait` in `command`',
791
845
  shell: 'no field: `command` always runs in a shell',
792
- interactive:
793
- 'a command run outside vx: a task never reads the terminal (its stdin is EOF, or a pipe vx holds under `persistent`)',
794
846
  },
795
847
  ],
796
848
  // wireit's `files` / `output` and include-style spellings written into
@@ -824,7 +876,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
824
876
  },
825
877
  ],
826
878
  // A package's turbo.json and Nx's project.json keys, written into a
827
- // project's vx.config (D-56). `tags` had read as a typo of `tasks`.
879
+ // project's vx.config (D-56).
828
880
  [
829
881
  PROJECT_FIELDS,
830
882
  {
@@ -832,7 +884,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
832
884
  extends: 'an imported module spread into `tasks` (a vx.config is code; nothing is inherited)',
833
885
  implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
834
886
  name: 'the package.json `name` (a project is named by its package)',
835
- tags: '`--filter` (a name glob or a directory) to select projects',
836
887
  // A package.json's, turbo.json's and project.json's keys, and a
837
888
  // task's fields one level too high (D-99).
838
889
  scripts:
@@ -4,6 +4,7 @@
4
4
  // ./<dir> the package at <dir>, else the packages under it (relative to workspace root)
5
5
  // {<dir>} same as ./<dir>
6
6
  // // the workspace-root project only (Turbo's name for the root)
7
+ // tag:<pattern> the projects whose config `tags` hold a match (Nx's `tag:`)
7
8
  // <pattern>... pattern + its transitive workspace dependencies
8
9
  // ...<pattern> pattern + its transitive workspace dependents
9
10
  // <pattern>^... only the transitive deps of pattern (excluding the matched package)
@@ -55,6 +56,8 @@ export interface ParsedFilter {
55
56
  pathRoot?: string
56
57
  /** `//`: the project at `matcher` itself, never the ones under it. */
57
58
  exactDir?: true
59
+ /** `tag:<pattern>`: `matcher` is a glob over the projects' tags, not their names. */
60
+ tag?: true
58
61
  }
59
62
 
60
63
  export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
@@ -141,6 +144,24 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
141
144
  }
142
145
  }
143
146
 
147
+ if (s.startsWith('tag:')) {
148
+ const tag = s.slice(4)
149
+ if (tag === '') throw new UserError(`filter "${raw}" names no tag`)
150
+ return {
151
+ raw,
152
+ negate,
153
+ withDeps,
154
+ withDependents,
155
+ onlyDeps,
156
+ onlyDependents,
157
+ isPath: false,
158
+ matcher: tag,
159
+ tag: true,
160
+ ...(gitSince !== undefined ? { gitSince } : {}),
161
+ ...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
162
+ }
163
+ }
164
+
144
165
  let isPath = false
145
166
  let matcher = s
146
167
  let pathGlob: Bun.Glob | undefined
@@ -187,6 +208,7 @@ function matchProjects(
187
208
  projects: ProjectMeta[],
188
209
  affectedByFilter: Map<ParsedFilter, Set<string>> | undefined,
189
210
  graph: PackageGraph,
211
+ tags: ProjectTags | undefined,
190
212
  ): string[] {
191
213
  // `[<since>]` selectors are pre-resolved by the caller (the parser
192
214
  // is pure; git access happens upstream). Use the provided set as
@@ -199,14 +221,24 @@ function matchProjects(
199
221
  for (const d of graph.transitiveDependents(name)) changed.add(d)
200
222
  }
201
223
  if (filter.matcher === '') return [...changed]
202
- return matchSelector(filter, projects).filter((name) => changed.has(name))
224
+ return matchSelector(filter, projects, tags).filter((name) => changed.has(name))
203
225
  }
204
- return matchSelector(filter, projects)
226
+ return matchSelector(filter, projects, tags)
205
227
  }
206
228
 
207
- /** The projects a name or path selector names, git ranges aside. */
208
- function matchSelector(filter: ParsedFilter, projects: ProjectMeta[]): string[] {
229
+ /** The projects a name, path or tag selector names, git ranges aside. */
230
+ function matchSelector(
231
+ filter: ParsedFilter,
232
+ projects: ProjectMeta[],
233
+ tags: ProjectTags | undefined,
234
+ ): string[] {
209
235
  const out: string[] = []
236
+ if (filter.tag === true) {
237
+ const re = compileNameGlob(filter.matcher)
238
+ for (const p of projects)
239
+ if (tags?.get(p.name)?.some((t) => re.test(t)) === true) out.push(p.name)
240
+ return out
241
+ }
210
242
  if (filter.isPath) {
211
243
  // A path naming a project's own directory is that project, as Turbo and
212
244
  // pnpm read it: `.` is the root project, never every project under the
@@ -268,10 +300,15 @@ function compileNameGlob(pattern: string): RegExp {
268
300
  return new RegExp(`^${escaped}$`)
269
301
  }
270
302
 
303
+ /** Each project's config `tags`, by project name. */
304
+ type ProjectTags = ReadonlyMap<string, readonly string[]>
305
+
271
306
  export interface ApplyFiltersOptions {
272
307
  filters: ParsedFilter[]
273
308
  projects: ProjectMeta[]
274
309
  graph: PackageGraph
310
+ /** Read by `tag:` selectors; a project absent here carries no tag. */
311
+ tags?: ProjectTags
275
312
  /**
276
313
  * Pre-resolved affected-project sets for each `[<since>]` filter.
277
314
  * The caller (CLI / programmatic embedder) runs the git work and
@@ -304,7 +341,7 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
304
341
  // taken in argv order, so `onNoMatch` names the filters as typed.
305
342
  const excludes: Set<string>[] = []
306
343
  for (const f of opts.filters) {
307
- const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph)
344
+ const matched = matchProjects(f, opts.projects, opts.affectedByFilter, opts.graph, opts.tags)
308
345
  if (matched.length === 0) opts.onNoMatch?.(f)
309
346
  const expanded = new Set<string>()
310
347
  for (const name of matched) {
@@ -34,7 +34,12 @@ export {
34
34
  loadWorkspaceConfig,
35
35
  WORKSPACE_CONFIG_FILENAMES,
36
36
  } from './project-loader.js'
37
- export { validateProjectConfig, validateWorkspace } from './config-schema.js'
37
+ export {
38
+ cacheScopeEnvError,
39
+ isCacheScope,
40
+ validateProjectConfig,
41
+ validateWorkspace,
42
+ } from './config-schema.js'
38
43
  export type { LoadProjectConfigOptions } from './project-loader.js'
39
44
  export {
40
45
  blobOidOf,
@@ -203,6 +203,8 @@ export interface GeneratedProject {
203
203
  /** absolute project dir */
204
204
  dir: string
205
205
  importLines: string[]
206
+ /** The project's `tags` (an Nx project's); none written when empty. */
207
+ tags?: readonly string[]
206
208
  tasks: GeneratedTask[]
207
209
  }
208
210
 
@@ -599,7 +601,9 @@ function renderConfigFile(
599
601
  ]
600
602
  if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
601
603
  if (p.importLines.length > 0) lines.push(...p.importLines)
602
- lines.push('', 'export default {', ' tasks: {')
604
+ lines.push('', 'export default {')
605
+ if (p.tags !== undefined && p.tags.length > 0) lines.push(` tags: ${renderValue(p.tags, ' ')},`)
606
+ lines.push(' tasks: {')
603
607
  for (const t of p.tasks) {
604
608
  // A reason can quote a manifest's own text (an Nx `env` key), and a
605
609
  // line break there ended the comment: the rest was code in the file