@vzn/vx 0.0.641 → 0.0.643
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +85 -11
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +12 -3
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +4 -6
- package/src/cache/layered-cache.ts +96 -10
- package/src/cache/output-index.ts +25 -4
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +25 -8
- package/src/cache/run-store.ts +4 -1
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +74 -237
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +6 -8
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +14 -15
- package/src/cli/select.ts +9 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +14 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +5 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +38 -4
- package/src/orchestrator/plugin.ts +49 -5
- package/src/orchestrator/prepare.ts +141 -22
- package/src/orchestrator/projects.ts +25 -6
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +38 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +52 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +8 -2
- package/src/settings.ts +11 -11
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +182 -75
- package/src/workspace/config-schema.ts +81 -19
- package/src/workspace/index.ts +3 -0
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
|
@@ -39,7 +39,9 @@ export interface LockfileClaimOptions {
|
|
|
39
39
|
* the lockfile does not pin (bun.lock's `patchedDependencies` names a patch
|
|
40
40
|
* file and records no hash of it). Their content hashes ('' when absent)
|
|
41
41
|
* reach `digest` as `files` and join the memo's identity, so an edited
|
|
42
|
-
* patch moves the key (item 1014).
|
|
42
|
+
* patch moves the key (item 1014). A directory (a `file:` dependency the
|
|
43
|
+
* install copies) hashes every file under it but `node_modules` and
|
|
44
|
+
* `.git`. Called only when the memo misses.
|
|
43
45
|
*/
|
|
44
46
|
readonly extraFiles?: (text: string) => readonly string[]
|
|
45
47
|
/** Folded into the memo's identity: bump when `digest` folds differently. */
|
|
@@ -109,13 +111,33 @@ interface Memo {
|
|
|
109
111
|
/** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
|
|
110
112
|
async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
|
|
111
113
|
if (path.isAbsolute(rel) || rel.split(/[\\/]/).includes('..')) return 'outside-root'
|
|
114
|
+
const abs = path.join(workspaceRoot, rel)
|
|
112
115
|
try {
|
|
113
|
-
return xxh3hex(await Bun.file(
|
|
114
|
-
} catch {
|
|
116
|
+
return xxh3hex(await Bun.file(abs).bytes())
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if ((err as NodeJS.ErrnoException).code === 'EISDIR') return hashDir(abs)
|
|
115
119
|
return ''
|
|
116
120
|
}
|
|
117
121
|
}
|
|
118
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
|
|
125
|
+
* lines in sorted order. Links are followed: a linked file is what a task
|
|
126
|
+
* reads, and the scan skipped it, so an edit to its target moved no key.
|
|
127
|
+
*/
|
|
128
|
+
async function hashDir(dir: string): Promise<string> {
|
|
129
|
+
const rels: string[] = []
|
|
130
|
+
const scan = { cwd: dir, dot: true, onlyFiles: true, followSymlinks: true }
|
|
131
|
+
for await (const rel of new Bun.Glob('**').scan(scan)) {
|
|
132
|
+
if (!rel.split('/').some((s) => s === 'node_modules' || s === '.git')) rels.push(rel)
|
|
133
|
+
}
|
|
134
|
+
rels.sort()
|
|
135
|
+
const lines = await Promise.all(
|
|
136
|
+
rels.map(async (rel) => `${rel}\0${xxh3hex(await Bun.file(path.join(dir, rel)).bytes())}`),
|
|
137
|
+
)
|
|
138
|
+
return `dir\0${xxh3hex(lines.join('\n'))}`
|
|
139
|
+
}
|
|
140
|
+
|
|
119
141
|
/**
|
|
120
142
|
* A file's identity for the in-process gate, as `Cache.hashFile`'s memo
|
|
121
143
|
* keys one: size and mtime alone kept the digests of a lockfile or patch
|
|
@@ -126,9 +148,16 @@ function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: n
|
|
|
126
148
|
return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`
|
|
127
149
|
}
|
|
128
150
|
|
|
151
|
+
// A directory's own stat moves only when an entry is added or removed, not
|
|
152
|
+
// when a file in it is edited: its identity is its content hash.
|
|
129
153
|
async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
|
|
130
154
|
return Promise.all(
|
|
131
|
-
rels.map((rel) =>
|
|
155
|
+
rels.map((rel) =>
|
|
156
|
+
stat(path.join(workspaceRoot, rel)).then(
|
|
157
|
+
(st) => (st.isDirectory() ? hashExtra(workspaceRoot, rel) : identityOf(st)),
|
|
158
|
+
() => 'gone',
|
|
159
|
+
),
|
|
160
|
+
),
|
|
132
161
|
)
|
|
133
162
|
}
|
|
134
163
|
|
|
@@ -262,15 +291,29 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
262
291
|
// lockfile is broken" and "the base commit's is" — a migration
|
|
263
292
|
// commit hits the second, and the bare parser message named
|
|
264
293
|
// neither (2026-09-20).
|
|
265
|
-
|
|
294
|
+
// A changed extra file is folded by its content on each side; one
|
|
295
|
+
// left out is the same on both, so it moves no digest either way.
|
|
296
|
+
const extrasOf = (pick: 'before' | 'after'): Map<string, string> => {
|
|
297
|
+
const out = new Map<string, string>()
|
|
298
|
+
for (const [rel, sides] of change.extras ?? []) {
|
|
299
|
+
const bytes = sides[pick]
|
|
300
|
+
out.set(rel, bytes === null ? '' : xxh3hex(bytes))
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
304
|
+
const sideOf = (
|
|
305
|
+
bytes: Uint8Array,
|
|
306
|
+
pick: 'before' | 'after',
|
|
307
|
+
side: string,
|
|
308
|
+
): ReadonlyMap<string, string> => {
|
|
266
309
|
try {
|
|
267
|
-
return digest(decode(bytes),
|
|
310
|
+
return digest(decode(bytes), extrasOf(pick))
|
|
268
311
|
} catch (err) {
|
|
269
312
|
throw new Error(`${err instanceof Error ? err.message : String(err)} (${side})`)
|
|
270
313
|
}
|
|
271
314
|
}
|
|
272
|
-
const before = sideOf(change.before, `as of the base ref`)
|
|
273
|
-
const after = sideOf(change.after, `in the working tree`)
|
|
315
|
+
const before = sideOf(change.before, 'before', `as of the base ref`)
|
|
316
|
+
const after = sideOf(change.after, 'after', `in the working tree`)
|
|
274
317
|
const names: string[] = []
|
|
275
318
|
for (const p of ctx.projects) {
|
|
276
319
|
const importer = importerOf(ctx.workspaceRoot, p.dir)
|
|
@@ -278,6 +321,9 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
278
321
|
}
|
|
279
322
|
return names
|
|
280
323
|
},
|
|
324
|
+
...(options.extraFiles !== undefined
|
|
325
|
+
? { extraFiles: (_file: string, bytes: Uint8Array) => options.extraFiles!(decode(bytes)) }
|
|
326
|
+
: {}),
|
|
281
327
|
},
|
|
282
328
|
async key(task, ctx) {
|
|
283
329
|
const digests = await loadOnce(ctx)
|
|
@@ -12,6 +12,7 @@ import path from 'node:path'
|
|
|
12
12
|
import {
|
|
13
13
|
type CacheLayer,
|
|
14
14
|
type GitFilesCache,
|
|
15
|
+
resolveOutputTree,
|
|
15
16
|
resolveOutputs,
|
|
16
17
|
resolveWorkspaceOutputs,
|
|
17
18
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -51,7 +52,9 @@ export function entryHolds(
|
|
|
51
52
|
): (files: readonly string[], rows: ReadonlyArray<{ path: string }>) => boolean {
|
|
52
53
|
return (files, rows) => {
|
|
53
54
|
const expected = new Set(
|
|
54
|
-
rows
|
|
55
|
+
rows
|
|
56
|
+
.map((r) => r.path)
|
|
57
|
+
.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX) && !p.endsWith('/')),
|
|
55
58
|
)
|
|
56
59
|
const seen = new Set(files)
|
|
57
60
|
if ((node.addsToOutputsOf?.length ?? 0) > 0) return [...expected].every((p) => seen.has(p))
|
|
@@ -111,8 +114,6 @@ export interface SaveMissArgs {
|
|
|
111
114
|
ownOutputFiles?: string[] | undefined
|
|
112
115
|
/** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
|
|
113
116
|
ownWsOutputFiles?: string[] | undefined
|
|
114
|
-
/** A sandboxed task's reach: a symlinked output it could not read is refused (`sandboxReadable`). */
|
|
115
|
-
readable?: ((real: string) => boolean) | undefined
|
|
116
117
|
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
117
118
|
captured: readonly TaskInputComponent[]
|
|
118
119
|
command: string
|
|
@@ -129,7 +130,7 @@ export interface SaveMissArgs {
|
|
|
129
130
|
* (a same-project downstream task reads both), and the pack + write +
|
|
130
131
|
* index go to the run's save lane (save-lane.ts).
|
|
131
132
|
*/
|
|
132
|
-
deferSave?: ((save: () => Promise<void>) => Promise<
|
|
133
|
+
deferSave?: ((save: () => Promise<void>) => Promise<boolean>) | undefined
|
|
133
134
|
/** Time the save and size its artifact, for a telemetry sink: one stat. */
|
|
134
135
|
measure?: boolean | undefined
|
|
135
136
|
}
|
|
@@ -138,6 +139,8 @@ export interface SaveMissArgs {
|
|
|
138
139
|
export interface SaveFacts {
|
|
139
140
|
saveMs?: number
|
|
140
141
|
artifactBytes?: number
|
|
142
|
+
/** The entry landed: the outcome's `saved`, set only once it has. */
|
|
143
|
+
saved?: true
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
/**
|
|
@@ -147,13 +150,16 @@ export interface SaveFacts {
|
|
|
147
150
|
export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveFacts> }> {
|
|
148
151
|
const { node, cache, log } = a
|
|
149
152
|
const endResolve = span('miss: resolve outputs')
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
// An additive task owns only the files it wrote (item 588), no directory.
|
|
154
|
+
const tree =
|
|
155
|
+
a.ownOutputFiles !== undefined
|
|
156
|
+
? { files: a.ownOutputFiles, emptyDirs: [] }
|
|
157
|
+
: await resolveOutputTree({
|
|
158
|
+
projectDir: node.projectDir,
|
|
159
|
+
outputs: a.outputs,
|
|
160
|
+
nestedProjectDirs: a.nestedProjectDirs,
|
|
161
|
+
})
|
|
162
|
+
const outputFiles = tree.files
|
|
157
163
|
const wsOutputFiles =
|
|
158
164
|
a.ownWsOutputFiles ??
|
|
159
165
|
(await resolveWorkspaceOutputs({
|
|
@@ -204,11 +210,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
204
210
|
hash: a.hash,
|
|
205
211
|
projectDir: node.projectDir,
|
|
206
212
|
outputFiles,
|
|
207
|
-
...(
|
|
208
|
-
|
|
209
|
-
|
|
213
|
+
...(tree.emptyDirs.length > 0 ? { outputDirs: tree.emptyDirs } : {}),
|
|
214
|
+
// The root an output symlink's target must stay inside, outputs.workspaceFiles or not.
|
|
215
|
+
workspaceRoot: a.workspaceRoot,
|
|
216
|
+
...(wsOutputFiles.length > 0 ? { workspaceOutputFiles: wsOutputFiles } : {}),
|
|
210
217
|
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
211
|
-
readable: a.readable,
|
|
212
218
|
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
213
219
|
// the contract no longer accepts one — so the invariant is enforced by
|
|
214
220
|
// the type rather than by every call site remembering the gate.
|
|
@@ -258,9 +264,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
258
264
|
// Off the slot when the run keeps a lane (the save lane bounds and
|
|
259
265
|
// drains it); in the slot otherwise — an embedder without a lane gets
|
|
260
266
|
// the entry before the outcome.
|
|
261
|
-
if (a.deferSave !== undefined)
|
|
267
|
+
if (a.deferSave !== undefined) {
|
|
268
|
+
return {
|
|
269
|
+
landed: a.deferSave(save).then((stored) => (stored ? { ...facts, saved: true } : facts)),
|
|
270
|
+
}
|
|
271
|
+
}
|
|
262
272
|
await save()
|
|
263
|
-
return { landed: Promise.resolve(facts) }
|
|
273
|
+
return { landed: Promise.resolve({ ...facts, saved: true }) }
|
|
264
274
|
}
|
|
265
275
|
|
|
266
276
|
/** What `markUnsaved` reads of a miss: where its declared outputs are. */
|
|
@@ -90,8 +90,16 @@ export interface RunOptions {
|
|
|
90
90
|
* saying it is not.
|
|
91
91
|
*/
|
|
92
92
|
remoteRequested?: boolean
|
|
93
|
-
/** This run's
|
|
93
|
+
/** This run's cache scope, above `VX_CACHE_SCOPE`, a plugin's and the branch's (an embedder's). */
|
|
94
94
|
cacheScope?: string
|
|
95
|
+
/**
|
|
96
|
+
* What the branch changed, for a scoped run: the tasks whose key it can
|
|
97
|
+
* move read the scope's key, every other task the default key (one
|
|
98
|
+
* lookup each). Absent, the run diffs the branch from `scopeBase`.
|
|
99
|
+
*/
|
|
100
|
+
scopeChanges?: AffectedChanges
|
|
101
|
+
/** The commit a scoped run diffs the branch from (`--affected`'s base); absent, a plugin's, else the merge base with the default branch. */
|
|
102
|
+
scopeBase?: string
|
|
95
103
|
/**
|
|
96
104
|
* The default policy's remote write is off (`remote:r`): the CLI passes it
|
|
97
105
|
* off CI (`CI` unset, `0` or `false`) unless `--cache-policy` or
|
|
@@ -7,7 +7,15 @@ import type { Logger } from '../report/index.js'
|
|
|
7
7
|
import type { OutputContext, VxPlugin } from './plugin.js'
|
|
8
8
|
|
|
9
9
|
const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
|
|
10
|
-
const OPTIONAL = [
|
|
10
|
+
const OPTIONAL = [
|
|
11
|
+
'runStart',
|
|
12
|
+
'taskStart',
|
|
13
|
+
'runEnd',
|
|
14
|
+
'stepStart',
|
|
15
|
+
'stepEnd',
|
|
16
|
+
'runStop',
|
|
17
|
+
'runDone',
|
|
18
|
+
] as const
|
|
11
19
|
|
|
12
20
|
/** Reject anything that is not Logger-shaped, naming what is missing. */
|
|
13
21
|
function checkLogger(value: unknown): Logger {
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -146,6 +146,10 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
146
146
|
const outcomes = await runGraph({
|
|
147
147
|
nodes: args.nodes,
|
|
148
148
|
concurrency: 1,
|
|
149
|
+
// A plan's keys and probes do not depend on the order it walks in, so
|
|
150
|
+
// it ranks nothing: the default ranking's closure over every task was
|
|
151
|
+
// ~30 ms of a 1,000-project `--dry`.
|
|
152
|
+
strategy: 'ready-order',
|
|
149
153
|
execute: async (node, live) => {
|
|
150
154
|
const upstream = keyUpstream(node, live)
|
|
151
155
|
const hashArgs = {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import {
|
|
12
|
+
type BranchScope,
|
|
12
13
|
type Cache,
|
|
13
14
|
type CacheLayer,
|
|
14
15
|
type CachePolicy,
|
|
@@ -34,7 +35,7 @@ import type {
|
|
|
34
35
|
RunReport,
|
|
35
36
|
ScheduleHookContext,
|
|
36
37
|
VxPlugin,
|
|
37
|
-
|
|
38
|
+
ConfigContext,
|
|
38
39
|
} from './plugin.js'
|
|
39
40
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
40
41
|
|
|
@@ -137,7 +138,7 @@ function assertEditedInPlace(
|
|
|
137
138
|
export async function applyConfigHooks(
|
|
138
139
|
plugins: readonly VxPlugin[],
|
|
139
140
|
workspace: WorkspaceConfig,
|
|
140
|
-
ctx:
|
|
141
|
+
ctx: ConfigContext,
|
|
141
142
|
/** Runs after EACH plugin's edit, so a refusal can name the plugin that made it. */
|
|
142
143
|
afterEach?: (plugin: VxPlugin) => void,
|
|
143
144
|
): Promise<void> {
|
|
@@ -381,6 +382,36 @@ export async function claimedAffected(
|
|
|
381
382
|
return names
|
|
382
383
|
}
|
|
383
384
|
|
|
385
|
+
/**
|
|
386
|
+
* The files a claimed file names that its plugin keys too
|
|
387
|
+
* (`FingerprintClaim.extraFiles`); `undefined` when it names none it can
|
|
388
|
+
* answer for, and a plugin that throws is refused by its name.
|
|
389
|
+
*/
|
|
390
|
+
export function claimedExtraFiles(
|
|
391
|
+
plugin: VxPlugin,
|
|
392
|
+
file: string,
|
|
393
|
+
bytes: Uint8Array,
|
|
394
|
+
): readonly string[] | undefined {
|
|
395
|
+
const read = plugin.fingerprint?.extraFiles
|
|
396
|
+
if (read === undefined) return undefined
|
|
397
|
+
let names: unknown
|
|
398
|
+
try {
|
|
399
|
+
names = read.call(plugin.fingerprint, file, bytes)
|
|
400
|
+
} catch (err) {
|
|
401
|
+
throw new UserError(
|
|
402
|
+
maskedLine(
|
|
403
|
+
`plugin '${plugin.name}' failed in fingerprint: ${err instanceof Error ? err.message : String(err)}`,
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
}
|
|
407
|
+
if (!Array.isArray(names) || names.some((n) => typeof n !== 'string')) {
|
|
408
|
+
throw new UserError(
|
|
409
|
+
`plugin '${plugin.name}' failed in fingerprint: extraFiles returned ${describeValue(names)}, not a list of paths`,
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
return names as string[]
|
|
413
|
+
}
|
|
414
|
+
|
|
384
415
|
/** What a run hands the `schedule` stage: a step per plugin, and where refined weights go. */
|
|
385
416
|
export interface ScheduleRun {
|
|
386
417
|
readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
@@ -525,6 +556,8 @@ export interface CacheHost extends CacheContext {
|
|
|
525
556
|
readonly policy: CachePolicy
|
|
526
557
|
/** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
|
|
527
558
|
readonly remotes?: ResolvedRemote[]
|
|
559
|
+
/** The run's branch scope, which picks each remote key. */
|
|
560
|
+
readonly branch?: BranchScope
|
|
528
561
|
}
|
|
529
562
|
|
|
530
563
|
/**
|
|
@@ -539,7 +572,7 @@ export async function resolveCache(
|
|
|
539
572
|
plugins: readonly VxPlugin[],
|
|
540
573
|
host: CacheHost,
|
|
541
574
|
): Promise<CacheLayer> {
|
|
542
|
-
const { localCache, policy, remotes, ...ctx } = host
|
|
575
|
+
const { localCache, policy, remotes, branch, ...ctx } = host
|
|
543
576
|
const resolved: ResolvedRemote[] = []
|
|
544
577
|
for (const plugin of plugins) {
|
|
545
578
|
if (plugin.cache === undefined) continue
|
|
@@ -548,6 +581,7 @@ export async function resolveCache(
|
|
|
548
581
|
assertShape(plugin, 'cache', remote, REMOTE_CACHE_METHODS, 'a remote cache layer')
|
|
549
582
|
const entry: ResolvedRemote = {
|
|
550
583
|
remote,
|
|
584
|
+
plugin: plugin.name,
|
|
551
585
|
read: policy.remoteRead,
|
|
552
586
|
write: policy.remoteWrite,
|
|
553
587
|
onError: (err) => ctx.warn(`[vx] plugin '${plugin.name}': ${err.message}`),
|
|
@@ -555,7 +589,7 @@ export async function resolveCache(
|
|
|
555
589
|
resolved.push(entry)
|
|
556
590
|
remotes?.push(entry)
|
|
557
591
|
}
|
|
558
|
-
return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved)
|
|
592
|
+
return resolved.length === 0 ? localCache : new LayeredCache(localCache, resolved, branch)
|
|
559
593
|
}
|
|
560
594
|
|
|
561
595
|
/** The plugin each resolved executor came from, so a hook of it that throws is named. */
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
|
|
21
21
|
import type { TaskExecutor } from '../exec/index.js'
|
|
22
22
|
import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
|
|
23
|
-
import { maskedLine, nearest, UserError } from '../util/index.js'
|
|
23
|
+
import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from '../util/index.js'
|
|
24
24
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
25
25
|
import type { EventBus, Logger, RunStartInfo } from '../report/index.js'
|
|
26
26
|
import type { CiContext } from './run-context.js'
|
|
@@ -61,8 +61,10 @@ export interface VxPlugin {
|
|
|
61
61
|
* The workspace config, before anything is derived from it (concurrency,
|
|
62
62
|
* cacheDir, timeout, …). `plugins` is already fixed by the time this runs.
|
|
63
63
|
* Core re-validates after each plugin, as it does a user's file.
|
|
64
|
+
* `ctx.setBranch` names what the CI knows of the branch: its cache scope
|
|
65
|
+
* and the commit it is compared with.
|
|
64
66
|
*/
|
|
65
|
-
config?(workspace: WorkspaceConfig, ctx:
|
|
67
|
+
config?(workspace: WorkspaceConfig, ctx: ConfigContext): void | Promise<void>
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
70
|
* Projects beyond the directories holding a vx.config, their names, and
|
|
@@ -285,8 +287,12 @@ interface BaseContext {
|
|
|
285
287
|
warn(message: string): void
|
|
286
288
|
}
|
|
287
289
|
|
|
288
|
-
/**
|
|
289
|
-
|
|
290
|
+
/**
|
|
291
|
+
* What `setup` receives: the run's lifecycle, observe-only, and the
|
|
292
|
+
* plugin's keychain (`secret`, `forgetSecret`): setup runs before the
|
|
293
|
+
* status line, so a prompt there is seen.
|
|
294
|
+
*/
|
|
295
|
+
export interface PluginSetupContext extends BaseContext, PluginSecrets {
|
|
290
296
|
/** This plugin's entries in the workspace's cache (`PluginCache`). */
|
|
291
297
|
readonly cache: PluginCache
|
|
292
298
|
/** The run is on CI (`CI` set, or a provider core detects). */
|
|
@@ -336,6 +342,22 @@ export interface WorkspaceHookContext {
|
|
|
336
342
|
warn(message: string): void
|
|
337
343
|
}
|
|
338
344
|
|
|
345
|
+
/**
|
|
346
|
+
* What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
|
|
347
|
+
* cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
|
|
348
|
+
* beats the one git names), and `base`, the commit the branch is compared
|
|
349
|
+
* with (a pull request's base), for a bare `--affected` and for which
|
|
350
|
+
* tasks read the scope. A later plugin's field wins.
|
|
351
|
+
*/
|
|
352
|
+
export interface BranchFacts {
|
|
353
|
+
scope?: string
|
|
354
|
+
base?: string
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface ConfigContext extends WorkspaceHookContext {
|
|
358
|
+
setBranch(facts: BranchFacts): void
|
|
359
|
+
}
|
|
360
|
+
|
|
339
361
|
export interface DiscoverContext extends WorkspaceHookContext {
|
|
340
362
|
/** Where vx's cache lives: a plugin may keep what it read to answer here. */
|
|
341
363
|
readonly cacheDir: string
|
|
@@ -419,6 +441,15 @@ export interface FingerprintClaim {
|
|
|
419
441
|
change: FingerprintChange,
|
|
420
442
|
ctx: FingerprintContext,
|
|
421
443
|
): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
|
|
444
|
+
/**
|
|
445
|
+
* The workspace-relative files a claimed file's bytes name that the
|
|
446
|
+
* plugin's key reads too: `bun.lock`'s patch files, which it names by
|
|
447
|
+
* path and hashes nowhere, or a `file:` dependency's directory. A change
|
|
448
|
+
* to one, or under one, asks `affected` about the file naming it, with
|
|
449
|
+
* `change.extras`. Without it, a `bun.lock` patch edit selects every
|
|
450
|
+
* project.
|
|
451
|
+
*/
|
|
452
|
+
extraFiles?(file: string, bytes: Uint8Array): readonly string[]
|
|
422
453
|
}
|
|
423
454
|
|
|
424
455
|
export interface FingerprintChange {
|
|
@@ -426,6 +457,15 @@ export interface FingerprintChange {
|
|
|
426
457
|
readonly file: string
|
|
427
458
|
readonly before: Uint8Array | null
|
|
428
459
|
readonly after: Uint8Array | null
|
|
460
|
+
/**
|
|
461
|
+
* Each changed `extraFiles` entry, as the claimant names it, at the base
|
|
462
|
+
* ref and in the working tree. A directory's sides are not its bytes:
|
|
463
|
+
* they differ because a file under it changed.
|
|
464
|
+
*/
|
|
465
|
+
readonly extras?: ReadonlyMap<
|
|
466
|
+
string,
|
|
467
|
+
{ readonly before: Uint8Array | null; readonly after: Uint8Array | null }
|
|
468
|
+
>
|
|
429
469
|
}
|
|
430
470
|
|
|
431
471
|
export interface FingerprintContext extends BaseContext {
|
|
@@ -463,7 +503,7 @@ export interface ScheduleHookContext extends BaseContext {
|
|
|
463
503
|
/** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
|
|
464
504
|
export interface CacheContext extends BaseContext {
|
|
465
505
|
/**
|
|
466
|
-
* The run's
|
|
506
|
+
* The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none. Core already keys a scoped run's
|
|
467
507
|
* writes apart; a remote reads it to pick a credential or check a token's
|
|
468
508
|
* claims (an OIDC branch) for every call it makes.
|
|
469
509
|
*/
|
|
@@ -695,6 +735,8 @@ export interface InstallPluginsArgs {
|
|
|
695
735
|
ci?: boolean
|
|
696
736
|
/** Default null. */
|
|
697
737
|
branch?: string | null
|
|
738
|
+
/** The run's secrets; default: the OS keychain and this terminal. */
|
|
739
|
+
secrets?: SecretBroker
|
|
698
740
|
/** Each setup as the step `<plugin>:setup`; default: untimed. */
|
|
699
741
|
step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
700
742
|
}
|
|
@@ -726,6 +768,7 @@ export class PluginSetupError extends UserError {
|
|
|
726
768
|
export async function installPlugins(args: InstallPluginsArgs): Promise<() => void> {
|
|
727
769
|
const { plugins, bus, workspaceRoot, cacheDir } = args
|
|
728
770
|
const warn = args.warn ?? ((m) => console.error(m))
|
|
771
|
+
const secrets = args.secrets ?? new SecretBroker(args.ci ?? false, warn)
|
|
729
772
|
const disposers: Array<() => void> = []
|
|
730
773
|
// By object, not name: one package can export two plugins.
|
|
731
774
|
const disabled = new Set<Plugin>()
|
|
@@ -754,6 +797,7 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
754
797
|
cacheDir,
|
|
755
798
|
warn,
|
|
756
799
|
cache: args.cache?.(plugin.name) ?? absentPluginCache,
|
|
800
|
+
...secrets.for(plugin.name),
|
|
757
801
|
ci: args.ci ?? false,
|
|
758
802
|
branch: args.branch ?? null,
|
|
759
803
|
// A subscription made past the hooks leaves with the run like a
|