@vzn/vx 0.0.641 → 0.0.643
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 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +85 -11
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +12 -3
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +4 -6
- package/src/cache/layered-cache.ts +96 -10
- package/src/cache/output-index.ts +25 -4
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +25 -8
- package/src/cache/run-store.ts +4 -1
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +74 -237
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +6 -8
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +14 -15
- package/src/cli/select.ts +9 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +14 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +5 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +38 -4
- package/src/orchestrator/plugin.ts +49 -5
- package/src/orchestrator/prepare.ts +141 -22
- package/src/orchestrator/projects.ts +25 -6
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +38 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +52 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +8 -2
- package/src/settings.ts +11 -11
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +182 -75
- package/src/workspace/config-schema.ts +81 -19
- package/src/workspace/index.ts +3 -0
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
// closes at the bottom of execution, `planRun()` does so via
|
|
8
8
|
// try/finally around its plan() call.
|
|
9
9
|
|
|
10
|
+
import { existsSync } from 'node:fs'
|
|
10
11
|
import path from 'node:path'
|
|
11
12
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
|
-
import { beginRun, mark, nearest, UserError } from '../util/index.js'
|
|
13
|
+
import { beginRun, mark, nearest, relPosix, type SecretBroker, UserError } from '../util/index.js'
|
|
13
14
|
import {
|
|
14
15
|
Cache,
|
|
15
16
|
type CacheLayer,
|
|
@@ -25,7 +26,7 @@ import {
|
|
|
25
26
|
writesRunStore,
|
|
26
27
|
type PluginCache,
|
|
27
28
|
type ResolvedRemote,
|
|
28
|
-
|
|
29
|
+
BranchScope,
|
|
29
30
|
applyGitEnumeration,
|
|
30
31
|
gitPathspecs,
|
|
31
32
|
MAX_SCOPED_PATHSPECS,
|
|
@@ -37,9 +38,11 @@ import {
|
|
|
37
38
|
buildPackageGraph,
|
|
38
39
|
computeNestedProjectDirs,
|
|
39
40
|
findWorkspaceRoot,
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
type AffectedChanges,
|
|
42
|
+
branchChanges,
|
|
43
|
+
configuredBaseBranch,
|
|
42
44
|
type LoadReads,
|
|
45
|
+
PROJECT_CONFIG_FILENAMES,
|
|
43
46
|
readClaimedFiles,
|
|
44
47
|
resolveCacheDir,
|
|
45
48
|
resolveStoreRoot,
|
|
@@ -76,13 +79,13 @@ import {
|
|
|
76
79
|
lockReader,
|
|
77
80
|
type LoadedProjects,
|
|
78
81
|
} from './projects.js'
|
|
79
|
-
import { keptByAffected } from './affected-tasks.js'
|
|
82
|
+
import { keptByAffected, keyedByChange } from './affected-tasks.js'
|
|
80
83
|
import { applyGraphStage } from './graph-stage.js'
|
|
81
84
|
import { keyExcludedDependencies } from './excluded-keys.js'
|
|
82
85
|
import { FingerprintWatch } from './fingerprint-watch.js'
|
|
83
86
|
import type { VxPlugin } from './plugin.js'
|
|
84
87
|
import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
|
|
85
|
-
import { currentBranch } from './run-context.js'
|
|
88
|
+
import { currentBranch, resolveCacheScope } from './run-context.js'
|
|
86
89
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
87
90
|
import { type Logger, warnOf } from '../report/index.js'
|
|
88
91
|
import type { RunOptions } from './options.js'
|
|
@@ -111,6 +114,10 @@ export interface PreparedRun {
|
|
|
111
114
|
hasRemoteLayer: boolean
|
|
112
115
|
/** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
|
|
113
116
|
cachePolicy: CachePolicy
|
|
117
|
+
/** The scope this run's cache reads and writes, when one is set; named on the summary's `info` row. */
|
|
118
|
+
cacheScope: string | undefined
|
|
119
|
+
/** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
|
|
120
|
+
remotes: ResolvedRemote[]
|
|
114
121
|
/**
|
|
115
122
|
* Scheduling priorities from plugins' `schedule` stage (task id → weight,
|
|
116
123
|
* merged over the structural baseline by the scheduler). Empty when no
|
|
@@ -245,8 +252,21 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
245
252
|
(reused !== undefined ? gitOfDiscovery(reused.projects) : undefined) ??
|
|
246
253
|
lazyGitEnumeration(workspaceRoot)
|
|
247
254
|
if (wholeTree) void git.start()
|
|
248
|
-
const {
|
|
255
|
+
const {
|
|
256
|
+
workspaceConfig,
|
|
257
|
+
plugins,
|
|
258
|
+
branch: branchFacts,
|
|
259
|
+
} = await loadWorkspacePlugins(workspaceRoot, warnOf(log))
|
|
249
260
|
mark('workspace config')
|
|
261
|
+
const baseBranch = configuredBaseBranch(workspaceConfig)
|
|
262
|
+
const cacheScope =
|
|
263
|
+
options.cacheScope ??
|
|
264
|
+
resolveCacheScope(
|
|
265
|
+
workspaceRoot,
|
|
266
|
+
branchFacts.scope,
|
|
267
|
+
plugins.some((p) => p.cache !== undefined),
|
|
268
|
+
baseBranch,
|
|
269
|
+
)
|
|
250
270
|
let learnedBranch: string | null | undefined
|
|
251
271
|
const branch = (): string | null =>
|
|
252
272
|
learnedBranch === undefined
|
|
@@ -265,10 +285,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
265
285
|
// Asked now, read when the cache opens: a repository with no remote is
|
|
266
286
|
// named by its root commit, a git spawn the discovery below overlaps.
|
|
267
287
|
const storeRoot = options.cacheDir ? null : resolveStoreRoot(workspaceRoot, workspaceConfig)
|
|
268
|
-
const
|
|
288
|
+
const discovering =
|
|
269
289
|
reused !== undefined
|
|
270
|
-
? reused.projects
|
|
271
|
-
:
|
|
290
|
+
? Promise.resolve(reused.projects)
|
|
291
|
+
: discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
|
|
292
|
+
// What the branch changed decides which tasks read the scope (owner,
|
|
293
|
+
// 2026-10-17). Only a scoped run's first remote call asks git: a run the
|
|
294
|
+
// local cache answers makes no remote call, and diffing beside discovery
|
|
295
|
+
// cost it 20-60 ms at 1k-5k projects.
|
|
296
|
+
const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
|
|
297
|
+
cacheScope === undefined
|
|
298
|
+
? undefined
|
|
299
|
+
: options.scopeChanges !== undefined
|
|
300
|
+
? async () => options.scopeChanges!
|
|
301
|
+
: () =>
|
|
302
|
+
branchChanges(
|
|
303
|
+
workspaceRoot,
|
|
304
|
+
options.scopeBase ?? branchFacts.base,
|
|
305
|
+
baseBranch,
|
|
306
|
+
discovering,
|
|
307
|
+
{
|
|
308
|
+
untracked: async () => (await git.start()).untracked,
|
|
309
|
+
// A scoped enumeration lists only its projects: null asks git.
|
|
310
|
+
links: async () => {
|
|
311
|
+
const g = await git.start()
|
|
312
|
+
if (!g.scopes.includes('.')) return null
|
|
313
|
+
const out: string[] = []
|
|
314
|
+
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
315
|
+
return out
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
const projectMetas = await discovering
|
|
272
320
|
mark('discover projects')
|
|
273
321
|
|
|
274
322
|
// SCOPED config loading: configs are programs, and evaluating 1090
|
|
@@ -310,12 +358,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
310
358
|
// so a reader profiling that stage was sent to the wrong code.
|
|
311
359
|
mark('package graph')
|
|
312
360
|
|
|
313
|
-
// `--cache-scope`, then `VX_CACHE_SCOPE`, win over the workspace (and `github()`'s guess):
|
|
314
|
-
// a CI job that knows its trust level says so without editing config.
|
|
315
|
-
const envScope = process.env['VX_CACHE_SCOPE'] || undefined
|
|
316
|
-
if (envScope !== undefined && !isCacheScope(envScope)) throw cacheScopeEnvError()
|
|
317
361
|
const workspacePolicy = workspaceCachePolicy(workspaceConfig)
|
|
318
|
-
const cacheScope = options.cacheScope ?? envScope ?? workspaceConfig?.cache?.scope
|
|
319
362
|
const asked =
|
|
320
363
|
options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
|
|
321
364
|
// A laptop reads the shared cache and does not write it unless a policy
|
|
@@ -325,16 +368,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
325
368
|
const readOnly =
|
|
326
369
|
options.remoteReadOnlyByDefault === true &&
|
|
327
370
|
!(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
|
|
328
|
-
const
|
|
371
|
+
const wanted: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
|
|
329
372
|
const localCache = new Cache(
|
|
330
373
|
cacheDir,
|
|
331
|
-
{ read:
|
|
374
|
+
{ read: wanted.localRead, write: wanted.localWrite },
|
|
332
375
|
workspaceRoot,
|
|
333
376
|
options.artifactCeiling,
|
|
334
377
|
'open',
|
|
335
378
|
await storeRoot,
|
|
336
379
|
)
|
|
337
|
-
localCache.
|
|
380
|
+
const unwritable = localCache.readOnlyNotice()
|
|
381
|
+
if (unwritable !== null) warnOf(log)(`[vx] ${unwritable}`)
|
|
382
|
+
const policy: CachePolicy = unwritable === null ? wanted : { ...wanted, localWrite: false }
|
|
338
383
|
// The root files the plugins claim (`VxPlugin.fingerprint`), whose bytes
|
|
339
384
|
// their claimants fold: read now so a task that rewrites one is caught.
|
|
340
385
|
const fingerprintsAt = Date.now()
|
|
@@ -405,6 +450,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
405
450
|
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
406
451
|
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
407
452
|
let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
|
|
453
|
+
refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
|
|
408
454
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
409
455
|
// A name a loaded project declares is no typo, whether or not the load
|
|
410
456
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -463,6 +509,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
463
509
|
// cache plugin.
|
|
464
510
|
let cache: CacheLayer
|
|
465
511
|
const remotes: ResolvedRemote[] = []
|
|
512
|
+
// A scope only picks remote keys (owner, 2026-10-17): the local store
|
|
513
|
+
// keeps every branch's entries under the task keys.
|
|
514
|
+
const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
|
|
466
515
|
if (options.remoteCache) {
|
|
467
516
|
remotes.push({
|
|
468
517
|
remote: options.remoteCache,
|
|
@@ -473,7 +522,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
473
522
|
}
|
|
474
523
|
try {
|
|
475
524
|
cache = options.remoteCache
|
|
476
|
-
? new LayeredCache(localCache, remotes)
|
|
525
|
+
? new LayeredCache(localCache, remotes, branchScope)
|
|
477
526
|
: await resolveCache(plugins, {
|
|
478
527
|
workspaceRoot,
|
|
479
528
|
cacheDir,
|
|
@@ -482,6 +531,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
482
531
|
localCache,
|
|
483
532
|
policy,
|
|
484
533
|
remotes,
|
|
534
|
+
...(branchScope !== undefined ? { branch: branchScope } : {}),
|
|
485
535
|
})
|
|
486
536
|
} catch (err) {
|
|
487
537
|
// A cache plugin that throws or returns something off-contract is
|
|
@@ -492,8 +542,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
492
542
|
localCache.close()
|
|
493
543
|
throw err
|
|
494
544
|
}
|
|
495
|
-
// One scoping rule above every layer, local and remote alike.
|
|
496
|
-
if (cacheScope !== undefined) cache = new ScopedCache(cache, cacheScope)
|
|
497
545
|
// Ask the LAYER, don't infer. Identity against `localCache` answers a
|
|
498
546
|
// DIFFERENT question — "did the plugin hand back something other than the
|
|
499
547
|
// handle I passed in?" — which an ordinary pass-through decorator (a
|
|
@@ -508,7 +556,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
508
556
|
// cycle under `vx watch` (item 1029). run() owns both only once this
|
|
509
557
|
// returns.
|
|
510
558
|
try {
|
|
511
|
-
const hashCache = createHashCache()
|
|
559
|
+
const hashCache = createHashCache(localCache.foldMemo())
|
|
512
560
|
const fingerprintWatch = new FingerprintWatch(
|
|
513
561
|
workspaceRoot,
|
|
514
562
|
claimed,
|
|
@@ -527,6 +575,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
527
575
|
localCache,
|
|
528
576
|
hasRemoteLayer,
|
|
529
577
|
cachePolicy: policy,
|
|
578
|
+
cacheScope,
|
|
579
|
+
remotes,
|
|
530
580
|
keepRunEntry: undefined,
|
|
531
581
|
priorities: () => Promise.resolve(new Map()),
|
|
532
582
|
branch,
|
|
@@ -713,6 +763,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
713
763
|
if (hasHook(plugins, 'key')) {
|
|
714
764
|
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
|
|
715
765
|
}
|
|
766
|
+
// The final inputs decide which tasks the branch changed: they read
|
|
767
|
+
// the scope, every other task the default key.
|
|
768
|
+
if (branchScope !== undefined && scopeChanges !== undefined) {
|
|
769
|
+
branchScope.routeTasks(async () =>
|
|
770
|
+
keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
|
|
771
|
+
)
|
|
772
|
+
}
|
|
716
773
|
refuseFiltersThroughGroups(nodes)
|
|
717
774
|
const exclude = options.excludeDependencies
|
|
718
775
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
@@ -775,6 +832,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
775
832
|
localCache,
|
|
776
833
|
hasRemoteLayer,
|
|
777
834
|
cachePolicy: policy,
|
|
835
|
+
cacheScope,
|
|
836
|
+
remotes,
|
|
778
837
|
priorities,
|
|
779
838
|
branch,
|
|
780
839
|
cacheFor,
|
|
@@ -906,3 +965,63 @@ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): Cache
|
|
|
906
965
|
)
|
|
907
966
|
}
|
|
908
967
|
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* A `project#build` naming the default `build` of a project the lock holds
|
|
971
|
+
* no config for, while its directory holds one, asks for that config's task, not
|
|
972
|
+
* one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
|
|
973
|
+
* renamed task gets "run `vx lock`" (Growth, lock drift).
|
|
974
|
+
*/
|
|
975
|
+
function refuseUnlockedConfig(
|
|
976
|
+
tasks: readonly string[],
|
|
977
|
+
projects: ReadonlyMap<string, ProjectEntry>,
|
|
978
|
+
metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
|
|
979
|
+
workspaceRoot: string,
|
|
980
|
+
): void {
|
|
981
|
+
for (const spec of tasks) {
|
|
982
|
+
const at = spec.indexOf('#')
|
|
983
|
+
if (at < 0) continue
|
|
984
|
+
const name = spec.slice(0, at)
|
|
985
|
+
const entry = projects.get(name)
|
|
986
|
+
if (entry === undefined || !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
|
|
987
|
+
const locked = metas.find((m) => m.name === name)?.configPath
|
|
988
|
+
if (typeof locked === 'string' && locked !== '') continue
|
|
989
|
+
const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(entry.dir, n)))
|
|
990
|
+
if (config === undefined) continue
|
|
991
|
+
throw new UserError(
|
|
992
|
+
`${spec}: ${relPosix(workspaceRoot, path.join(entry.dir, config))} is not in the lock yet — run \`vx lock\``,
|
|
993
|
+
)
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Each remote's `open`, in declared order, for the ones the policy lets read
|
|
999
|
+
* or write. One at a time: two that ask on the terminal must not interleave.
|
|
1000
|
+
* A throw warns through the remote's `onError` (naming its plugin) and
|
|
1001
|
+
* turns that remote off; the run goes on, as after any remote failure.
|
|
1002
|
+
* True when that left the run no remote at all.
|
|
1003
|
+
*/
|
|
1004
|
+
export async function openRemotes(
|
|
1005
|
+
remotes: readonly ResolvedRemote[],
|
|
1006
|
+
ci: boolean,
|
|
1007
|
+
secrets: SecretBroker,
|
|
1008
|
+
): Promise<boolean> {
|
|
1009
|
+
let closed = false
|
|
1010
|
+
for (const entry of remotes) {
|
|
1011
|
+
if (entry.remote.open === undefined || !(entry.read || entry.write)) continue
|
|
1012
|
+
try {
|
|
1013
|
+
await entry.remote.open({
|
|
1014
|
+
ci,
|
|
1015
|
+
warn: (message) => entry.onError?.(new Error(message)),
|
|
1016
|
+
...secrets.for(entry.plugin ?? 'vx'),
|
|
1017
|
+
})
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
entry.read = false
|
|
1020
|
+
entry.write = false
|
|
1021
|
+
const e = err instanceof Error ? err : new Error(String(err))
|
|
1022
|
+
entry.onError?.(e)
|
|
1023
|
+
closed = true
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return closed && !remotes.some((r) => r.read || r.write)
|
|
1027
|
+
}
|
|
@@ -25,6 +25,8 @@ import type { WorkspaceConfig } from '../config.js'
|
|
|
25
25
|
import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
|
|
26
26
|
import { relPosix, UserError } from '../util/index.js'
|
|
27
27
|
import {
|
|
28
|
+
cacheScopeError,
|
|
29
|
+
isCacheScope,
|
|
28
30
|
loadWorkspaceConfig,
|
|
29
31
|
discoveredProject,
|
|
30
32
|
linkDiscoveredEdges,
|
|
@@ -33,7 +35,7 @@ import {
|
|
|
33
35
|
} from '../workspace/index.js'
|
|
34
36
|
import { parseDependencySpec } from '../graph/index.js'
|
|
35
37
|
import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
|
|
36
|
-
import type { VxPlugin } from './plugin.js'
|
|
38
|
+
import type { BranchFacts, VxPlugin } from './plugin.js'
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* The workspace config with the plugin `config` stage applied — the first
|
|
@@ -43,10 +45,16 @@ import type { VxPlugin } from './plugin.js'
|
|
|
43
45
|
export async function loadWorkspacePlugins(
|
|
44
46
|
workspaceRoot: string,
|
|
45
47
|
warn: (message: string) => void,
|
|
46
|
-
): Promise<{
|
|
48
|
+
): Promise<{
|
|
49
|
+
workspaceConfig: WorkspaceConfig | null
|
|
50
|
+
plugins: readonly VxPlugin[]
|
|
51
|
+
branch: BranchFacts
|
|
52
|
+
}> {
|
|
47
53
|
const loaded = await loadWorkspaceConfig(workspaceRoot)
|
|
48
54
|
const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
|
|
49
|
-
|
|
55
|
+
const branch: BranchFacts = {}
|
|
56
|
+
if (loaded === null || !hasHook(plugins, 'config'))
|
|
57
|
+
return { workspaceConfig: loaded, plugins, branch }
|
|
50
58
|
// Bun keeps one module per specifier, so the file's export is one object
|
|
51
59
|
// per process: the CLI's selection pass, the run and each `vx watch`
|
|
52
60
|
// cycle handed the hooks what the last load's hooks had edited
|
|
@@ -60,10 +68,16 @@ export async function loadWorkspacePlugins(
|
|
|
60
68
|
// Re-validated after each plugin, as the `project` stage is: an edit
|
|
61
69
|
// the loader would refuse from a user (`concurrency: -3`) hung the run,
|
|
62
70
|
// and `cacheDir: 42` reached `path.resolve` as a TypeError.
|
|
63
|
-
|
|
71
|
+
const setBranch = (facts: BranchFacts): void => {
|
|
72
|
+
if (facts.scope !== undefined && !isCacheScope(facts.scope))
|
|
73
|
+
throw new UserError(`setBranch: ${cacheScopeError('scope')}`)
|
|
74
|
+
if (facts.scope !== undefined) branch.scope = facts.scope
|
|
75
|
+
if (facts.base !== undefined) branch.base = facts.base
|
|
76
|
+
}
|
|
77
|
+
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) =>
|
|
64
78
|
validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
|
|
65
79
|
)
|
|
66
|
-
return { workspaceConfig, plugins }
|
|
80
|
+
return { workspaceConfig, plugins, branch }
|
|
67
81
|
}
|
|
68
82
|
|
|
69
83
|
/**
|
|
@@ -127,11 +141,16 @@ export async function discoverProjects(
|
|
|
127
141
|
(e) => e.changed,
|
|
128
142
|
() => null,
|
|
129
143
|
)
|
|
144
|
+
const byDir = new Map(projects.map((p) => [p.dir, p]))
|
|
130
145
|
const edges = await applyDiscoverHooks(
|
|
131
146
|
plugins,
|
|
132
147
|
projects,
|
|
133
148
|
{ workspaceRoot, cacheDir, warn, worktreeChanges },
|
|
134
|
-
(entry, plugin) =>
|
|
149
|
+
async (entry, plugin) => {
|
|
150
|
+
const found = await discoveredProject(workspaceRoot, byDir, entry, plugin.name)
|
|
151
|
+
if (found.added) byDir.set(found.meta.dir, found.meta)
|
|
152
|
+
return found
|
|
153
|
+
},
|
|
135
154
|
)
|
|
136
155
|
checkProjectNames(workspaceRoot, projects)
|
|
137
156
|
linkDiscoveredEdges(workspaceRoot, projects, edges)
|
|
@@ -15,6 +15,7 @@ import os from 'node:os'
|
|
|
15
15
|
import path from 'node:path'
|
|
16
16
|
import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
|
|
17
17
|
import { resolveSetting } from '../settings.js'
|
|
18
|
+
import { branchCacheScope, cacheScopeEnvError, isCacheScope } from '../workspace/index.js'
|
|
18
19
|
import { VERSION } from '../version.js'
|
|
19
20
|
import type { RunContextRecord } from './telemetry.js'
|
|
20
21
|
|
|
@@ -525,6 +526,85 @@ export function currentBranch(
|
|
|
525
526
|
return set ?? captureGitContext(workspaceRoot, null, env).branch
|
|
526
527
|
}
|
|
527
528
|
|
|
529
|
+
/**
|
|
530
|
+
* The cache scope a checkout on a branch reads, from the `.git` files
|
|
531
|
+
* alone (owner, 2026-10-17): `<owner>/<repo>:<branch>`, the branch it
|
|
532
|
+
* tracks (`branch.<name>.merge` on `branch.<name>.remote`), else its own
|
|
533
|
+
* name on `origin`, the repository named by that remote's URL. None on
|
|
534
|
+
* the default branch, on a detached HEAD, or with no remote URL. CI writes
|
|
535
|
+
* that scope for the same branch (`github()`), so a laptop reads what its
|
|
536
|
+
* branch's CI saved.
|
|
537
|
+
*/
|
|
538
|
+
export function localBranchScope(
|
|
539
|
+
workspaceRoot: string,
|
|
540
|
+
baseBranch: string | undefined,
|
|
541
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
542
|
+
): string | undefined {
|
|
543
|
+
const head = readHeadDirect(workspaceRoot)
|
|
544
|
+
if (head === null || head.branch === null) return undefined
|
|
545
|
+
const config = gitConfigSections(workspaceRoot)
|
|
546
|
+
const own = config.get(`branch "${head.branch}"`)
|
|
547
|
+
const name = own?.get('merge')?.replace(/^refs\/heads\//, '') ?? head.branch
|
|
548
|
+
const url = config.get(`remote "${own?.get('remote') ?? 'origin'}"`)?.get('url')
|
|
549
|
+
const repo = url === undefined ? undefined : repoOfUrl(url)
|
|
550
|
+
if (repo === undefined) return undefined
|
|
551
|
+
const trunk = baseBranch ?? captureDefaultBranch(env, workspaceRoot)
|
|
552
|
+
if (trunk === null ? name === 'main' || name === 'master' : name === trunk) return undefined
|
|
553
|
+
return branchCacheScope(repo, name)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** `owner/repo` from a remote URL (its last two path segments); undefined for a local path. */
|
|
557
|
+
function repoOfUrl(url: string): string | undefined {
|
|
558
|
+
const parts = normalizeRemoteUrl(url).split('/')
|
|
559
|
+
return parts.length < 3 ? undefined : parts.slice(-2).join('/')
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** `.git/config`'s sections, each a key → value map (last wins); empty when it cannot be read. */
|
|
563
|
+
function gitConfigSections(workspaceRoot: string): Map<string, Map<string, string>> {
|
|
564
|
+
const sections = new Map<string, Map<string, string>>()
|
|
565
|
+
try {
|
|
566
|
+
const dirs = refDirsOf(workspaceRoot)
|
|
567
|
+
if (dirs === null) return sections
|
|
568
|
+
let at: Map<string, string> | undefined
|
|
569
|
+
for (const raw of fs.readFileSync(path.join(dirs.commonDir, 'config'), 'utf8').split('\n')) {
|
|
570
|
+
const line = raw.trim()
|
|
571
|
+
const section = /^\[\s*(\w+)(?:\s+"(.*)")?\s*\]$/.exec(line)
|
|
572
|
+
if (section !== null) {
|
|
573
|
+
const key = section[2] === undefined ? section[1]! : `${section[1]} "${section[2]}"`
|
|
574
|
+
at = sections.get(key) ?? new Map()
|
|
575
|
+
sections.set(key, at)
|
|
576
|
+
continue
|
|
577
|
+
}
|
|
578
|
+
const kv = /^(\w+)\s*=\s*(.*)$/.exec(line)
|
|
579
|
+
if (kv !== null) at?.set(kv[1]!.toLowerCase(), kv[2]!.trim())
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
// no config to read: no remote, so no scope.
|
|
583
|
+
}
|
|
584
|
+
return sections
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* The run's cache scope (owner, 2026-10-17): `VX_CACHE_SCOPE` (empty: none),
|
|
589
|
+
* else a plugin's (`setBranch`, `github()` on Actions), else, with a cache
|
|
590
|
+
* plugin to share it, the branch's from git. Undefined: the default keys.
|
|
591
|
+
*/
|
|
592
|
+
export function resolveCacheScope(
|
|
593
|
+
workspaceRoot: string,
|
|
594
|
+
pluginScope: string | undefined,
|
|
595
|
+
hasRemote: boolean,
|
|
596
|
+
baseBranch: string | undefined,
|
|
597
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
598
|
+
): string | undefined {
|
|
599
|
+
const named = env['VX_CACHE_SCOPE']
|
|
600
|
+
if (named !== undefined) {
|
|
601
|
+
if (named === '') return undefined
|
|
602
|
+
if (!isCacheScope(named)) throw cacheScopeEnvError()
|
|
603
|
+
return named
|
|
604
|
+
}
|
|
605
|
+
return pluginScope ?? (hasRemote ? localBranchScope(workspaceRoot, baseBranch, env) : undefined)
|
|
606
|
+
}
|
|
607
|
+
|
|
528
608
|
export interface TelemetryRunContextArgs {
|
|
529
609
|
runId: string
|
|
530
610
|
/** The invocation, already masked. */
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
isUserError,
|
|
50
50
|
detectColors,
|
|
51
51
|
type ColorSupport,
|
|
52
|
+
SecretBroker,
|
|
52
53
|
} from '../util/index.js'
|
|
53
54
|
import { keyedProjects } from './keyed-projects.js'
|
|
54
55
|
import { isDefaultBuild } from './projects.js'
|
|
@@ -87,7 +88,7 @@ import { isPassStatus } from '../graph/index.js'
|
|
|
87
88
|
import type { RunContextRecord } from './telemetry.js'
|
|
88
89
|
import { plan, type RunPlan } from './plan.js'
|
|
89
90
|
import type { AffectedReason } from './affected-tasks.js'
|
|
90
|
-
import { prepareRun, type PreparedRun } from './prepare.js'
|
|
91
|
+
import { openRemotes, prepareRun, type PreparedRun } from './prepare.js'
|
|
91
92
|
import { acquireRunLock } from './run-lock.js'
|
|
92
93
|
import { terminateChildren } from './signals.js'
|
|
93
94
|
import { runStop } from './run-stop.js'
|
|
@@ -466,6 +467,15 @@ async function runOnBus(
|
|
|
466
467
|
failures: keepRun.failures,
|
|
467
468
|
traffic: prepared.localCache.traffic,
|
|
468
469
|
})
|
|
470
|
+
// Before setup and the status line: a remote's `open` may ask on the terminal.
|
|
471
|
+
const secrets = new SecretBroker(
|
|
472
|
+
ciContext.ci,
|
|
473
|
+
warnOf(log),
|
|
474
|
+
undefined,
|
|
475
|
+
undefined,
|
|
476
|
+
terminal === null ? undefined : () => terminal.holdStatus(),
|
|
477
|
+
)
|
|
478
|
+
const remotesClosed = await openRemotes(prepared.remotes, ciContext.ci, secrets)
|
|
469
479
|
try {
|
|
470
480
|
disposePlugins = await installPlugins({
|
|
471
481
|
plugins: prepared.plugins,
|
|
@@ -476,6 +486,7 @@ async function runOnBus(
|
|
|
476
486
|
cache: prepared.cacheFor,
|
|
477
487
|
ci: ciContext.ci,
|
|
478
488
|
branch: prepared.branch(),
|
|
489
|
+
secrets,
|
|
479
490
|
step,
|
|
480
491
|
})
|
|
481
492
|
} catch (err) {
|
|
@@ -592,6 +603,7 @@ async function runOnBus(
|
|
|
592
603
|
handsTerminal: holders.size > 0,
|
|
593
604
|
teardowns: prepared.plugins.filter((p) => p.teardown).length,
|
|
594
605
|
onStop: () => {
|
|
606
|
+
log.runStop?.()
|
|
595
607
|
stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
|
|
596
608
|
cache.stopUploads?.()
|
|
597
609
|
keeper?.stop()
|
|
@@ -634,13 +646,15 @@ async function runOnBus(
|
|
|
634
646
|
try {
|
|
635
647
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
636
648
|
const endedAtMsAtStart = Date.now()
|
|
637
|
-
|
|
649
|
+
// Every remote turned off by its `open` is no remote: a task must not clean
|
|
650
|
+
// its outputs for a save that goes nowhere.
|
|
651
|
+
const remoteCacheEnabled = prepared.hasRemoteLayer && !remotesClosed
|
|
638
652
|
// Normalised ONCE so every consumer — execute-task, the dedup
|
|
639
653
|
// predicate, the recorded invocation row — reads the policy that
|
|
640
654
|
// actually governed the run. Reading the raw request here made tasks
|
|
641
655
|
// clean their outputs before every exec for a save that never
|
|
642
656
|
// happened.
|
|
643
|
-
const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy,
|
|
657
|
+
const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
|
|
644
658
|
if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
|
|
645
659
|
warnOf(log)(
|
|
646
660
|
'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
|
|
@@ -785,6 +799,7 @@ async function runOnBus(
|
|
|
785
799
|
version: VERSION,
|
|
786
800
|
packageCount: packagesInScope.size,
|
|
787
801
|
remoteCacheEnabled,
|
|
802
|
+
cacheScope: prepared.cacheScope,
|
|
788
803
|
cache: {
|
|
789
804
|
local: policy.localRead || policy.localWrite,
|
|
790
805
|
localWrite: policy.localWrite,
|
|
@@ -794,6 +809,7 @@ async function runOnBus(
|
|
|
794
809
|
},
|
|
795
810
|
concurrency,
|
|
796
811
|
workspaceProjectCount,
|
|
812
|
+
runId: undefined as string | undefined,
|
|
797
813
|
}
|
|
798
814
|
|
|
799
815
|
// Asked only when a plugin declares `output`: a plain run pays nothing.
|
|
@@ -810,6 +826,8 @@ async function runOnBus(
|
|
|
810
826
|
|
|
811
827
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
812
828
|
// (TTY-only); custom loggers may ignore them.
|
|
829
|
+
// The status line starts here: a prompt now would be drawn over.
|
|
830
|
+
secrets.closePrompts()
|
|
813
831
|
log.runStart?.({
|
|
814
832
|
total: taskCount,
|
|
815
833
|
concurrency,
|
|
@@ -817,18 +835,22 @@ async function runOnBus(
|
|
|
817
835
|
context: runContext,
|
|
818
836
|
startedAtMs: endedAtMsAtStart,
|
|
819
837
|
})
|
|
820
|
-
// The live region's
|
|
821
|
-
// region is still up a second in, so a warm run
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
838
|
+
// The live region's forecast: the graph's from the first free row, and
|
|
839
|
+
// history's only if the region is still up a second in, so a warm run
|
|
840
|
+
// never reads history.
|
|
841
|
+
terminal?.forecast(
|
|
842
|
+
() => createForecast(nodes, new Map(), concurrency),
|
|
843
|
+
async () => {
|
|
844
|
+
const ids: string[] = []
|
|
845
|
+
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
846
|
+
const p50s = await new LocalHistoryProvider(
|
|
847
|
+
prepared.localCache.dbHandle(),
|
|
848
|
+
undefined,
|
|
849
|
+
prepared.branch(),
|
|
850
|
+
).p50sFor(ids)
|
|
851
|
+
return createForecast(nodes, p50s, concurrency)
|
|
852
|
+
},
|
|
853
|
+
)
|
|
832
854
|
|
|
833
855
|
// Remote-only: kick off background prefetches so remote-GET latency
|
|
834
856
|
// overlaps execution. Fire-and-forget — execution starts on the next
|
|
@@ -1269,6 +1291,7 @@ async function runOnBus(
|
|
|
1269
1291
|
})
|
|
1270
1292
|
}
|
|
1271
1293
|
mark('record history')
|
|
1294
|
+
runContext.runId = runId
|
|
1272
1295
|
// Drain any still-in-flight background prefetches before closing the
|
|
1273
1296
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
1274
1297
|
// throw. Tasks that resolved as local hits never awaited their
|
|
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
|
|
|
7
7
|
import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
|
-
import type { ExecConfig
|
|
10
|
+
import type { ExecConfig } from '../config.js'
|
|
11
11
|
import {
|
|
12
12
|
bindableWrites,
|
|
13
13
|
initSandbox,
|
|
@@ -289,53 +289,6 @@ export async function sandboxRequestFor(
|
|
|
289
289
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
-
/**
|
|
293
|
-
* Whether a sandboxed task could read `real` (a canonical path inside its
|
|
294
|
-
* project): under one of its read or write grants or its `node_modules`,
|
|
295
|
-
* and not behind a wall. vx packs a symlinked output as its target's
|
|
296
|
-
* bytes, outside the sandbox, so a link the task planted to a file it was
|
|
297
|
-
* denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
|
|
298
|
-
* into the cache wherever creating the link went unseen: seatbelt, or
|
|
299
|
-
* Linux untraced. The grants are matched as written, not expanded: a
|
|
300
|
-
* glob's hit made by the task itself counts.
|
|
301
|
-
*/
|
|
302
|
-
export function sandboxReadable(
|
|
303
|
-
node: TaskNode,
|
|
304
|
-
sandbox: SandboxConfig,
|
|
305
|
-
workspaceRoot: string,
|
|
306
|
-
nested: readonly string[] = [],
|
|
307
|
-
): (real: string) => boolean {
|
|
308
|
-
const projectReal = toRealPath(node.projectDir)
|
|
309
|
-
const at = (p: string): string =>
|
|
310
|
-
p.startsWith('~')
|
|
311
|
-
? path.join(homedir(), p.slice(1))
|
|
312
|
-
: path.isAbsolute(p)
|
|
313
|
-
? p
|
|
314
|
-
: path.join(projectReal, p)
|
|
315
|
-
const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
|
|
316
|
-
const literals = [
|
|
317
|
-
...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
|
|
318
|
-
toRealPath(path.join(node.projectDir, 'node_modules')),
|
|
319
|
-
toRealPath(path.join(workspaceRoot, 'node_modules')),
|
|
320
|
-
]
|
|
321
|
-
const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
|
|
322
|
-
const walls = [
|
|
323
|
-
...nested,
|
|
324
|
-
path.join(workspaceRoot, '.git'),
|
|
325
|
-
path.join(workspaceRoot, '.vx', 'cache'),
|
|
326
|
-
].map(toRealPath)
|
|
327
|
-
return (real) => {
|
|
328
|
-
if (walls.some((w) => atOrUnder(real, w))) return false
|
|
329
|
-
if (literals.some((l) => atOrUnder(real, l))) return true
|
|
330
|
-
// A directory a glob matches is granted whole.
|
|
331
|
-
for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
|
|
332
|
-
if (globs.some((g) => g.match(p))) return true
|
|
333
|
-
if (p === projectReal) break
|
|
334
|
-
}
|
|
335
|
-
return false
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
292
|
/**
|
|
340
293
|
* The sandbox half of the request, plus the empty files vx created so a
|
|
341
294
|
* literal write grant had something to bind (`placeholders`). They are
|