@vzn/vx 0.0.18 → 0.0.21
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/LICENSE +1 -1
- package/README.md +99 -65
- package/package.json +10 -20
- package/src/bin.ts +12 -2
- package/src/cache/archive.ts +120 -18
- package/src/cache/cache.ts +313 -1324
- package/src/cache/chained-cache.ts +0 -8
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +16 -7
- package/src/cache/inputs.ts +246 -733
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +12 -17
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +39 -11
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +64 -36
- package/src/cli/index.ts +54 -15
- package/src/cli/info.ts +120 -91
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +38 -6
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +14 -3
- package/src/cli/plugin-commands.ts +15 -8
- package/src/cli/run.ts +84 -302
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +2 -1
- package/src/cli/watch.ts +647 -97
- package/src/cli/why.ts +44 -14
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +156 -136
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +11 -26
- package/src/exec/index.ts +6 -2
- package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
- package/src/exec/runner.ts +57 -18
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +582 -370
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +1 -9
- package/src/graph/scheduler.ts +158 -203
- package/src/graph/task-graph.ts +2 -2
- package/src/index.ts +97 -102
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +3 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/events.ts +42 -8
- package/src/orchestrator/execute-task.ts +145 -554
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/framed-output.ts +41 -36
- package/src/orchestrator/history.ts +118 -98
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +35 -4
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +13 -22
- package/src/orchestrator/metrics.ts +53 -20
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +34 -31
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plugin-host.ts +195 -38
- package/src/orchestrator/plugin.ts +162 -9
- package/src/orchestrator/prepare.ts +77 -132
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-artifacts.ts +25 -3
- package/src/orchestrator/run-context.ts +1 -1
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +2 -2
- package/src/orchestrator/run.ts +246 -597
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +60 -3
- package/src/orchestrator/status-line.ts +2 -1
- package/src/orchestrator/summary.ts +68 -25
- package/src/orchestrator/task-hash.ts +20 -18
- package/src/orchestrator/task-log-buffer.ts +8 -11
- package/src/orchestrator/telemetry-host.ts +11 -1
- package/src/orchestrator/telemetry.ts +3 -17
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/index.ts +11 -3
- package/src/util/paths.ts +23 -2
- package/src/util/settle.ts +19 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +111 -11
- package/src/workspace/config-cache.ts +11 -2
- package/src/workspace/config-eval.ts +11 -4
- package/src/workspace/config-imports.ts +5 -4
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +43 -6
- package/src/workspace/fingerprint.ts +37 -3
- package/src/workspace/index.ts +30 -3
- package/src/workspace/lockfile.ts +12 -7
- package/src/{cli → workspace}/migrate-scripts.ts +26 -19
- package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
- package/src/workspace/package-graph.ts +98 -28
- package/src/workspace/project-loader.ts +76 -746
- package/src/workspace/workspace.ts +98 -13
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/schedule-history/index.ts +0 -109
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
// run with a clean UserError naming plugin + hook. A plugin that throws
|
|
9
9
|
// inside a bus hook is logged and disabled for the remainder of the run.
|
|
10
10
|
|
|
11
|
+
import { readFileSync } from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { fileURLToPath } from 'node:url'
|
|
11
14
|
import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
|
|
12
|
-
import type
|
|
15
|
+
import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
|
|
13
16
|
import type { TaskExecutor } from '../exec/index.js'
|
|
14
17
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
15
18
|
import { UserError } from '../util/index.js'
|
|
19
|
+
import type { ProjectMeta } from '../workspace/index.js'
|
|
16
20
|
import type { EventBus, RunStartInfo } from './events.js'
|
|
17
21
|
import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
18
22
|
|
|
@@ -32,7 +36,12 @@ import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
|
32
36
|
* outside the list.
|
|
33
37
|
*/
|
|
34
38
|
export interface VxPlugin {
|
|
35
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* The name of the package the plugin was defined in — set by
|
|
41
|
+
* `definePlugin` from the nearest `package.json`, never by the plugin.
|
|
42
|
+
* Heads every warning about the plugin, every `vx info` line, and the
|
|
43
|
+
* key material a `key` hook contributes.
|
|
44
|
+
*/
|
|
36
45
|
readonly name: string
|
|
37
46
|
|
|
38
47
|
// --- PIPELINE stages (shape the run before it executes — opt-in) ----------
|
|
@@ -54,7 +63,10 @@ export interface VxPlugin {
|
|
|
54
63
|
* One project's validated config, right after it loaded and before the
|
|
55
64
|
* graph is built. Add, remove or edit tasks. Runs for every loaded project
|
|
56
65
|
* on every run — a config's cached evaluation is the user's file, and the
|
|
57
|
-
* hook is applied on top of it live.
|
|
66
|
+
* hook is applied on top of it live. A package with NO config file is
|
|
67
|
+
* loaded too, as `{ tasks: {} }`, so a plugin can give tasks to packages
|
|
68
|
+
* that never wrote one (`ctx.packageJson` carries their scripts); with no
|
|
69
|
+
* `project` plugin such a package is never visited.
|
|
58
70
|
*/
|
|
59
71
|
project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise<void>
|
|
60
72
|
|
|
@@ -82,6 +94,20 @@ export interface VxPlugin {
|
|
|
82
94
|
| undefined
|
|
83
95
|
| Promise<Readonly<Record<string, string>> | undefined>
|
|
84
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Workspace-root files this plugin keys on its own, taken OUT of the
|
|
99
|
+
* workspace fingerprint. Core folds every lockfile it knows into every
|
|
100
|
+
* task's key, so one `pnpm install` re-keys the whole workspace. A plugin
|
|
101
|
+
* that reads the lockfile and folds each project's own dependency
|
|
102
|
+
* closure through `key` claims the file here: core leaves it out of the
|
|
103
|
+
* fingerprint every key sees (the config-evaluation cache still keys on
|
|
104
|
+
* it — a config may import a dependency), and `--affected` asks
|
|
105
|
+
* `affected` which projects a change to it touches instead of selecting
|
|
106
|
+
* every project. A file has one claimant; a name core does not fold is
|
|
107
|
+
* refused, since there is nothing to take out.
|
|
108
|
+
*/
|
|
109
|
+
readonly fingerprint?: FingerprintClaim
|
|
110
|
+
|
|
85
111
|
/**
|
|
86
112
|
* Scheduling priorities: task id → weight, higher runs first among READY
|
|
87
113
|
* tasks (merged over the structural baseline, which stays the tie-break).
|
|
@@ -93,6 +119,20 @@ export interface VxPlugin {
|
|
|
93
119
|
ctx: ScheduleHookContext,
|
|
94
120
|
): ReadonlyMap<string, number> | undefined | Promise<ReadonlyMap<string, number> | undefined>
|
|
95
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Admission over the worker count: asked for every task about to start
|
|
124
|
+
* on this machine, with the tasks running here right now. Return `false`
|
|
125
|
+
* to hold it until something finishes (it is asked again then). Asked
|
|
126
|
+
* many times per run, so it must be cheap and synchronous; a throw is
|
|
127
|
+
* reported once and the plugin admits from then on — a policy never
|
|
128
|
+
* breaks a run. Restore-tier hits and tasks on an executor pool hold no
|
|
129
|
+
* local resources and are never asked. When several plugins answer, all
|
|
130
|
+
* must admit. Core keeps no notion of what a task needs: a plugin that
|
|
131
|
+
* packs memory or CPU learns or declares the numbers itself
|
|
132
|
+
* (`@vzn/vx-schedule-history` packs what past executions used).
|
|
133
|
+
*/
|
|
134
|
+
admit?(task: TaskNode, ctx: AdmitContext): boolean
|
|
135
|
+
|
|
96
136
|
// --- CLI verbs (opt-in) ---------------------------------------------------
|
|
97
137
|
|
|
98
138
|
/**
|
|
@@ -110,8 +150,9 @@ export interface VxPlugin {
|
|
|
110
150
|
* Contribute a cache layer. Returns a CacheLayer wrapping (or replacing)
|
|
111
151
|
* the local Cache, or undefined to decline. Consulted ONCE per prepareRun.
|
|
112
152
|
* Precedence: first non-undefined plugin cache wins, in declaration
|
|
113
|
-
* order
|
|
114
|
-
*
|
|
153
|
+
* order, ahead of core's own `.vx/cache` handle at the tail. A layer
|
|
154
|
+
* that WRAPS the local handle subsumes that tail, so it is not written
|
|
155
|
+
* twice.
|
|
115
156
|
*/
|
|
116
157
|
cache?(ctx: CacheContext): CacheLayer | undefined | Promise<CacheLayer | undefined>
|
|
117
158
|
|
|
@@ -119,10 +160,10 @@ export interface VxPlugin {
|
|
|
119
160
|
* Contribute a task executor — WHERE one task's command runs. Consulted
|
|
120
161
|
* ONCE per run; every contributed executor is kept, in declaration order,
|
|
121
162
|
* and per task the first whose `accepts()` passes executes it. Nothing is
|
|
122
|
-
* appended:
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
163
|
+
* appended in front: core's own local executor is always the TAIL, so
|
|
164
|
+
* declining a task hands it back to this machine rather than failing
|
|
165
|
+
* the run. Persistent tasks never reach an executor (local by
|
|
166
|
+
* construction).
|
|
126
167
|
*/
|
|
127
168
|
executor?(ctx: ExecutorContext): TaskExecutor | undefined | Promise<TaskExecutor | undefined>
|
|
128
169
|
|
|
@@ -188,6 +229,18 @@ export interface ProjectHookContext extends BaseContext {
|
|
|
188
229
|
readonly dir: string
|
|
189
230
|
/** The parsed `package.json`, read-only. */
|
|
190
231
|
readonly packageJson: Readonly<Record<string, unknown>>
|
|
232
|
+
/**
|
|
233
|
+
* Every package the workspace discovered — the one being visited, the
|
|
234
|
+
* rest of the run's scope and the packages outside it, config file or
|
|
235
|
+
* not — as core read them at startup. A plugin whose mapping needs the
|
|
236
|
+
* whole workspace (a `dependsOn` is only valid against every package's
|
|
237
|
+
* scripts at once) reads it here instead of walking the workspace a
|
|
238
|
+
* second time. The same array is handed to every visit of a run and a
|
|
239
|
+
* new one to the next run — a plugin instance outlives a run under `vx
|
|
240
|
+
* watch`, so a per-run memo keys on this array's identity, never on the
|
|
241
|
+
* process.
|
|
242
|
+
*/
|
|
243
|
+
readonly projects: readonly ProjectMeta[]
|
|
191
244
|
}
|
|
192
245
|
|
|
193
246
|
export interface GraphHookContext extends BaseContext {
|
|
@@ -197,6 +250,42 @@ export interface GraphHookContext extends BaseContext {
|
|
|
197
250
|
|
|
198
251
|
export interface KeyHookContext extends BaseContext {}
|
|
199
252
|
|
|
253
|
+
/** A plugin's claim on workspace fingerprint files — see `VxPlugin.fingerprint`. */
|
|
254
|
+
export interface FingerprintClaim {
|
|
255
|
+
/** Root-relative names from `WORKSPACE_FINGERPRINT_FILES`, e.g. `['pnpm-lock.yaml']`. */
|
|
256
|
+
readonly files: readonly string[]
|
|
257
|
+
/**
|
|
258
|
+
* The projects (package names) a change to a claimed file affects. Called
|
|
259
|
+
* by `--affected` with the file's bytes at the base ref and in the
|
|
260
|
+
* working tree (`null` where it does not exist). Return `undefined` to
|
|
261
|
+
* select every project — the answer a claimant gives when it cannot
|
|
262
|
+
* tell, and what core does for an unclaimed file.
|
|
263
|
+
*/
|
|
264
|
+
affected(
|
|
265
|
+
change: FingerprintChange,
|
|
266
|
+
ctx: FingerprintContext,
|
|
267
|
+
): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface FingerprintChange {
|
|
271
|
+
/** The claimed file's root-relative name. */
|
|
272
|
+
readonly file: string
|
|
273
|
+
readonly before: Uint8Array | null
|
|
274
|
+
readonly after: Uint8Array | null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface FingerprintContext extends BaseContext {
|
|
278
|
+
/** Every project in the workspace: package name and absolute directory. */
|
|
279
|
+
readonly projects: ReadonlyArray<{ readonly name: string; readonly dir: string }>
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export interface AdmitContext {
|
|
283
|
+
/** The tasks executing on this machine right now, in dispatch order. */
|
|
284
|
+
readonly running: readonly TaskNode[]
|
|
285
|
+
/** The run's worker count — the ceiling the count gate already applies. */
|
|
286
|
+
readonly concurrency: number
|
|
287
|
+
}
|
|
288
|
+
|
|
200
289
|
export interface ScheduleHookContext extends BaseContext {
|
|
201
290
|
/** The run's local cache handle — its `dbHandle()` holds the run history a policy can learn from. */
|
|
202
291
|
readonly localCache: Cache
|
|
@@ -263,6 +352,70 @@ export interface Plugin {
|
|
|
263
352
|
setup?(ctx: PluginContext): void | Promise<void>
|
|
264
353
|
}
|
|
265
354
|
|
|
355
|
+
/** What a plugin author writes: every hook, and no name. */
|
|
356
|
+
export type PluginHooks = Omit<VxPlugin, 'name'>
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Where a plugin is defined — `import.meta` of its module. `dir` is Bun's
|
|
360
|
+
* field; `url` is the standard one, for a module evaluated elsewhere.
|
|
361
|
+
*/
|
|
362
|
+
export interface PluginOrigin {
|
|
363
|
+
readonly dir?: string
|
|
364
|
+
readonly url?: string
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const packageNameByDir = new Map<string, string>()
|
|
368
|
+
|
|
369
|
+
/** The name of the nearest `package.json` above `dir` — the one that owns it. */
|
|
370
|
+
function pluginPackageName(dir: string): string {
|
|
371
|
+
const memo = packageNameByDir.get(dir)
|
|
372
|
+
if (memo !== undefined) return memo
|
|
373
|
+
for (let d = dir; ;) {
|
|
374
|
+
let text: string | undefined
|
|
375
|
+
try {
|
|
376
|
+
text = readFileSync(path.join(d, 'package.json'), 'utf8')
|
|
377
|
+
} catch {
|
|
378
|
+
/* not here; look one level up */
|
|
379
|
+
}
|
|
380
|
+
if (text !== undefined) {
|
|
381
|
+
const name = (JSON.parse(text) as { name?: unknown }).name
|
|
382
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
383
|
+
throw new UserError(
|
|
384
|
+
`definePlugin: ${path.join(d, 'package.json')} has no name — a plugin is a package, and its name is the package's`,
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
packageNameByDir.set(dir, name)
|
|
388
|
+
return name
|
|
389
|
+
}
|
|
390
|
+
const parent = path.dirname(d)
|
|
391
|
+
if (parent === d) {
|
|
392
|
+
throw new UserError(
|
|
393
|
+
`definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`,
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
d = parent
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The one way to make a plugin: `definePlugin(import.meta, { ...hooks })`.
|
|
402
|
+
* The name is read from the package the calling module belongs to — the
|
|
403
|
+
* nearest `package.json` above it — and stamped where the workspace loader
|
|
404
|
+
* checks for it, so a plugin cannot be named anything but its package.
|
|
405
|
+
*/
|
|
406
|
+
export function definePlugin(origin: PluginOrigin, hooks: PluginHooks): VxPlugin {
|
|
407
|
+
if ('name' in hooks) {
|
|
408
|
+
throw new UserError(`definePlugin: a plugin's name is its package name — drop the 'name' field`)
|
|
409
|
+
}
|
|
410
|
+
const dir =
|
|
411
|
+
origin.dir ?? (origin.url !== undefined ? path.dirname(fileURLToPath(origin.url)) : undefined)
|
|
412
|
+
if (dir === undefined) {
|
|
413
|
+
throw new UserError(`definePlugin: the first argument must be the plugin module's import.meta`)
|
|
414
|
+
}
|
|
415
|
+
const name = pluginPackageName(dir)
|
|
416
|
+
return { ...hooks, name, [PLUGIN_PACKAGE]: name } as VxPlugin
|
|
417
|
+
}
|
|
418
|
+
|
|
266
419
|
export interface InstallPluginsArgs {
|
|
267
420
|
plugins: readonly Plugin[]
|
|
268
421
|
workspaceRoot: string
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
// try/finally around its plan() call.
|
|
9
9
|
|
|
10
10
|
import path from 'node:path'
|
|
11
|
-
import type {
|
|
11
|
+
import type { WorkspaceConfig } from '../config.js'
|
|
12
12
|
import { mark, UserError } from '../util/index.js'
|
|
13
13
|
import {
|
|
14
14
|
Cache,
|
|
15
|
+
noteSchemaReset,
|
|
15
16
|
type CacheLayer,
|
|
16
17
|
type CachePolicy,
|
|
17
18
|
FULL_CACHE_POLICY,
|
|
@@ -25,34 +26,30 @@ import {
|
|
|
25
26
|
import {
|
|
26
27
|
buildPackageGraph,
|
|
27
28
|
computeNestedProjectDirs,
|
|
28
|
-
|
|
29
|
+
computeWorkspaceFingerprints,
|
|
29
30
|
findWorkspaceRoot,
|
|
30
|
-
frozenProjectConfig,
|
|
31
31
|
listProjects,
|
|
32
|
-
loadProjectConfigs,
|
|
33
32
|
loadWorkspace,
|
|
34
|
-
|
|
33
|
+
FROZEN_WITHOUT_LOCK,
|
|
35
34
|
readLockfile,
|
|
36
35
|
resolveCacheDir,
|
|
37
|
-
validateProjectConfig,
|
|
38
36
|
type ProjectEntry,
|
|
39
37
|
} from '../workspace/index.js'
|
|
40
38
|
import {
|
|
41
39
|
buildTaskGraph,
|
|
42
40
|
expandRequested,
|
|
43
|
-
parseDependencySpec,
|
|
44
41
|
type TaskNode,
|
|
45
42
|
unresolvedRequests,
|
|
46
43
|
} from '../graph/index.js'
|
|
47
44
|
import {
|
|
48
|
-
applyConfigHooks,
|
|
49
45
|
applyGraphHooks,
|
|
50
46
|
applyKeyHooks,
|
|
51
|
-
applyProjectHooks,
|
|
52
47
|
applyScheduleHooks,
|
|
48
|
+
fingerprintClaims,
|
|
53
49
|
hasHook,
|
|
54
50
|
resolveCache,
|
|
55
51
|
} from './plugin-host.js'
|
|
52
|
+
import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
|
|
56
53
|
import type { VxPlugin } from './plugin.js'
|
|
57
54
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
58
55
|
import type { Logger } from './logger.js'
|
|
@@ -76,8 +73,7 @@ export interface PreparedRun {
|
|
|
76
73
|
* injected remote layer, or one a plugin's `cache` capability built. The
|
|
77
74
|
* remote axes of the policy only mean anything then: without a remote
|
|
78
75
|
* layer a `remote:w` policy writes NOWHERE, so a task that believed it
|
|
79
|
-
* would be saved still cleans its outputs before executing
|
|
80
|
-
* `--verify`) restores an artifact that was never written.
|
|
76
|
+
* would be saved still cleans its outputs before executing.
|
|
81
77
|
*/
|
|
82
78
|
hasRemoteLayer: boolean
|
|
83
79
|
/**
|
|
@@ -96,6 +92,12 @@ export interface PreparedRun {
|
|
|
96
92
|
unresolvedTasks: readonly string[]
|
|
97
93
|
/** Every discovered project — the declared task names a typo is measured against. */
|
|
98
94
|
projects: ReadonlyMap<string, ProjectEntry>
|
|
95
|
+
/**
|
|
96
|
+
* True iff at least one package in the workspace has a `vx.config.*` at
|
|
97
|
+
* all — regardless of scope, so a `--filter` that matched nothing is not
|
|
98
|
+
* mistaken for a workspace vx was never set up in.
|
|
99
|
+
*/
|
|
100
|
+
anyProjectConfig: boolean
|
|
99
101
|
workspaceFingerprint: string
|
|
100
102
|
nestedDirsByProject: Map<string, string[]>
|
|
101
103
|
/**
|
|
@@ -129,25 +131,6 @@ export interface PreparedRun {
|
|
|
129
131
|
empty: null | 'no-tasks-declared' | 'empty-graph'
|
|
130
132
|
}
|
|
131
133
|
|
|
132
|
-
/** Project names named by a `pkg#task` dependsOn entry anywhere in `config`. */
|
|
133
|
-
function crossDepProjects(config: ProjectConfig): string[] {
|
|
134
|
-
const out: string[] = []
|
|
135
|
-
for (const task of Object.values(config.tasks ?? {})) {
|
|
136
|
-
for (const raw of task.dependsOn ?? []) {
|
|
137
|
-
// A malformed spec is the graph builder's error to report — it names
|
|
138
|
-
// the offending task. Here it just contributes no project.
|
|
139
|
-
if (!raw.includes('#')) continue
|
|
140
|
-
try {
|
|
141
|
-
const spec = parseDependencySpec(raw)
|
|
142
|
-
if (spec.kind === 'cross') out.push(spec.project)
|
|
143
|
-
} catch {
|
|
144
|
-
continue
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
return out
|
|
149
|
-
}
|
|
150
|
-
|
|
151
134
|
/**
|
|
152
135
|
* Build the prepared-run context: workspace discovery, project-config
|
|
153
136
|
* load, package + task graph, cache handle (local, optionally wrapped
|
|
@@ -178,16 +161,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
178
161
|
// await further down still sees the error).
|
|
179
162
|
earlyGit?.catch(() => {})
|
|
180
163
|
const workspace = await loadWorkspace(workspaceRoot)
|
|
181
|
-
const workspaceConfig = await
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
|
|
185
|
-
if (workspaceConfig !== null && hasHook(plugins, 'config')) {
|
|
186
|
-
await applyConfigHooks(plugins, workspaceConfig, {
|
|
187
|
-
workspaceRoot,
|
|
188
|
-
warn: (m) => log.status(m),
|
|
189
|
-
})
|
|
190
|
-
}
|
|
164
|
+
const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
|
|
165
|
+
log.status(m),
|
|
166
|
+
)
|
|
191
167
|
mark('workspace config')
|
|
192
168
|
const projectMetas = await listProjects(workspace)
|
|
193
169
|
mark('discover projects')
|
|
@@ -201,17 +177,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
201
177
|
// Turbo-like: a broken config in an unrelated package no longer
|
|
202
178
|
// fails a scoped run — it surfaces when that package enters scope.
|
|
203
179
|
const packageGraph = buildPackageGraph(projectMetas)
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// Seeds: explicit scope, plus anchored pkg#task targets (which
|
|
211
|
-
// bypass scope by design). With no explicit scope, bare task names
|
|
212
|
-
// fan out across the whole workspace — but when EVERY spec is
|
|
213
|
-
// anchored, the anchors alone are the scope and nothing else needs
|
|
214
|
-
// its config evaluated.
|
|
180
|
+
// Seeds: explicit scope, plus anchored pkg#task targets (which bypass
|
|
181
|
+
// scope by design). With no explicit scope, bare task names fan out
|
|
182
|
+
// across the whole workspace — but when EVERY spec is anchored, the
|
|
183
|
+
// anchors alone are the scope and nothing else needs its config
|
|
184
|
+
// evaluated.
|
|
215
185
|
const anchored: string[] = []
|
|
216
186
|
let hasBare = false
|
|
217
187
|
for (const spec of options.tasks) {
|
|
@@ -219,16 +189,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
219
189
|
if (hashIdx > 0) anchored.push(spec.slice(0, hashIdx))
|
|
220
190
|
else hasBare = true
|
|
221
191
|
}
|
|
222
|
-
const seeds
|
|
223
|
-
options.projects
|
|
224
|
-
? options.projects.filter((p) => haveConfig.has(p))
|
|
225
|
-
: hasBare
|
|
226
|
-
? haveConfig
|
|
227
|
-
: [],
|
|
228
|
-
)
|
|
229
|
-
for (const project of anchored) {
|
|
230
|
-
if (haveConfig.has(project)) seeds.add(project)
|
|
231
|
-
}
|
|
192
|
+
const seeds: 'all' | string[] =
|
|
193
|
+
options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
|
|
232
194
|
|
|
233
195
|
// The local cache opens BEFORE the configs load: it is also where their
|
|
234
196
|
// cached evaluations live. `--cache-dir <path>` (RunOptions.cacheDir)
|
|
@@ -239,25 +201,17 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
239
201
|
? path.resolve(options.cwd, options.cacheDir)
|
|
240
202
|
: resolveCacheDir(workspaceRoot, workspaceConfig)
|
|
241
203
|
const localCache = new Cache(cacheDir, { read: policy.localRead, write: policy.localWrite })
|
|
242
|
-
|
|
204
|
+
noteSchemaReset(localCache, (m) => log.status(m))
|
|
205
|
+
// Two digests from one read: the config-evaluation cache keys on every
|
|
206
|
+
// file (a config may import a dependency), the task keys on the files no
|
|
207
|
+
// plugin claims (`VxPlugin.fingerprint`).
|
|
208
|
+
const fingerprints = await computeWorkspaceFingerprints(
|
|
209
|
+
workspaceRoot,
|
|
210
|
+
new Set(fingerprintClaims(plugins).keys()),
|
|
211
|
+
)
|
|
212
|
+
const workspaceFingerprint = fingerprints.unclaimed
|
|
243
213
|
mark('open cache')
|
|
244
214
|
|
|
245
|
-
type ConfigMeta = (typeof projectsWithConfigs)[number]
|
|
246
|
-
const metaByName = new Map<string, ConfigMeta>(projectsWithConfigs.map((m) => [m.name, m]))
|
|
247
|
-
const needed = new Set<string>()
|
|
248
|
-
const pending: ConfigMeta[] = []
|
|
249
|
-
const consider = (name: string): void => {
|
|
250
|
-
if (needed.has(name)) return
|
|
251
|
-
needed.add(name)
|
|
252
|
-
const meta = metaByName.get(name)
|
|
253
|
-
if (meta) pending.push(meta)
|
|
254
|
-
}
|
|
255
|
-
const considerWithDeps = (name: string): void => {
|
|
256
|
-
consider(name)
|
|
257
|
-
for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
|
|
258
|
-
}
|
|
259
|
-
for (const seed of seeds) considerWithDeps(seed)
|
|
260
|
-
|
|
261
215
|
// Frozen mode (--frozen, CI): configs load FROM vx-lock.json after a
|
|
262
216
|
// content-hash tripwire — no evaluation; env-dependent configs keep
|
|
263
217
|
// their locked values. Default (local) runs ALWAYS evaluate live:
|
|
@@ -267,55 +221,31 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
267
221
|
// See docs/design/config-lock-2026-06.md.
|
|
268
222
|
const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
|
|
269
223
|
if (options.frozen === true && lock === null) {
|
|
270
|
-
throw new UserError(
|
|
271
|
-
`--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it`,
|
|
272
|
-
)
|
|
224
|
+
throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
273
225
|
}
|
|
274
226
|
|
|
275
|
-
|
|
276
|
-
// project the PACKAGE graph cannot reach (the cross form ignores npm
|
|
277
|
-
// deps by design), so its config has to be pulled in — and that config
|
|
278
|
-
// may declare cross edges of its own. The common case (no cross deps)
|
|
279
|
-
// is a single round, identical to loading the closure in one batch.
|
|
280
|
-
const projects = new Map<string, ProjectEntry>()
|
|
281
|
-
const projectStage = hasHook(plugins, 'project')
|
|
227
|
+
let loaded: LoadedProjects
|
|
282
228
|
try {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// then re-validates so a plugin can only produce what the loader
|
|
297
|
-
// accepts from a user. The message names the config file — the
|
|
298
|
-
// plugin's edit is a defect in THAT project's tasks.
|
|
299
|
-
await applyProjectHooks(plugins, config, {
|
|
300
|
-
workspaceRoot,
|
|
301
|
-
cacheDir,
|
|
302
|
-
warn: (m) => log.status(m),
|
|
303
|
-
name: meta.name,
|
|
304
|
-
dir: meta.dir,
|
|
305
|
-
packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
|
|
306
|
-
})
|
|
307
|
-
validateProjectConfig(config, `${meta.configPath} (after plugins)`)
|
|
308
|
-
}
|
|
309
|
-
projects.set(meta.name, { name: meta.name, dir: meta.dir, config })
|
|
310
|
-
for (const name of crossDepProjects(config)) considerWithDeps(name)
|
|
311
|
-
}
|
|
312
|
-
}
|
|
229
|
+
loaded = await loadProjects({
|
|
230
|
+
workspaceRoot,
|
|
231
|
+
cacheDir,
|
|
232
|
+
plugins,
|
|
233
|
+
projectMetas,
|
|
234
|
+
packageGraph,
|
|
235
|
+
seeds,
|
|
236
|
+
closure: true,
|
|
237
|
+
lock,
|
|
238
|
+
evalCache: { store: localCache, workspaceFingerprint: fingerprints.all },
|
|
239
|
+
warn: (m) => log.status(m),
|
|
240
|
+
...(options.staged !== undefined ? { staged: options.staged } : {}),
|
|
241
|
+
})
|
|
313
242
|
} catch (err) {
|
|
314
243
|
// The cache opened before the configs loaded (it holds their cached
|
|
315
244
|
// evaluations); a config error must not leak the handle.
|
|
316
245
|
localCache.close()
|
|
317
246
|
throw err
|
|
318
247
|
}
|
|
248
|
+
const { projects, configured: projectsWithConfigs } = loaded
|
|
319
249
|
mark('load configs')
|
|
320
250
|
|
|
321
251
|
// Boundary geometry considers every config-bearing project in the
|
|
@@ -333,24 +263,32 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
333
263
|
const unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
|
|
334
264
|
|
|
335
265
|
// Cache seam precedence: an EXPLICITLY injected remote layer
|
|
336
|
-
// (RunOptions.remoteCache — a distribution agent or
|
|
266
|
+
// (RunOptions.remoteCache — a distribution agent or daemon that already
|
|
337
267
|
// holds a wire client) wins outright; else a plugin's `cache` capability;
|
|
338
268
|
// else the local cache alone. Core ships no wire client — the remote
|
|
339
|
-
// cache is a plugin concern (
|
|
269
|
+
// cache is a plugin concern (docs/patterns.md § Remote cache wire). Injection
|
|
340
270
|
// winning prevents double-wrapping when the workspace also declares a
|
|
341
271
|
// cache plugin.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
272
|
+
let cache: CacheLayer
|
|
273
|
+
try {
|
|
274
|
+
cache = options.remoteCache
|
|
275
|
+
? new LayeredCache(localCache, options.remoteCache, {
|
|
276
|
+
policy,
|
|
277
|
+
onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
|
|
278
|
+
})
|
|
279
|
+
: await resolveCache(plugins, {
|
|
280
|
+
workspaceRoot,
|
|
281
|
+
cacheDir,
|
|
282
|
+
warn: (m) => log.status(m),
|
|
283
|
+
localCache,
|
|
284
|
+
policy,
|
|
285
|
+
})
|
|
286
|
+
} catch (err) {
|
|
287
|
+
// A cache plugin that throws or returns something off-contract is
|
|
288
|
+
// refused by name; the local handle opened above must not leak with it.
|
|
289
|
+
localCache.close()
|
|
290
|
+
throw err
|
|
291
|
+
}
|
|
354
292
|
// Ask the LAYER, don't infer. Identity against `localCache` answers a
|
|
355
293
|
// DIFFERENT question — "did the plugin hand back something other than the
|
|
356
294
|
// handle I passed in?" — which an ordinary pass-through decorator (a
|
|
@@ -397,6 +335,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
397
335
|
nodes: new Map(),
|
|
398
336
|
unresolvedTasks,
|
|
399
337
|
projects,
|
|
338
|
+
anyProjectConfig: projectsWithConfigs.length > 0,
|
|
400
339
|
workspaceFingerprint,
|
|
401
340
|
nestedDirsByProject,
|
|
402
341
|
gitFilesCache,
|
|
@@ -414,6 +353,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
414
353
|
? { excludeDependencies: options.excludeDependencies }
|
|
415
354
|
: {}),
|
|
416
355
|
})
|
|
356
|
+
// The graph is built; what follows is the plugins' (graph, key,
|
|
357
|
+
// schedule). Two rows, so a plugin's key stage reads as its own cost
|
|
358
|
+
// and not as graph building — a lockfile plugin's 1000 stats per run
|
|
359
|
+
// hid inside one `prepare (graph)` row until 2026-09-10.
|
|
360
|
+
mark('build graph')
|
|
417
361
|
if (hasHook(plugins, 'graph')) {
|
|
418
362
|
await applyGraphHooks(plugins, nodes, {
|
|
419
363
|
workspaceRoot,
|
|
@@ -447,6 +391,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
447
391
|
nodes,
|
|
448
392
|
unresolvedTasks,
|
|
449
393
|
projects,
|
|
394
|
+
anyProjectConfig: projectsWithConfigs.length > 0,
|
|
450
395
|
workspaceFingerprint,
|
|
451
396
|
nestedDirsByProject,
|
|
452
397
|
gitFilesCache,
|