@vzn/vx 0.0.636 → 0.0.638
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/README.md +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -4,30 +4,30 @@
|
|
|
4
4
|
// plugin `project` stage, which can give tasks to a package that never
|
|
5
5
|
// wrote a `vx.config.ts`.
|
|
6
6
|
|
|
7
|
+
import { stat } from 'node:fs/promises'
|
|
7
8
|
import path from 'node:path'
|
|
8
9
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
9
10
|
import {
|
|
11
|
+
checkProjectNames,
|
|
10
12
|
frozenProjectConfigs,
|
|
11
|
-
loadProjectConfigs,
|
|
12
13
|
LOCKFILE_NAME,
|
|
14
|
+
lockedProjects,
|
|
15
|
+
NO_LOCK,
|
|
16
|
+
pathName,
|
|
17
|
+
readLockfile,
|
|
13
18
|
validateProjectConfig,
|
|
14
|
-
type LoadProjectConfigOptions,
|
|
15
|
-
type LoadReads,
|
|
16
19
|
type Lockfile,
|
|
17
20
|
type PackageGraph,
|
|
18
21
|
type ProjectEntry,
|
|
19
22
|
type ProjectMeta,
|
|
20
|
-
type Workspace,
|
|
21
23
|
} from '../workspace/index.js'
|
|
22
24
|
import type { WorkspaceConfig } from '../config.js'
|
|
23
|
-
import {
|
|
24
|
-
import { UserError } from '../util/index.js'
|
|
25
|
+
import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
|
|
26
|
+
import { relPosix, UserError } from '../util/index.js'
|
|
25
27
|
import {
|
|
26
|
-
computeWorkspaceFingerprint,
|
|
27
|
-
listProjects,
|
|
28
|
-
loadWorkspace,
|
|
29
28
|
loadWorkspaceConfig,
|
|
30
|
-
|
|
29
|
+
discoveredProject,
|
|
30
|
+
linkDiscoveredEdges,
|
|
31
31
|
resolveCacheDir,
|
|
32
32
|
validateWorkspace,
|
|
33
33
|
} from '../workspace/index.js'
|
|
@@ -61,7 +61,7 @@ export async function loadWorkspacePlugins(
|
|
|
61
61
|
// the loader would refuse from a user (`concurrency: -3`) hung the run,
|
|
62
62
|
// and `cacheDir: 42` reached `path.resolve` as a TypeError.
|
|
63
63
|
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
|
|
64
|
-
validateWorkspace(workspaceConfig,
|
|
64
|
+
validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
|
|
65
65
|
)
|
|
66
66
|
return { workspaceConfig, plugins }
|
|
67
67
|
}
|
|
@@ -77,6 +77,15 @@ export function gitOfDiscovery(projects: readonly ProjectMeta[]): LazyGitEnumera
|
|
|
77
77
|
return discoveredGit.get(projects)
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/** The lock a discovery read, by the array it returned: a later config load reads no second copy. */
|
|
81
|
+
const discoveredLock = new WeakMap<readonly ProjectMeta[], () => Promise<Lockfile>>()
|
|
82
|
+
|
|
83
|
+
export function lockOfDiscovery(
|
|
84
|
+
projects: readonly ProjectMeta[],
|
|
85
|
+
): (() => Promise<Lockfile>) | undefined {
|
|
86
|
+
return discoveredLock.get(projects)
|
|
87
|
+
}
|
|
88
|
+
|
|
80
89
|
/**
|
|
81
90
|
* The package graph a selection pass built over a discovery, by that
|
|
82
91
|
* discovery's array, when it folded no task edge in: the graph a run that
|
|
@@ -93,34 +102,40 @@ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: Pack
|
|
|
93
102
|
}
|
|
94
103
|
|
|
95
104
|
/**
|
|
96
|
-
* The workspace's projects:
|
|
97
|
-
*
|
|
105
|
+
* The workspace's projects: the directories the lock records (a run never
|
|
106
|
+
* walks for configs), then the plugin `discover` stage, which may add
|
|
107
|
+
* directories, name path-named ones and give edges. Sorted by name; two
|
|
108
|
+
* projects of one name are refused.
|
|
98
109
|
*/
|
|
99
110
|
export async function discoverProjects(
|
|
100
|
-
|
|
111
|
+
workspaceRoot: string,
|
|
112
|
+
lock: () => Promise<Lockfile>,
|
|
101
113
|
plugins: readonly VxPlugin[],
|
|
102
114
|
cacheDir: string,
|
|
103
115
|
warn: (message: string) => void,
|
|
104
116
|
/** The run's enumeration, which `DiscoverContext.worktreeChanges` reads (and starts). */
|
|
105
|
-
git: LazyGitEnumeration = lazyGitEnumeration(
|
|
117
|
+
git: LazyGitEnumeration = lazyGitEnumeration(workspaceRoot),
|
|
106
118
|
): Promise<ProjectMeta[]> {
|
|
107
|
-
const projects = await
|
|
119
|
+
const projects = lockedProjects(workspaceRoot, (await lock()).projects)
|
|
108
120
|
// Registered whatever the hooks: an `--affected` selection reads its
|
|
109
121
|
// untracked files from this walk, and the run reuses it (I-26).
|
|
110
122
|
discoveredGit.set(projects, git)
|
|
111
|
-
|
|
123
|
+
discoveredLock.set(projects, lock)
|
|
124
|
+
if (!hasHook(plugins, 'discover')) return checkProjectNames(workspaceRoot, projects)
|
|
112
125
|
const worktreeChanges = (): Promise<readonly string[] | null> =>
|
|
113
126
|
git.start().then(
|
|
114
127
|
(e) => e.changed,
|
|
115
128
|
() => null,
|
|
116
129
|
)
|
|
117
|
-
await applyDiscoverHooks(
|
|
130
|
+
const edges = await applyDiscoverHooks(
|
|
118
131
|
plugins,
|
|
119
132
|
projects,
|
|
120
|
-
{ workspaceRoot
|
|
121
|
-
(
|
|
133
|
+
{ workspaceRoot, cacheDir, warn, worktreeChanges },
|
|
134
|
+
(entry, plugin) => discoveredProject(workspaceRoot, projects, entry, plugin.name),
|
|
122
135
|
)
|
|
123
|
-
|
|
136
|
+
checkProjectNames(workspaceRoot, projects)
|
|
137
|
+
linkDiscoveredEdges(workspaceRoot, projects, edges)
|
|
138
|
+
return projects
|
|
124
139
|
}
|
|
125
140
|
|
|
126
141
|
export type LoadProjectsArgs = LoadProjectsBase &
|
|
@@ -142,13 +157,14 @@ interface LoadProjectsBase {
|
|
|
142
157
|
*/
|
|
143
158
|
seeds: 'all' | Iterable<string>
|
|
144
159
|
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
160
|
+
* The lock configs come from, asked only when a config is to be read (a
|
|
161
|
+
* run whose every config the CLI's selection pass staged never parses
|
|
162
|
+
* it). `vx lock` hands the one it is about to write.
|
|
148
163
|
*/
|
|
149
|
-
|
|
150
|
-
evalCache: LoadProjectConfigOptions['evalCache']
|
|
164
|
+
configs: () => Promise<Lockfile>
|
|
151
165
|
warn: (message: string) => void
|
|
166
|
+
/** `vx lock`'s audit: the `project` stage's `ctx.lock`. */
|
|
167
|
+
lock?: boolean
|
|
152
168
|
/**
|
|
153
169
|
* Entries a load in this same process already produced with the same
|
|
154
170
|
* cache dir and lock (the CLI's selection pass, which stages every
|
|
@@ -180,7 +196,8 @@ export interface LoadedProjects {
|
|
|
180
196
|
* enters scope.
|
|
181
197
|
*/
|
|
182
198
|
export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
|
|
183
|
-
const { plugins,
|
|
199
|
+
const { plugins, workspaceRoot } = args
|
|
200
|
+
const lock = args.configs
|
|
184
201
|
const graph = args.closure ? args.packageGraph : null
|
|
185
202
|
// A package with no config file declares no tasks — unless a plugin
|
|
186
203
|
// fills the `project` stage, in which case it is a project the stage may
|
|
@@ -238,27 +255,13 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
238
255
|
// deps by design), so its config has to be pulled in — and that config
|
|
239
256
|
// may declare cross edges of its own. The common case (no cross deps)
|
|
240
257
|
// is a single round, identical to loading the closure in one batch.
|
|
241
|
-
// Turbo spells the root package `//` (`dependsOn: ['//#lint']`)
|
|
242
|
-
// the root project
|
|
243
|
-
// to
|
|
244
|
-
// alike. With no root project it stays,
|
|
258
|
+
// Turbo spells the root package `//` (`dependsOn: ['//#lint']`), as vx
|
|
259
|
+
// names the root project unless something names it; a named root's
|
|
260
|
+
// spelling is rewritten to its name once, here, for the closure, the
|
|
261
|
+
// graph and `cache.inputs.tasks` alike. With no root project it stays,
|
|
262
|
+
// and the graph's refusal says so.
|
|
245
263
|
const root = path.resolve(workspaceRoot)
|
|
246
264
|
const rootName = args.projectMetas.find((m) => path.resolve(m.dir) === root)?.name
|
|
247
|
-
// A project the lock holds whose config file is gone ran nothing under
|
|
248
|
-
// --frozen, green, though the lock promises what runs is what was
|
|
249
|
-
// locked (X-144); a run never even asks such a project for a task.
|
|
250
|
-
// Refused as a moved config is (D-88). With no lock at all, nothing was
|
|
251
|
-
// locked: a repo of config-less projects needs none.
|
|
252
|
-
if (lock) {
|
|
253
|
-
const bare = args.projectMetas.filter((m) => m.configPath === null)
|
|
254
|
-
const held = bare.length === 0 ? null : await lock().catch(() => null)
|
|
255
|
-
const gone = held === null ? undefined : bare.find((m) => Object.hasOwn(held.projects, m.name))
|
|
256
|
-
if (gone !== undefined && held !== null)
|
|
257
|
-
throw new UserError(
|
|
258
|
-
`${LOCKFILE_NAME} locks "${gone.name}" at ${held.projects[gone.name]!.configPath}, but it has no config now — ` +
|
|
259
|
-
`run \`vx lock\` to refresh, or delete ${LOCKFILE_NAME}`,
|
|
260
|
-
)
|
|
261
|
-
}
|
|
262
265
|
const projects = new Map<string, ProjectEntry>()
|
|
263
266
|
while (pending.length > 0) {
|
|
264
267
|
const round = pending.splice(0, pending.length)
|
|
@@ -269,14 +272,30 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
269
272
|
(m): m is ProjectMeta & { configPath: string } =>
|
|
270
273
|
typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
|
|
271
274
|
)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
275
|
+
// A locked project whose config file is gone ran as locked, green,
|
|
276
|
+
// though nothing declares it any more (X-144): asked only of the
|
|
277
|
+
// configs this run reads.
|
|
278
|
+
const present = await Promise.all(withFile.map((m) => Bun.file(m.configPath).exists()))
|
|
279
|
+
const gone = withFile.find((_, i) => !present[i])
|
|
280
|
+
if (gone !== undefined) {
|
|
281
|
+
const rel = relPosix(workspaceRoot, gone.dir) || '.'
|
|
282
|
+
const named = gone.name === pathName(workspaceRoot, gone.dir) ? '' : ` ("${gone.name}")`
|
|
283
|
+
const file = relPosix(gone.dir, gone.configPath)
|
|
284
|
+
// exists() says false for a file behind a directory this user may not
|
|
285
|
+
// search too; asked only on this failure path, so a run pays no stat.
|
|
286
|
+
const denied = await stat(gone.configPath).then(
|
|
287
|
+
() => null,
|
|
288
|
+
(err: NodeJS.ErrnoException) =>
|
|
289
|
+
err.code === 'EACCES' || err.code === 'EPERM' ? err.code : null,
|
|
290
|
+
)
|
|
291
|
+
throw new UserError(
|
|
292
|
+
denied !== null
|
|
293
|
+
? `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`
|
|
294
|
+
: `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
const loaded =
|
|
298
|
+
withFile.length === 0 ? [] : frozenProjectConfigs(await lock(), withFile, workspaceRoot)
|
|
280
299
|
let next = 0
|
|
281
300
|
for (const meta of round) {
|
|
282
301
|
const pre = args.staged?.get(meta.name)
|
|
@@ -305,10 +324,15 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
305
324
|
warn: args.warn,
|
|
306
325
|
name: meta.name,
|
|
307
326
|
dir: meta.dir,
|
|
308
|
-
packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
|
|
309
327
|
projects: args.projectMetas,
|
|
328
|
+
lock: args.lock === true,
|
|
310
329
|
},
|
|
311
|
-
(plugin) =>
|
|
330
|
+
(plugin) =>
|
|
331
|
+
validateProjectConfig(
|
|
332
|
+
config,
|
|
333
|
+
`${where} (after plugin '${plugin.name}')`,
|
|
334
|
+
meta.configPath,
|
|
335
|
+
),
|
|
312
336
|
)
|
|
313
337
|
}
|
|
314
338
|
const built = withDefaultBuild(config)
|
|
@@ -396,57 +420,39 @@ function crossDepProjects(config: ProjectConfig): string[] {
|
|
|
396
420
|
}
|
|
397
421
|
|
|
398
422
|
/**
|
|
399
|
-
* The run path's view of a workspace's projects for a reader —
|
|
400
|
-
*
|
|
401
|
-
* `project` stages
|
|
402
|
-
*
|
|
403
|
-
* is every project or a list of names; no closure, no lock (a reader
|
|
404
|
-
* reads live, as a default run does). Plugin warnings go to `warn`.
|
|
423
|
+
* The run path's view of a workspace's projects for a reader — a plugin's
|
|
424
|
+
* verb, an embedder: the lock, the plugin `config`, `discover` and
|
|
425
|
+
* `project` stages. `scope` is every project or a list of names; no
|
|
426
|
+
* closure. No lock is `NO_LOCK`. Plugin warnings go to `warn`.
|
|
405
427
|
*/
|
|
406
428
|
export async function loadResolvedProjects(
|
|
407
429
|
workspaceRoot: string,
|
|
408
430
|
opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
|
|
409
431
|
): Promise<Map<string, ProjectEntry>> {
|
|
410
432
|
const warn = opts.warn ?? ((): void => {})
|
|
411
|
-
const reads: LoadReads = new Map()
|
|
412
433
|
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
|
|
413
434
|
const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
435
|
+
const lock = lockReader(workspaceRoot)
|
|
436
|
+
const metas = await discoverProjects(workspaceRoot, lock, plugins, cacheDir, warn)
|
|
437
|
+
const loaded = await loadProjects({
|
|
438
|
+
workspaceRoot,
|
|
417
439
|
cacheDir,
|
|
440
|
+
plugins,
|
|
441
|
+
projectMetas: metas,
|
|
442
|
+
seeds: opts.scope ?? 'all',
|
|
443
|
+
closure: false,
|
|
444
|
+
configs: lock,
|
|
418
445
|
warn,
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
workspaceRoot,
|
|
432
|
-
cacheDir,
|
|
433
|
-
plugins,
|
|
434
|
-
projectMetas: metas,
|
|
435
|
-
seeds: opts.scope ?? 'all',
|
|
436
|
-
closure: false,
|
|
437
|
-
lock: null,
|
|
438
|
-
evalCache:
|
|
439
|
-
cache === undefined
|
|
440
|
-
? undefined
|
|
441
|
-
: {
|
|
442
|
-
store: cache,
|
|
443
|
-
workspaceRoot,
|
|
444
|
-
workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot, reads),
|
|
445
|
-
},
|
|
446
|
-
warn,
|
|
447
|
-
})
|
|
448
|
-
return loaded.projects
|
|
449
|
-
} finally {
|
|
450
|
-
cache?.close()
|
|
451
|
-
}
|
|
446
|
+
})
|
|
447
|
+
return loaded.projects
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** The lock, read once on first ask; no lock is `NO_LOCK`. */
|
|
451
|
+
export function lockReader(workspaceRoot: string): () => Promise<Lockfile> {
|
|
452
|
+
let read: Promise<Lockfile> | undefined
|
|
453
|
+
return () =>
|
|
454
|
+
(read ??= readLockfile(workspaceRoot).then((lock) => {
|
|
455
|
+
if (lock === null) throw new UserError(NO_LOCK)
|
|
456
|
+
return lock
|
|
457
|
+
}))
|
|
452
458
|
}
|
|
@@ -12,8 +12,9 @@
|
|
|
12
12
|
// GET per key.
|
|
13
13
|
//
|
|
14
14
|
// HARD SCOPE: this runs ONLY when a remote layer is configured. It
|
|
15
|
-
//
|
|
16
|
-
//
|
|
15
|
+
// asks the local index only which keys it holds (no local get / no
|
|
16
|
+
// isOutputsCurrent), and probes the remote for the rest: a run the local
|
|
17
|
+
// cache answers makes no remote call (M, 2026-10-15). Local-only
|
|
17
18
|
// runs are byte-identical to before; the caller gates on the layer's
|
|
18
19
|
// own `hasRemote`, so a third-party remote layer gets the overlap too.
|
|
19
20
|
// The two batch-probe hooks are OPTIONAL on the contract: a remote layer
|
|
@@ -30,7 +31,8 @@ export interface PrefetchArgs {
|
|
|
30
31
|
/** A layer whose `hasRemote` is true — the caller's gate. */
|
|
31
32
|
cache: CacheLayer
|
|
32
33
|
workspaceRoot: string
|
|
33
|
-
|
|
34
|
+
/** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
|
|
35
|
+
claimedFiles: readonly string[]
|
|
34
36
|
forwardArgs?: readonly string[] | undefined
|
|
35
37
|
nestedDirsByProject: Map<string, string[]>
|
|
36
38
|
gitFilesCache: GitFilesCache
|
|
@@ -74,8 +76,11 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
|
|
|
74
76
|
// would 404. When the remote can't batch (`null` — an older remote, reads
|
|
75
77
|
// disabled, or a layer that doesn't implement the hook at all), fall back to
|
|
76
78
|
// prefetching every stable key, exactly as before.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
const held =
|
|
80
|
+
(await args.cache.localHasMany?.([...new Set(stableKeys.map((k) => k.hash))])) ?? null
|
|
81
|
+
let toPrefetch = held === null ? stableKeys : stableKeys.filter((k) => !held.has(k.hash))
|
|
82
|
+
if (toPrefetch.length === 0) return
|
|
83
|
+
const uniqueHashes = [...new Set(toPrefetch.map((k) => k.hash))]
|
|
79
84
|
const present = (await args.cache.remoteHasMany?.(uniqueHashes)) ?? null
|
|
80
85
|
if (present !== null) {
|
|
81
86
|
args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
|
|
@@ -84,7 +89,7 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
|
|
|
84
89
|
// economy: deleting it alone GETs nothing extra, deleting both GETs
|
|
85
90
|
// every absent key. What it decides is which hashes the pump asks
|
|
86
91
|
// the layer for, and that is what the row pins (item 643, 645).
|
|
87
|
-
toPrefetch =
|
|
92
|
+
toPrefetch = toPrefetch.filter((k) => present.has(k.hash))
|
|
88
93
|
if (toPrefetch.length === 0) return
|
|
89
94
|
}
|
|
90
95
|
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import os from 'node:os'
|
|
15
15
|
import path from 'node:path'
|
|
16
|
-
import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
|
|
16
|
+
import { executablePath, gitEnv, relPosix, ulid, xxh3hex } from '../util/index.js'
|
|
17
|
+
import { resolveSetting } from '../settings.js'
|
|
17
18
|
|
|
18
19
|
export interface GitContext {
|
|
19
20
|
/** `git rev-parse HEAD`, or null outside a repo / on failure. */
|
|
@@ -86,6 +87,7 @@ export function captureGitContext(
|
|
|
86
87
|
'--abbrev-ref',
|
|
87
88
|
'HEAD',
|
|
88
89
|
],
|
|
90
|
+
env: gitEnv(),
|
|
89
91
|
stdout: 'pipe',
|
|
90
92
|
stderr: 'ignore',
|
|
91
93
|
})
|
|
@@ -280,6 +282,7 @@ export function captureDefaultBranch(
|
|
|
280
282
|
'--short',
|
|
281
283
|
'refs/remotes/origin/HEAD',
|
|
282
284
|
],
|
|
285
|
+
env: gitEnv(),
|
|
283
286
|
stdout: 'pipe',
|
|
284
287
|
stderr: 'ignore',
|
|
285
288
|
})
|
|
@@ -445,10 +448,10 @@ export function normalizeRemoteUrl(raw: string): string {
|
|
|
445
448
|
* (telemetry schema v2). Derivation ladder:
|
|
446
449
|
* 1. git remote origin URL, normalized → xxh3 (same id from any
|
|
447
450
|
* machine's checkout of the same repo);
|
|
448
|
-
* 2. no remote → a salt persisted at `<root>/.vx/workspace-id`
|
|
449
|
-
* checkout keeps a stable identity across runs; `.vx
|
|
450
|
-
*
|
|
451
|
-
* 3. unwritable `.vx
|
|
451
|
+
* 2. no remote → a salt persisted at `<root>/.vx/cache/workspace-id`
|
|
452
|
+
* (the checkout keeps a stable identity across runs; `.vx/cache` holds
|
|
453
|
+
* the machine-local state, never committed);
|
|
454
|
+
* 3. unwritable `.vx/cache` → the root path itself (stable per machine).
|
|
452
455
|
* The URL comes from the run's own `git var -l` when it has one, else one
|
|
453
456
|
* `git` spawn behind try/catch; call sites gate on telemetry being active
|
|
454
457
|
* so a plain run never pays it. Never throws.
|
|
@@ -473,6 +476,7 @@ export function captureWorkspaceIdentity(
|
|
|
473
476
|
// different proxies would derive different workspace ids. The raw
|
|
474
477
|
// configured URL is the identity.
|
|
475
478
|
cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
|
|
479
|
+
env: gitEnv(),
|
|
476
480
|
stdout: 'pipe',
|
|
477
481
|
stderr: 'ignore',
|
|
478
482
|
})
|
|
@@ -488,7 +492,7 @@ export function captureWorkspaceIdentity(
|
|
|
488
492
|
// git unavailable — fall through to the salt.
|
|
489
493
|
}
|
|
490
494
|
try {
|
|
491
|
-
const saltPath = path.join(workspaceRoot, '.vx', 'workspace-id')
|
|
495
|
+
const saltPath = path.join(workspaceRoot, '.vx', 'cache', 'workspace-id')
|
|
492
496
|
let salt: string
|
|
493
497
|
try {
|
|
494
498
|
salt = fs.readFileSync(saltPath, 'utf8').trim()
|
|
@@ -503,3 +507,18 @@ export function captureWorkspaceIdentity(
|
|
|
503
507
|
return { id: xxh3hex(workspaceRoot), name: base, ...where }
|
|
504
508
|
}
|
|
505
509
|
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* The run's branch: `run.branch` (the flag given, else `VX_RUN_BRANCH`,
|
|
513
|
+
* else the workspace's), else HEAD's, else the CI provider's for a
|
|
514
|
+
* detached checkout. What run history records and a forecast reads first.
|
|
515
|
+
*/
|
|
516
|
+
export function currentBranch(
|
|
517
|
+
workspaceRoot: string,
|
|
518
|
+
workspace?: unknown,
|
|
519
|
+
flag?: string,
|
|
520
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
521
|
+
): string | null {
|
|
522
|
+
const set = resolveSetting('run.branch', workspace, flag, env).value as string | undefined
|
|
523
|
+
return set ?? captureGitContext(workspaceRoot, null, env).branch
|
|
524
|
+
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// A failed run's task output and the files it names, so an agent reads why
|
|
2
|
-
// a task failed without the terminal (`vx last --format json`,
|
|
3
|
-
// getFailures). One file per failed run, `<cacheDir>/failures/<runId>.json`,
|
|
2
|
+
// a task failed without the terminal (`vx last --format json`). One file per failed run, `<cacheDir>/failures/<runId>.json`,
|
|
4
3
|
// the newest 50 kept: files, not cache.db, so the index layout (and every
|
|
5
4
|
// shared store under it) stays as it was.
|
|
6
5
|
|
|
@@ -21,6 +20,8 @@ export interface TaskFailure {
|
|
|
21
20
|
taskId: string
|
|
22
21
|
exitCode: number
|
|
23
22
|
timedOut?: true
|
|
23
|
+
/** The input that changed while the command ran (see `TaskOutcome`). */
|
|
24
|
+
inputMoved?: string
|
|
24
25
|
/** Plain text, bounded, secrets masked; '' when the run kept none. */
|
|
25
26
|
output: string
|
|
26
27
|
locations: OutputLocation[]
|
|
@@ -47,18 +48,25 @@ function failuresOf(outcomes: readonly TaskOutcome[]): StoredFailure[] {
|
|
|
47
48
|
const isFile = fileMemo()
|
|
48
49
|
const out: StoredFailure[] = []
|
|
49
50
|
for (const o of outcomes) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
// A task failed by a moved input may have printed nothing: the input
|
|
52
|
+
// is still why, and an agent reads it here.
|
|
53
|
+
if ((o.failedOutput === undefined && o.inputMoved === undefined) || isGroupTask(o.node))
|
|
54
|
+
continue
|
|
55
|
+
const output =
|
|
56
|
+
o.failedOutput === undefined
|
|
57
|
+
? ''
|
|
58
|
+
: Bun.stripANSI(
|
|
59
|
+
decodeOutputLog(o.failedOutput)
|
|
60
|
+
.map((c) => c.text)
|
|
61
|
+
.join(''),
|
|
62
|
+
)
|
|
56
63
|
out.push({
|
|
57
64
|
taskId: o.node.id,
|
|
58
65
|
project: o.node.projectName,
|
|
59
66
|
task: o.node.taskName,
|
|
60
67
|
exitCode: o.exitCode,
|
|
61
68
|
...(o.timedOut === true ? { timedOut: true as const } : {}),
|
|
69
|
+
...(o.inputMoved !== undefined ? { inputMoved: o.inputMoved } : {}),
|
|
62
70
|
output,
|
|
63
71
|
locations: fileLocations(output, o.node.projectDir, isFile),
|
|
64
72
|
...(o.sandboxFix !== undefined ? { sandboxFix: o.sandboxFix } : {}),
|
|
@@ -138,6 +146,7 @@ export function runFailures(cacheDir: string, db: Database, runId?: string): Run
|
|
|
138
146
|
taskId,
|
|
139
147
|
exitCode: r.exitCode,
|
|
140
148
|
...(r.timedOut === 1 ? { timedOut: true as const } : {}),
|
|
149
|
+
...(k?.inputMoved !== undefined ? { inputMoved: k.inputMoved } : {}),
|
|
141
150
|
output: k?.output ?? '',
|
|
142
151
|
locations: k?.locations ?? [],
|
|
143
152
|
...(k?.sandboxFix !== undefined ? { sandboxFix: k.sandboxFix } : {}),
|
|
@@ -330,7 +330,7 @@ export async function acquireRunLock(
|
|
|
330
330
|
opts: RunLockOptions,
|
|
331
331
|
): Promise<() => Promise<void>> {
|
|
332
332
|
const lockDir = runLockPath(workspaceRoot, opts.dir)
|
|
333
|
-
// A task's own process (`vx cache
|
|
333
|
+
// A task's own process (`vx cache clean` in a task) would wait for the
|
|
334
334
|
// run that started it, which holds the lock until that task ends.
|
|
335
335
|
const outer = process.env[VX_RUN_WORKSPACE_ENV]
|
|
336
336
|
if (outer !== undefined && runLockPath(outer, opts.dir) === lockDir) {
|
|
@@ -27,7 +27,7 @@ export interface RunRecordsInput {
|
|
|
27
27
|
ok: boolean
|
|
28
28
|
command: string
|
|
29
29
|
requestedTasks: readonly string[]
|
|
30
|
-
/** Already
|
|
30
|
+
/** Already formatted (`formatCachePolicy`), the form the header row stores. */
|
|
31
31
|
cachePolicy: string
|
|
32
32
|
concurrency: number
|
|
33
33
|
flow: InvocationRecord['flow']
|
|
@@ -82,7 +82,7 @@ function statusWord(o: OutcomeView): string {
|
|
|
82
82
|
case 'cache-hit-remote':
|
|
83
83
|
return 'success'
|
|
84
84
|
case 'failed':
|
|
85
|
-
return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
|
|
85
|
+
return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady, o.inputMoved)
|
|
86
86
|
case 'skipped':
|
|
87
87
|
return skippedLabel(o.blockedBy)
|
|
88
88
|
case 'aborted':
|
|
@@ -144,8 +144,13 @@ const cell = escapeMarkdownCell
|
|
|
144
144
|
export function formatRunReportMarkdown(result: RunResult): string {
|
|
145
145
|
const t = tally(result.outcomes)
|
|
146
146
|
const cached = t.cachedLocal + t.cachedRemote
|
|
147
|
+
// The terminal summary's words and counts: every task in the run counts
|
|
148
|
+
// (M, 2026-10-14), and a task the stop reached before it ran was never
|
|
149
|
+
// aborted.
|
|
150
|
+
const aborted = t.aborted - t.notRun
|
|
151
|
+
const count = t.total + aborted + t.notRun
|
|
147
152
|
const parts = [
|
|
148
|
-
`**${
|
|
153
|
+
`**${count} task${count === 1 ? '' : 's'}**`,
|
|
149
154
|
`${t.successful} success`,
|
|
150
155
|
`${t.failed} failed`,
|
|
151
156
|
// What the hits did to the disk, as the run's summary splits them.
|
|
@@ -154,15 +159,10 @@ export function formatRunReportMarkdown(result: RunResult): string {
|
|
|
154
159
|
: '0 cached',
|
|
155
160
|
]
|
|
156
161
|
if (t.skipped > 0) parts.push(`${t.skipped} skipped`)
|
|
162
|
+
if (aborted > 0) parts.push(`${aborted} aborted`)
|
|
163
|
+
if (t.notRun > 0) parts.push(`${t.notRun} not run`)
|
|
157
164
|
parts.push(`${fmtDuration(t.executedMs)} total`)
|
|
158
165
|
if (t.savedMs > 0) parts.push(`${fmtDuration(t.savedMs)} saved`)
|
|
159
|
-
// The terminal summary's words: neither is in `total`, and a task the stop
|
|
160
|
-
// reached before it ran was never aborted.
|
|
161
|
-
const aborted = t.aborted - t.notRun
|
|
162
|
-
const uncounted: string[] = []
|
|
163
|
-
if (aborted > 0) uncounted.push(`${aborted} aborted`)
|
|
164
|
-
if (t.notRun > 0) uncounted.push(`${t.notRun} not run`)
|
|
165
|
-
if (uncounted.length > 0) parts.push(`not counted: ${uncounted.join(', ')}`)
|
|
166
166
|
|
|
167
167
|
// Only a stop (a signal, `RunOptions.signal`) makes an `aborted` outcome,
|
|
168
168
|
// so one is how an interrupted run tells itself from a failed one.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// A run's run-level settings, resolved once before any task: the flag
|
|
2
|
+
// (a RunOptions field) beats its variable, which beats `.vx/workspace`
|
|
3
|
+
// (src/settings.ts). A task's own `exec.timeout` / `exec.retries` is
|
|
4
|
+
// applied where the task runs, above all three.
|
|
5
|
+
|
|
6
|
+
import type { WorkspaceConfig } from '../config.js'
|
|
7
|
+
import { resolveSetting } from '../settings.js'
|
|
8
|
+
import { setSettleBounds, type SettleBound } from '../util/index.js'
|
|
9
|
+
import type { RunOptions } from './options.js'
|
|
10
|
+
|
|
11
|
+
const FROM_OPTIONS = {
|
|
12
|
+
concurrency: 'run.concurrency',
|
|
13
|
+
schedule: 'run.schedule',
|
|
14
|
+
timeout: 'run.timeout',
|
|
15
|
+
retries: 'run.retries',
|
|
16
|
+
continueMode: 'run.continue',
|
|
17
|
+
readyNotice: 'run.readyNotice',
|
|
18
|
+
download: 'cache.download',
|
|
19
|
+
} as const satisfies Partial<Record<keyof RunOptions, string>>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `options` with each run-level setting it leaves unset filled from the
|
|
23
|
+
* environment, then the workspace. Arms the settle bounds (`util/settle`,
|
|
24
|
+
* below settings) with the flag's or workspace's `run.killGrace` and
|
|
25
|
+
* `run.teardownTimeout`: the variable sits between them, read per call.
|
|
26
|
+
*/
|
|
27
|
+
export function withRunSettings(
|
|
28
|
+
options: RunOptions,
|
|
29
|
+
workspace: WorkspaceConfig | null | undefined,
|
|
30
|
+
): RunOptions {
|
|
31
|
+
const out: Record<string, unknown> = { ...options }
|
|
32
|
+
for (const [field, path] of Object.entries(FROM_OPTIONS)) {
|
|
33
|
+
const { value } = resolveSetting(path, workspace, options[field as keyof RunOptions])
|
|
34
|
+
if (value !== undefined) out[field] = value
|
|
35
|
+
}
|
|
36
|
+
const bound = (flag: number | undefined, path: string): SettleBound | undefined => {
|
|
37
|
+
if (flag !== undefined) return { ms: flag, from: 'flag' }
|
|
38
|
+
const { value } = resolveSetting(path, workspace, undefined, {})
|
|
39
|
+
return value === undefined ? undefined : { ms: value as number, from: 'workspace' }
|
|
40
|
+
}
|
|
41
|
+
const killGrace = bound(options.killGrace, 'run.killGrace')
|
|
42
|
+
const teardown = bound(options.teardownTimeout, 'run.teardownTimeout')
|
|
43
|
+
setSettleBounds({
|
|
44
|
+
...(killGrace !== undefined ? { killGrace } : {}),
|
|
45
|
+
...(teardown !== undefined ? { teardown } : {}),
|
|
46
|
+
})
|
|
47
|
+
return out as unknown as RunOptions
|
|
48
|
+
}
|