@vzn/vx 0.0.491 → 0.0.492
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 +5 -5
- package/src/cache/index.ts +1 -0
- package/src/cache/layered-cache.ts +73 -5
- package/src/cache/policy.ts +20 -0
- package/src/cli/run.ts +14 -0
- package/src/config.ts +23 -0
- package/src/exec/executor.ts +8 -1
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +26 -17
- package/src/graph/scheduler.ts +28 -0
- package/src/orchestrator/execute-task.ts +4 -0
- package/src/orchestrator/logger.ts +27 -3
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/placement.ts +53 -3
- package/src/orchestrator/prepare.ts +15 -1
- package/src/orchestrator/run.ts +11 -15
- package/src/workspace/config-schema.ts +47 -4
- package/src/workspace/index.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.492",
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.492",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.492",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.492",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.492"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/index.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
549
|
+
await this.wire.put(hash, body, { durationMs })
|
|
482
550
|
} catch (err) {
|
|
483
551
|
this.reportRemoteError('upload', hash, err)
|
|
484
552
|
}
|
package/src/cache/policy.ts
CHANGED
|
@@ -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/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
|
|
@@ -362,6 +375,16 @@ export interface ExecConfig {
|
|
|
362
375
|
* to cache and the work continues after vx's run notion of "done".
|
|
363
376
|
*/
|
|
364
377
|
persistent?: PersistentConfig
|
|
378
|
+
/**
|
|
379
|
+
* The task reads the terminal: a prompt, a REPL, a watch mode's keys.
|
|
380
|
+
* When vx's stdin is a TTY the task gets vx's stdin, stdout and stderr
|
|
381
|
+
* as they are, and no other task starts while it runs (a persistent one:
|
|
382
|
+
* until it is spawned). It runs in its own session, so `/dev/tty` still
|
|
383
|
+
* cannot be opened. Off a TTY (CI) it runs as any task. Always on this
|
|
384
|
+
* machine, never cached; not allowed with `cache`, `sandbox` or
|
|
385
|
+
* `persistent.readyWhen`.
|
|
386
|
+
*/
|
|
387
|
+
interactive?: boolean
|
|
365
388
|
/**
|
|
366
389
|
* Confine this task's process: what it may read, write and reach.
|
|
367
390
|
* Nothing is confined unless this is present.
|
package/src/exec/executor.ts
CHANGED
|
@@ -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({
|
package/src/exec/runner.ts
CHANGED
|
@@ -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
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
//
|
|
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).
|
|
508
|
-
//
|
|
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: [
|
|
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).
|
|
718
|
-
//
|
|
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
|
-
//
|
|
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()
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -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, {
|
|
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`)
|
|
@@ -400,6 +417,34 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
400
417
|
}
|
|
401
418
|
}
|
|
402
419
|
}
|
|
420
|
+
const interactive = (exec as { interactive?: unknown }).interactive
|
|
421
|
+
if (interactive !== undefined && typeof interactive !== 'boolean') {
|
|
422
|
+
throw new UserError(`${where}.exec.interactive must be a boolean (or omitted)`)
|
|
423
|
+
}
|
|
424
|
+
if (interactive === true) {
|
|
425
|
+
if (cache !== undefined) {
|
|
426
|
+
throw new UserError(
|
|
427
|
+
`${where}: \`cache\` is not allowed on an interactive task — what it does ` +
|
|
428
|
+
`depends on what is typed, which no key holds`,
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
// bwrap's --new-session keeps the terminal usable (probed), but the
|
|
432
|
+
// sandboxed spawn pipes the task's streams: its stderr is read for
|
|
433
|
+
// the tracer's own errors (sandbox-runtime.ts).
|
|
434
|
+
if ((exec as { sandbox?: unknown }).sandbox !== undefined) {
|
|
435
|
+
throw new UserError(
|
|
436
|
+
`${where}: \`sandbox\` is not allowed on an interactive task — a sandboxed ` +
|
|
437
|
+
`task's output passes through vx, and an interactive one writes to the terminal`,
|
|
438
|
+
)
|
|
439
|
+
}
|
|
440
|
+
const ready = (exec as { persistent?: { readyWhen?: unknown } }).persistent
|
|
441
|
+
if (typeof ready === 'object' && ready !== null && ready.readyWhen !== undefined) {
|
|
442
|
+
throw new UserError(
|
|
443
|
+
`${where}: \`persistent.readyWhen\` is not allowed on an interactive task — ` +
|
|
444
|
+
`its output goes to the terminal, so vx reads none of it; it is ready once spawned`,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
403
448
|
const persistent = (exec as { persistent?: unknown }).persistent
|
|
404
449
|
if (persistent !== undefined) {
|
|
405
450
|
if (typeof persistent !== 'object' || persistent === null) {
|
|
@@ -691,6 +736,7 @@ const EXEC_FIELDS = new Set([
|
|
|
691
736
|
'timeout',
|
|
692
737
|
'retries',
|
|
693
738
|
'persistent',
|
|
739
|
+
'interactive',
|
|
694
740
|
'remote',
|
|
695
741
|
'sandbox',
|
|
696
742
|
])
|
|
@@ -767,8 +813,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
767
813
|
outputLogs: 'the `--output-logs` flag of `vx run`',
|
|
768
814
|
// Turbo's `interactive` / `with`, Nx's `cwd` / `parallelism` /
|
|
769
815
|
// `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`)',
|
|
816
|
+
interactive: '`exec.interactive: true`',
|
|
772
817
|
with: '`dependsOn` on each task it runs beside: a persistent one stays up, and this task starts once it is ready',
|
|
773
818
|
cwd: '`cd <dir> && …` in `exec.command`: a task runs in its project directory',
|
|
774
819
|
parallelism:
|
|
@@ -789,8 +834,6 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
789
834
|
commands: 'one `command` (`a && b`, or `a & b; wait` to run them at once), or one task each',
|
|
790
835
|
parallel: 'one task each, which vx runs at once, or `a & b; wait` in `command`',
|
|
791
836
|
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
837
|
},
|
|
795
838
|
],
|
|
796
839
|
// wireit's `files` / `output` and include-style spellings written into
|
package/src/workspace/index.ts
CHANGED
|
@@ -34,7 +34,12 @@ export {
|
|
|
34
34
|
loadWorkspaceConfig,
|
|
35
35
|
WORKSPACE_CONFIG_FILENAMES,
|
|
36
36
|
} from './project-loader.js'
|
|
37
|
-
export {
|
|
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,
|