@vzn/vx 0.0.635 → 0.0.637
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 +47 -19
- package/docs/caching.md +222 -210
- package/docs/cli.md +699 -664
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +31 -32
- package/docs/schema.md +418 -202
- 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 +2 -2
- package/schemas/show.json +13 -4
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -61
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +123 -144
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +191 -71
- 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 +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +25 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- 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 +192 -70
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +43 -28
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +114 -73
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- 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-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +127 -88
- 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 +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -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/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +251 -241
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +22 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- 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 +362 -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,12 +157,11 @@ 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
|
|
152
166
|
/**
|
|
153
167
|
* Entries a load in this same process already produced with the same
|
|
@@ -180,7 +194,8 @@ export interface LoadedProjects {
|
|
|
180
194
|
* enters scope.
|
|
181
195
|
*/
|
|
182
196
|
export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjects> {
|
|
183
|
-
const { plugins,
|
|
197
|
+
const { plugins, workspaceRoot } = args
|
|
198
|
+
const lock = args.configs
|
|
184
199
|
const graph = args.closure ? args.packageGraph : null
|
|
185
200
|
// A package with no config file declares no tasks — unless a plugin
|
|
186
201
|
// fills the `project` stage, in which case it is a project the stage may
|
|
@@ -238,27 +253,13 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
238
253
|
// deps by design), so its config has to be pulled in — and that config
|
|
239
254
|
// may declare cross edges of its own. The common case (no cross deps)
|
|
240
255
|
// 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,
|
|
256
|
+
// Turbo spells the root package `//` (`dependsOn: ['//#lint']`), as vx
|
|
257
|
+
// names the root project unless something names it; a named root's
|
|
258
|
+
// spelling is rewritten to its name once, here, for the closure, the
|
|
259
|
+
// graph and `cache.inputs.tasks` alike. With no root project it stays,
|
|
260
|
+
// and the graph's refusal says so.
|
|
245
261
|
const root = path.resolve(workspaceRoot)
|
|
246
262
|
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
263
|
const projects = new Map<string, ProjectEntry>()
|
|
263
264
|
while (pending.length > 0) {
|
|
264
265
|
const round = pending.splice(0, pending.length)
|
|
@@ -269,14 +270,30 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
269
270
|
(m): m is ProjectMeta & { configPath: string } =>
|
|
270
271
|
typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
|
|
271
272
|
)
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
273
|
+
// A locked project whose config file is gone ran as locked, green,
|
|
274
|
+
// though nothing declares it any more (X-144): asked only of the
|
|
275
|
+
// configs this run reads.
|
|
276
|
+
const present = await Promise.all(withFile.map((m) => Bun.file(m.configPath).exists()))
|
|
277
|
+
const gone = withFile.find((_, i) => !present[i])
|
|
278
|
+
if (gone !== undefined) {
|
|
279
|
+
const rel = relPosix(workspaceRoot, gone.dir) || '.'
|
|
280
|
+
const named = gone.name === pathName(workspaceRoot, gone.dir) ? '' : ` ("${gone.name}")`
|
|
281
|
+
const file = relPosix(gone.dir, gone.configPath)
|
|
282
|
+
// exists() says false for a file behind a directory this user may not
|
|
283
|
+
// search too; asked only on this failure path, so a run pays no stat.
|
|
284
|
+
const denied = await stat(gone.configPath).then(
|
|
285
|
+
() => null,
|
|
286
|
+
(err: NodeJS.ErrnoException) =>
|
|
287
|
+
err.code === 'EACCES' || err.code === 'EPERM' ? err.code : null,
|
|
288
|
+
)
|
|
289
|
+
throw new UserError(
|
|
290
|
+
denied !== null
|
|
291
|
+
? `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`
|
|
292
|
+
: `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
const loaded =
|
|
296
|
+
withFile.length === 0 ? [] : frozenProjectConfigs(await lock(), withFile, workspaceRoot)
|
|
280
297
|
let next = 0
|
|
281
298
|
for (const meta of round) {
|
|
282
299
|
const pre = args.staged?.get(meta.name)
|
|
@@ -305,10 +322,14 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
305
322
|
warn: args.warn,
|
|
306
323
|
name: meta.name,
|
|
307
324
|
dir: meta.dir,
|
|
308
|
-
packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
|
|
309
325
|
projects: args.projectMetas,
|
|
310
326
|
},
|
|
311
|
-
(plugin) =>
|
|
327
|
+
(plugin) =>
|
|
328
|
+
validateProjectConfig(
|
|
329
|
+
config,
|
|
330
|
+
`${where} (after plugin '${plugin.name}')`,
|
|
331
|
+
meta.configPath,
|
|
332
|
+
),
|
|
312
333
|
)
|
|
313
334
|
}
|
|
314
335
|
const built = withDefaultBuild(config)
|
|
@@ -396,57 +417,39 @@ function crossDepProjects(config: ProjectConfig): string[] {
|
|
|
396
417
|
}
|
|
397
418
|
|
|
398
419
|
/**
|
|
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`.
|
|
420
|
+
* The run path's view of a workspace's projects for a reader — a plugin's
|
|
421
|
+
* verb, an embedder: the lock, the plugin `config`, `discover` and
|
|
422
|
+
* `project` stages. `scope` is every project or a list of names; no
|
|
423
|
+
* closure. No lock is `NO_LOCK`. Plugin warnings go to `warn`.
|
|
405
424
|
*/
|
|
406
425
|
export async function loadResolvedProjects(
|
|
407
426
|
workspaceRoot: string,
|
|
408
427
|
opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
|
|
409
428
|
): Promise<Map<string, ProjectEntry>> {
|
|
410
429
|
const warn = opts.warn ?? ((): void => {})
|
|
411
|
-
const reads: LoadReads = new Map()
|
|
412
430
|
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
|
|
413
431
|
const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
432
|
+
const lock = lockReader(workspaceRoot)
|
|
433
|
+
const metas = await discoverProjects(workspaceRoot, lock, plugins, cacheDir, warn)
|
|
434
|
+
const loaded = await loadProjects({
|
|
435
|
+
workspaceRoot,
|
|
417
436
|
cacheDir,
|
|
437
|
+
plugins,
|
|
438
|
+
projectMetas: metas,
|
|
439
|
+
seeds: opts.scope ?? 'all',
|
|
440
|
+
closure: false,
|
|
441
|
+
configs: lock,
|
|
418
442
|
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
|
-
}
|
|
443
|
+
})
|
|
444
|
+
return loaded.projects
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** The lock, read once on first ask; no lock is `NO_LOCK`. */
|
|
448
|
+
export function lockReader(workspaceRoot: string): () => Promise<Lockfile> {
|
|
449
|
+
let read: Promise<Lockfile> | undefined
|
|
450
|
+
return () =>
|
|
451
|
+
(read ??= readLockfile(workspaceRoot).then((lock) => {
|
|
452
|
+
if (lock === null) throw new UserError(NO_LOCK)
|
|
453
|
+
return lock
|
|
454
|
+
}))
|
|
452
455
|
}
|
|
@@ -30,7 +30,8 @@ export interface PrefetchArgs {
|
|
|
30
30
|
/** A layer whose `hasRemote` is true — the caller's gate. */
|
|
31
31
|
cache: CacheLayer
|
|
32
32
|
workspaceRoot: string
|
|
33
|
-
|
|
33
|
+
/** The root files the plugins claim (`DeriveStableKeysArgs.claimedFiles`). */
|
|
34
|
+
claimedFiles: readonly string[]
|
|
34
35
|
forwardArgs?: readonly string[] | undefined
|
|
35
36
|
nestedDirsByProject: Map<string, string[]>
|
|
36
37
|
gitFilesCache: GitFilesCache
|
|
@@ -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) {
|
|
@@ -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
|
+
}
|