@vzn/vx 0.0.641 → 0.0.642
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 +92 -12
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +6 -2
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +2 -6
- package/src/cache/layered-cache.ts +68 -4
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +3 -1
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +4 -4
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- 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 +10 -0
- 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 +1 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +31 -0
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run.ts +37 -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 +46 -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 +5 -2
- 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 +63 -13
- package/src/workspace/config-schema.ts +37 -3
- 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
|
@@ -381,6 +381,36 @@ export async function claimedAffected(
|
|
|
381
381
|
return names
|
|
382
382
|
}
|
|
383
383
|
|
|
384
|
+
/**
|
|
385
|
+
* The files a claimed file names that its plugin keys too
|
|
386
|
+
* (`FingerprintClaim.extraFiles`); `undefined` when it names none it can
|
|
387
|
+
* answer for, and a plugin that throws is refused by its name.
|
|
388
|
+
*/
|
|
389
|
+
export function claimedExtraFiles(
|
|
390
|
+
plugin: VxPlugin,
|
|
391
|
+
file: string,
|
|
392
|
+
bytes: Uint8Array,
|
|
393
|
+
): readonly string[] | undefined {
|
|
394
|
+
const read = plugin.fingerprint?.extraFiles
|
|
395
|
+
if (read === undefined) return undefined
|
|
396
|
+
let names: unknown
|
|
397
|
+
try {
|
|
398
|
+
names = read.call(plugin.fingerprint, file, bytes)
|
|
399
|
+
} catch (err) {
|
|
400
|
+
throw new UserError(
|
|
401
|
+
maskedLine(
|
|
402
|
+
`plugin '${plugin.name}' failed in fingerprint: ${err instanceof Error ? err.message : String(err)}`,
|
|
403
|
+
),
|
|
404
|
+
)
|
|
405
|
+
}
|
|
406
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
|
|
407
|
+
throw new UserError(
|
|
408
|
+
`plugin '${plugin.name}' failed in fingerprint: extraFiles returned ${describeValue(names)}, not a list of paths`,
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
return names as string[]
|
|
412
|
+
}
|
|
413
|
+
|
|
384
414
|
/** What a run hands the `schedule` stage: a step per plugin, and where refined weights go. */
|
|
385
415
|
export interface ScheduleRun {
|
|
386
416
|
readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
@@ -548,6 +578,7 @@ export async function resolveCache(
|
|
|
548
578
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
549
579
|
const entry: ResolvedRemote = {
|
|
550
580
|
remote,
|
|
581
|
+
plugin: plugin.name,
|
|
551
582
|
read: policy.remoteRead,
|
|
552
583
|
write: policy.remoteWrite,
|
|
553
584
|
onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
|
|
21
21
|
import type { TaskExecutor } from '../exec/index.js'
|
|
22
22
|
import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
|
|
23
|
-
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
23
|
+
import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from '../util/index.js'
|
|
24
24
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
25
25
|
import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
|
|
26
26
|
import type { CiContext } from './run-context.js'
|
|
@@ -285,8 +285,12 @@ interface BaseContext {
|
|
|
285
285
|
warn(message: string): void
|
|
286
286
|
}
|
|
287
287
|
|
|
288
|
-
/**
|
|
289
|
-
|
|
288
|
+
/**
|
|
289
|
+
* What `setup` receives: the run's lifecycle, observe-only, and the
|
|
290
|
+
* plugin's keychain (`secret`, `forgetSecret`): setup runs before the
|
|
291
|
+
* status line, so a prompt there is seen.
|
|
292
|
+
*/
|
|
293
|
+
export interface PluginSetupContext extends BaseContext, PluginSecrets {
|
|
290
294
|
/** This plugin's entries in the workspace's cache (`PluginCache`). */
|
|
291
295
|
readonly cache: PluginCache
|
|
292
296
|
/** The run is on CI (`CI` set, or a provider core detects). */
|
|
@@ -419,6 +423,15 @@ export interface FingerprintClaim {
|
|
|
419
423
|
change: FingerprintChange,
|
|
420
424
|
ctx: FingerprintContext,
|
|
421
425
|
): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
|
|
426
|
+
/**
|
|
427
|
+
* The workspace-relative files a claimed file's bytes name that the
|
|
428
|
+
* plugin's key reads too: `bun.lock`'s patch files, which it names by
|
|
429
|
+
* path and hashes nowhere, or a `file:` dependency's directory. A change
|
|
430
|
+
* to one, or under one, asks `affected` about the file naming it, with
|
|
431
|
+
* `change.extras`. Without it, a `bun.lock` patch edit selects every
|
|
432
|
+
* project.
|
|
433
|
+
*/
|
|
434
|
+
extraFiles?(file: string, bytes: Uint8Array): readonly string[]
|
|
422
435
|
}
|
|
423
436
|
|
|
424
437
|
export interface FingerprintChange {
|
|
@@ -426,6 +439,15 @@ export interface FingerprintChange {
|
|
|
426
439
|
readonly file: string
|
|
427
440
|
readonly before: Uint8Array | null
|
|
428
441
|
readonly after: Uint8Array | null
|
|
442
|
+
/**
|
|
443
|
+
* Each changed `extraFiles` entry, as the claimant names it, at the base
|
|
444
|
+
* ref and in the working tree. A directory's sides are not its bytes:
|
|
445
|
+
* they differ because a file under it changed.
|
|
446
|
+
*/
|
|
447
|
+
readonly extras?: ReadonlyMap<
|
|
448
|
+
string,
|
|
449
|
+
{ readonly before: Uint8Array | null; readonly after: Uint8Array | null }
|
|
450
|
+
>
|
|
429
451
|
}
|
|
430
452
|
|
|
431
453
|
export interface FingerprintContext extends BaseContext {
|
|
@@ -695,6 +717,8 @@ export interface InstallPluginsArgs {
|
|
|
695
717
|
ci?: boolean
|
|
696
718
|
/** Default null. */
|
|
697
719
|
branch?: string | null
|
|
720
|
+
/** The run's secrets; default: the OS keychain and this terminal. */
|
|
721
|
+
secrets?: SecretBroker
|
|
698
722
|
/** Each setup as the step `<plugin>:setup`; default: untimed. */
|
|
699
723
|
step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
700
724
|
}
|
|
@@ -726,6 +750,7 @@ export class PluginSetupError extends UserError {
|
|
|
726
750
|
export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
|
|
727
751
|
const { plugins, bus, workspaceRoot, cacheDir } = args
|
|
728
752
|
const warn = args.warn ?? ((m) => console.error(m))
|
|
753
|
+
const secrets = args.secrets ?? new SecretBroker(args.ci ?? false, warn)
|
|
729
754
|
const disposers: Array<() => void> = []
|
|
730
755
|
// By object, not name: one package can export two plugins.
|
|
731
756
|
const disabled = new Set<Plugin>()
|
|
@@ -754,6 +779,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
754
779
|
cacheDir,
|
|
755
780
|
warn,
|
|
756
781
|
cache: args.cache?.(plugin.name) ?? absentPluginCache,
|
|
782
|
+
...secrets.for(plugin.name),
|
|
757
783
|
ci: args.ci ?? false,
|
|
758
784
|
branch: args.branch ?? null,
|
|
759
785
|
// A subscription made past the hooks leaves with the run like a
|
|
@@ -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,
|
|
@@ -40,6 +41,7 @@ import {
|
|
|
40
41
|
cacheScopeEnvError,
|
|
41
42
|
isCacheScope,
|
|
42
43
|
type LoadReads,
|
|
44
|
+
PROJECT_CONFIG_FILENAMES,
|
|
43
45
|
readClaimedFiles,
|
|
44
46
|
resolveCacheDir,
|
|
45
47
|
resolveStoreRoot,
|
|
@@ -111,6 +113,8 @@ export interface PreparedRun {
|
|
|
111
113
|
hasRemoteLayer: boolean
|
|
112
114
|
/** `--cache-policy` with the workspace's `cacheScope` applied: what the layers were built under. */
|
|
113
115
|
cachePolicy: CachePolicy
|
|
116
|
+
/** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
|
|
117
|
+
remotes: ResolvedRemote[]
|
|
114
118
|
/**
|
|
115
119
|
* Scheduling priorities from plugins' `schedule` stage (task id → weight,
|
|
116
120
|
* merged over the structural baseline by the scheduler). Empty when no
|
|
@@ -325,16 +329,18 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
325
329
|
const readOnly =
|
|
326
330
|
options.remoteReadOnlyByDefault === true &&
|
|
327
331
|
!(options.cache === undefined && namesRemote(workspaceConfig?.cache?.policy))
|
|
328
|
-
const
|
|
332
|
+
const wanted: CachePolicy = readOnly ? { ...asked, remoteWrite: false } : asked
|
|
329
333
|
const localCache = new Cache(
|
|
330
334
|
cacheDir,
|
|
331
|
-
{ read:
|
|
335
|
+
{ read: wanted.localRead, write: wanted.localWrite },
|
|
332
336
|
workspaceRoot,
|
|
333
337
|
options.artifactCeiling,
|
|
334
338
|
'open',
|
|
335
339
|
await storeRoot,
|
|
336
340
|
)
|
|
337
|
-
localCache.
|
|
341
|
+
const unwritable = localCache.readOnlyNotice()
|
|
342
|
+
if (unwritable !== null) warnOf(log)(`[vx] ${unwritable}`)
|
|
343
|
+
const policy: CachePolicy = unwritable === null ? wanted : { ...wanted, localWrite: false }
|
|
338
344
|
// The root files the plugins claim (`VxPlugin.fingerprint`), whose bytes
|
|
339
345
|
// their claimants fold: read now so a task that rewrites one is caught.
|
|
340
346
|
const fingerprintsAt = Date.now()
|
|
@@ -405,6 +411,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
405
411
|
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
406
412
|
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
407
413
|
let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
|
|
414
|
+
refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
|
|
408
415
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
409
416
|
// A name a loaded project declares is no typo, whether or not the load
|
|
410
417
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -508,7 +515,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
508
515
|
// cycle under `vx watch` (item 1029). run() owns both only once this
|
|
509
516
|
// returns.
|
|
510
517
|
try {
|
|
511
|
-
const hashCache = createHashCache()
|
|
518
|
+
const hashCache = createHashCache(localCache.foldMemo())
|
|
512
519
|
const fingerprintWatch = new FingerprintWatch(
|
|
513
520
|
workspaceRoot,
|
|
514
521
|
claimed,
|
|
@@ -527,6 +534,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
527
534
|
localCache,
|
|
528
535
|
hasRemoteLayer,
|
|
529
536
|
cachePolicy: policy,
|
|
537
|
+
remotes,
|
|
530
538
|
keepRunEntry: undefined,
|
|
531
539
|
priorities: () => Promise.resolve(new Map()),
|
|
532
540
|
branch,
|
|
@@ -775,6 +783,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
775
783
|
localCache,
|
|
776
784
|
hasRemoteLayer,
|
|
777
785
|
cachePolicy: policy,
|
|
786
|
+
remotes,
|
|
778
787
|
priorities,
|
|
779
788
|
branch,
|
|
780
789
|
cacheFor,
|
|
@@ -906,3 +915,63 @@ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): Cache
|
|
|
906
915
|
)
|
|
907
916
|
}
|
|
908
917
|
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* A `project#build` naming the default `build` of a project the lock holds
|
|
921
|
+
* no config for, while its directory holds one, asks for that config's task, not
|
|
922
|
+
* one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
|
|
923
|
+
* renamed task gets "run `vx lock`" (Growth, lock drift).
|
|
924
|
+
*/
|
|
925
|
+
function refuseUnlockedConfig(
|
|
926
|
+
tasks: readonly string[],
|
|
927
|
+
projects: ReadonlyMap<string, ProjectEntry>,
|
|
928
|
+
metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
|
|
929
|
+
workspaceRoot: string,
|
|
930
|
+
): void {
|
|
931
|
+
for (const spec of tasks) {
|
|
932
|
+
const at = spec.indexOf('#')
|
|
933
|
+
if (at < 0) continue
|
|
934
|
+
const name = spec.slice(0, at)
|
|
935
|
+
const entry = projects.get(name)
|
|
936
|
+
if (entry === undefined || !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
|
|
937
|
+
const locked = metas.find((m) => m.name === name)?.configPath
|
|
938
|
+
if (typeof locked === 'string' && locked !== '') continue
|
|
939
|
+
const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(entry.dir, n)))
|
|
940
|
+
if (config === undefined) continue
|
|
941
|
+
throw new UserError(
|
|
942
|
+
`${spec}: ${relPosix(workspaceRoot, path.join(entry.dir, config))} is not in the lock yet — run \`vx lock\``,
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Each remote's `open`, in declared order, for the ones the policy lets read
|
|
949
|
+
* or write. One at a time: two that ask on the terminal must not interleave.
|
|
950
|
+
* A throw warns through the remote's `onError` (naming its plugin) and
|
|
951
|
+
* turns that remote off; the run goes on, as after any remote failure.
|
|
952
|
+
* True when that left the run no remote at all.
|
|
953
|
+
*/
|
|
954
|
+
export async function openRemotes(
|
|
955
|
+
remotes: readonly ResolvedRemote[],
|
|
956
|
+
ci: boolean,
|
|
957
|
+
secrets: SecretBroker,
|
|
958
|
+
): Promise<boolean> {
|
|
959
|
+
let closed = false
|
|
960
|
+
for (const entry of remotes) {
|
|
961
|
+
if (entry.remote.open === undefined || !(entry.read || entry.write)) continue
|
|
962
|
+
try {
|
|
963
|
+
await entry.remote.open({
|
|
964
|
+
ci,
|
|
965
|
+
warn: (message) => entry.onError?.(new Error(message)),
|
|
966
|
+
...secrets.for(entry.plugin ?? 'vx'),
|
|
967
|
+
})
|
|
968
|
+
} catch (err) {
|
|
969
|
+
entry.read = false
|
|
970
|
+
entry.write = false
|
|
971
|
+
const e = err instanceof Error ? err : new Error(String(err))
|
|
972
|
+
entry.onError?.(e)
|
|
973
|
+
closed = true
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
return closed && !remotes.some((r) => r.read || r.write)
|
|
977
|
+
}
|
|
@@ -127,11 +127,16 @@ export async function discoverProjects(
|
|
|
127
127
|
(e) => e.changed,
|
|
128
128
|
() => null,
|
|
129
129
|
)
|
|
130
|
+
const byDir = new Map(projects.map((p) => [p.dir, p]))
|
|
130
131
|
const edges = await applyDiscoverHooks(
|
|
131
132
|
plugins,
|
|
132
133
|
projects,
|
|
133
134
|
{ workspaceRoot, cacheDir, warn, worktreeChanges },
|
|
134
|
-
(entry, plugin) =>
|
|
135
|
+
async (entry, plugin) => {
|
|
136
|
+
const found = await discoveredProject(workspaceRoot, byDir, entry, plugin.name)
|
|
137
|
+
if (found.added) byDir.set(found.meta.dir, found.meta)
|
|
138
|
+
return found
|
|
139
|
+
},
|
|
135
140
|
)
|
|
136
141
|
checkProjectNames(workspaceRoot, projects)
|
|
137
142
|
linkDiscoveredEdges(workspaceRoot, projects, edges)
|
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',
|
|
@@ -794,6 +808,7 @@ async function runOnBus(
|
|
|
794
808
|
},
|
|
795
809
|
concurrency,
|
|
796
810
|
workspaceProjectCount,
|
|
811
|
+
runId: undefined as string | undefined,
|
|
797
812
|
}
|
|
798
813
|
|
|
799
814
|
// Asked only when a plugin declares `output`: a plain run pays nothing.
|
|
@@ -810,6 +825,8 @@ async function runOnBus(
|
|
|
810
825
|
|
|
811
826
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
812
827
|
// (TTY-only); custom loggers may ignore them.
|
|
828
|
+
// The status line starts here: a prompt now would be drawn over.
|
|
829
|
+
secrets.closePrompts()
|
|
813
830
|
log.runStart?.({
|
|
814
831
|
total: taskCount,
|
|
815
832
|
concurrency,
|
|
@@ -817,18 +834,22 @@ async function runOnBus(
|
|
|
817
834
|
context: runContext,
|
|
818
835
|
startedAtMs: endedAtMsAtStart,
|
|
819
836
|
})
|
|
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
|
-
|
|
837
|
+
// The live region's forecast: the graph's from the first free row, and
|
|
838
|
+
// history's only if the region is still up a second in, so a warm run
|
|
839
|
+
// never reads history.
|
|
840
|
+
terminal?.forecast(
|
|
841
|
+
() => createForecast(nodes, new Map(), concurrency),
|
|
842
|
+
async () => {
|
|
843
|
+
const ids: string[] = []
|
|
844
|
+
for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
|
|
845
|
+
const p50s = await new LocalHistoryProvider(
|
|
846
|
+
prepared.localCache.dbHandle(),
|
|
847
|
+
undefined,
|
|
848
|
+
prepared.branch(),
|
|
849
|
+
).p50sFor(ids)
|
|
850
|
+
return createForecast(nodes, p50s, concurrency)
|
|
851
|
+
},
|
|
852
|
+
)
|
|
832
853
|
|
|
833
854
|
// Remote-only: kick off background prefetches so remote-GET latency
|
|
834
855
|
// overlaps execution. Fire-and-forget — execution starts on the next
|
|
@@ -1269,6 +1290,7 @@ async function runOnBus(
|
|
|
1269
1290
|
})
|
|
1270
1291
|
}
|
|
1271
1292
|
mark('record history')
|
|
1293
|
+
runContext.runId = runId
|
|
1272
1294
|
// Drain any still-in-flight background prefetches before closing the
|
|
1273
1295
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
1274
1296
|
// 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
|
|
@@ -12,22 +12,29 @@
|
|
|
12
12
|
export interface SaveLane {
|
|
13
13
|
/**
|
|
14
14
|
* Start `save` now if the lane has room, else when one finishes. Resolves
|
|
15
|
-
* once the save has settled
|
|
16
|
-
*
|
|
17
|
-
* same task in another run
|
|
15
|
+
* once the save has settled, true when the entry landed — a failure is
|
|
16
|
+
* reported and resolves false — for the readers that must know: the
|
|
17
|
+
* task's outcome, and a duplicate of the same task in another run
|
|
18
|
+
* (admission's in-flight join).
|
|
18
19
|
*/
|
|
19
|
-
defer(save: () => Promise<void>): Promise<
|
|
20
|
+
defer(save: () => Promise<void>): Promise<boolean>
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
|
|
23
|
-
const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
|
|
24
|
+
const queue: Array<{ save: () => Promise<void>; settle: (stored: boolean) => void }> = []
|
|
24
25
|
let running = 0
|
|
25
|
-
const start = (save: () => Promise<void>, settle: () => void): void => {
|
|
26
|
+
const start = (save: () => Promise<void>, settle: (stored: boolean) => void): void => {
|
|
26
27
|
void save()
|
|
27
|
-
.
|
|
28
|
-
|
|
28
|
+
.then(
|
|
29
|
+
() => true,
|
|
30
|
+
(err: unknown) => {
|
|
31
|
+
onError(err)
|
|
32
|
+
return false
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
.then((stored) => {
|
|
29
36
|
running--
|
|
30
|
-
settle()
|
|
37
|
+
settle(stored)
|
|
31
38
|
const next = queue.shift()
|
|
32
39
|
if (next !== undefined) start(next.save, next.settle)
|
|
33
40
|
})
|
|
@@ -35,7 +42,7 @@ export function createSaveLane(cap: number, onError: (err: unknown) => void): Sa
|
|
|
35
42
|
}
|
|
36
43
|
return {
|
|
37
44
|
defer(save) {
|
|
38
|
-
return new Promise<
|
|
45
|
+
return new Promise<boolean>((settle) => {
|
|
39
46
|
if (running < cap) start(save, settle)
|
|
40
47
|
else queue.push({ save, settle })
|
|
41
48
|
})
|
|
@@ -126,9 +126,11 @@ export function forwardSignals(args: {
|
|
|
126
126
|
const everyChild = (): Child[] => [...args.liveChildren, ...args.persistentRegistry.values()]
|
|
127
127
|
const exit = (signal: StopSignal): never => {
|
|
128
128
|
// The run's own end already flushed the log; a second signal cut it
|
|
129
|
-
// short, so the failure frames it holds print now (idempotent)
|
|
129
|
+
// short, so the failure frames it holds print now (idempotent), and
|
|
130
|
+
// the live region goes so a terminal keeps no frozen one.
|
|
130
131
|
try {
|
|
131
132
|
args.log.runEnd?.()
|
|
133
|
+
args.log.runDone?.()
|
|
132
134
|
} catch {
|
|
133
135
|
// teardown must not throw on the way out
|
|
134
136
|
}
|
|
@@ -158,15 +160,10 @@ export function forwardSignals(args: {
|
|
|
158
160
|
if (stopping !== undefined) exit(stopping)
|
|
159
161
|
stopping = signal
|
|
160
162
|
claimExitForSignal()
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
try {
|
|
166
|
-
args.log.runDone?.()
|
|
167
|
-
} catch {
|
|
168
|
-
// teardown must not throw on the way out
|
|
169
|
-
}
|
|
163
|
+
// The live region stays: the stop marks its rows `stopping` (run.ts's
|
|
164
|
+
// onStop), and the run's own end replaces it with the footer. Cleared
|
|
165
|
+
// at once, it left a blank screen for as long as the stop took (M,
|
|
166
|
+
// 2026-10-17).
|
|
170
167
|
args.stop(signal)
|
|
171
168
|
void settleWithin(args.done, args.boundMs)
|
|
172
169
|
// The run's output may still be in the pipe: `process.exit` drops
|