@vzn/vx 0.0.594 → 0.0.596
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 +1 -1
- package/package.json +5 -5
- package/src/bin.ts +26 -11
- package/src/cache/archive.ts +2 -2
- package/src/cache/cache.ts +235 -174
- package/src/cache/chained-cache.ts +25 -7
- package/src/cache/config-evals.ts +19 -4
- package/src/cache/git-inputs.ts +19 -8
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +122 -49
- package/src/cache/layer.ts +7 -5
- package/src/cache/layered-cache.ts +25 -2
- package/src/cache/policy.ts +5 -0
- package/src/cache/tar-stream.ts +1 -1
- package/src/cache/zstd.ts +10 -4
- package/src/cli/cache.ts +11 -15
- package/src/cli/completions.ts +42 -6
- package/src/cli/foreign-flags.ts +26 -5
- package/src/cli/help.ts +5 -3
- package/src/cli/init.ts +1 -1
- package/src/cli/last.ts +4 -1
- package/src/cli/plan-format.ts +9 -0
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +60 -42
- package/src/cli/select.ts +30 -3
- package/src/cli/show.ts +42 -3
- package/src/cli/watch-filter.ts +77 -13
- package/src/cli/watch-fs.ts +15 -7
- package/src/cli/watch-judge.ts +16 -9
- package/src/cli/watch.ts +22 -15
- package/src/config.ts +26 -26
- package/src/exec/env.ts +5 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/proc-sample.ts +6 -2
- package/src/exec/runner.ts +98 -40
- package/src/exec/sandbox-paths.ts +12 -6
- package/src/exec/sandbox-runtime.ts +136 -44
- package/src/exec/sandbox-violations.ts +58 -11
- package/src/graph/index.ts +1 -0
- package/src/graph/scheduler.ts +29 -13
- package/src/graph/task-graph.ts +134 -25
- package/src/index.ts +6 -0
- package/src/orchestrator/admission.ts +12 -3
- package/src/orchestrator/events.ts +10 -1
- package/src/orchestrator/execute-task.ts +65 -20
- package/src/orchestrator/failure-mode.ts +8 -2
- package/src/orchestrator/framed-output.ts +3 -3
- package/src/orchestrator/hit-restore.ts +6 -3
- package/src/orchestrator/local-shortcircuit.ts +4 -1
- package/src/orchestrator/lockfile-claim.ts +43 -46
- package/src/orchestrator/logger.ts +11 -6
- package/src/orchestrator/metrics.ts +19 -8
- package/src/orchestrator/miss-save.ts +2 -2
- package/src/orchestrator/options.ts +7 -0
- package/src/orchestrator/plan.ts +28 -4
- package/src/orchestrator/plugin-host.ts +25 -3
- package/src/orchestrator/plugin.ts +39 -17
- package/src/orchestrator/prepare.ts +13 -4
- package/src/orchestrator/run-artifacts.ts +2 -1
- package/src/orchestrator/run.ts +72 -12
- package/src/orchestrator/sandbox-request.ts +65 -9
- package/src/orchestrator/save-lane.ts +5 -5
- package/src/orchestrator/signals.ts +5 -2
- package/src/orchestrator/summary.ts +16 -5
- package/src/orchestrator/telemetry.ts +16 -7
- package/src/util/index.ts +7 -1
- package/src/util/paths.ts +23 -3
- package/src/util/secret-mask.ts +36 -0
- package/src/util/settle.ts +15 -0
- package/src/workspace/affected.ts +30 -8
- package/src/workspace/config-cache.ts +25 -4
- package/src/workspace/config-imports.ts +8 -6
- package/src/workspace/config-schema.ts +74 -9
- package/src/workspace/filter.ts +19 -0
- package/src/workspace/migrate-scripts.ts +5 -3
- package/src/workspace/migration.ts +1 -1
- package/src/workspace/package-graph.ts +15 -3
- package/src/workspace/project-loader.ts +76 -2
- package/src/workspace/repo-id.ts +42 -4
- package/src/workspace/workspace.ts +98 -20
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// The digests are memoised on disk under the cache dir by the file's
|
|
11
11
|
// xxh3, so a warm run pays one read + hash + a small JSON read, never a
|
|
12
|
-
// parse; within one process (a run, a watch cycle) the file's size
|
|
13
|
-
// mtime gate even the read; and per run the read happens once (a WeakMap
|
|
12
|
+
// parse; within one process (a run, a watch cycle) the file's size,
|
|
13
|
+
// mtime, ctime and inode gate even the read; and per run the read happens once (a WeakMap
|
|
14
14
|
// on the context core hands every `key` call of a run).
|
|
15
15
|
|
|
16
16
|
import path from 'node:path'
|
|
@@ -87,18 +87,33 @@ async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
/**
|
|
91
|
+
* A file's identity for the in-process gate, as `Cache.hashFile`'s memo
|
|
92
|
+
* keys one: size and mtime alone kept the digests of a lockfile or patch
|
|
93
|
+
* replaced by another of the same size with its mtime kept (`cp -p`,
|
|
94
|
+
* `tar -x`), and no task sets a ctime.
|
|
95
|
+
*/
|
|
96
|
+
function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: number }): string {
|
|
97
|
+
return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
|
|
101
|
+
return Promise.all(
|
|
102
|
+
rels.map((rel) => stat(path.join(workspaceRoot, rel)).then(identityOf, () => 'gone')),
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Each file's identity, taken BEFORE its hash, so a write between the two re-reads it. */
|
|
107
|
+
async function hashExtras(
|
|
91
108
|
workspaceRoot: string,
|
|
92
109
|
rels: readonly string[],
|
|
93
|
-
): Promise<
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
() => ({ size: -1, mtimeMs: -1 }),
|
|
99
|
-
),
|
|
100
|
-
),
|
|
110
|
+
): Promise<{ hashes: Record<string, string>; stats: Record<string, string> }> {
|
|
111
|
+
const ids = await statsOf(workspaceRoot, rels)
|
|
112
|
+
const stats = Object.fromEntries(rels.map((rel, i) => [rel, ids[i]!] as const))
|
|
113
|
+
const hashes = Object.fromEntries(
|
|
114
|
+
await Promise.all(rels.map(async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const)),
|
|
101
115
|
)
|
|
116
|
+
return { hashes, stats }
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks {
|
|
@@ -108,15 +123,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
108
123
|
if (scope !== 'project' && scope !== 'workspace') {
|
|
109
124
|
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
110
125
|
}
|
|
111
|
-
// Per workspace root: what the last read saw.
|
|
112
|
-
// re-read; the content hash decides whether the digests are current.
|
|
126
|
+
// Per workspace root: what the last read saw. Each file's identity gates
|
|
127
|
+
// a re-read; the content hash decides whether the digests are current.
|
|
113
128
|
const seen = new Map<
|
|
114
129
|
string,
|
|
115
130
|
{
|
|
116
|
-
|
|
117
|
-
mtimeMs: number
|
|
131
|
+
identity: string
|
|
118
132
|
extras: readonly string[]
|
|
119
|
-
extraStats:
|
|
133
|
+
extraStats: Readonly<Record<string, string>>
|
|
120
134
|
digests: Digests
|
|
121
135
|
}
|
|
122
136
|
>()
|
|
@@ -125,23 +139,18 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
125
139
|
|
|
126
140
|
const load = async (workspaceRoot: string, cacheDir: string): Promise<Digests> => {
|
|
127
141
|
const full = path.join(workspaceRoot, file)
|
|
128
|
-
let
|
|
142
|
+
let identity: string
|
|
129
143
|
try {
|
|
130
|
-
|
|
144
|
+
identity = identityOf(await stat(full))
|
|
131
145
|
} catch {
|
|
132
146
|
const digests: Digests = { lock: '', importers: new Map() }
|
|
133
|
-
seen.set(workspaceRoot, {
|
|
147
|
+
seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
|
|
134
148
|
return digests
|
|
135
149
|
}
|
|
136
150
|
const last = seen.get(workspaceRoot)
|
|
137
|
-
if (last !== undefined && last.
|
|
151
|
+
if (last !== undefined && last.identity === identity) {
|
|
138
152
|
const now = await statsOf(workspaceRoot, last.extras)
|
|
139
|
-
if (
|
|
140
|
-
now.every(
|
|
141
|
-
(s, i) =>
|
|
142
|
-
s.size === last.extraStats[i]!.size && s.mtimeMs === last.extraStats[i]!.mtimeMs,
|
|
143
|
-
)
|
|
144
|
-
) {
|
|
153
|
+
if (now.every((s, i) => s === last.extraStats[last.extras[i]!])) {
|
|
145
154
|
return last.digests
|
|
146
155
|
}
|
|
147
156
|
}
|
|
@@ -152,28 +161,22 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
152
161
|
// re-hashes the files the memo names and never parses to learn them.
|
|
153
162
|
const memo = await readMemo(memoFile, version, lockHash)
|
|
154
163
|
let extras: Record<string, string> = {}
|
|
164
|
+
let extraStats: Record<string, string> = {}
|
|
155
165
|
let importers: ReadonlyMap<string, string> | undefined
|
|
156
166
|
if (memo !== undefined) {
|
|
157
|
-
const now = Object.
|
|
158
|
-
|
|
159
|
-
Object.keys(memo.extras).map(
|
|
160
|
-
async (rel) => [rel, await hashExtra(workspaceRoot, rel)] as const,
|
|
161
|
-
),
|
|
162
|
-
),
|
|
163
|
-
)
|
|
164
|
-
if (Object.entries(memo.extras).every(([rel, h]) => now[rel] === h)) {
|
|
167
|
+
const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
|
|
168
|
+
if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
|
|
165
169
|
extras = memo.extras
|
|
170
|
+
extraStats = now.stats
|
|
166
171
|
importers = memo.importers
|
|
167
172
|
}
|
|
168
173
|
}
|
|
169
174
|
if (importers === undefined) {
|
|
170
175
|
const text = decode(bytes)
|
|
171
176
|
const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
),
|
|
176
|
-
)
|
|
177
|
+
const now = await hashExtras(workspaceRoot, rels)
|
|
178
|
+
extras = now.hashes
|
|
179
|
+
extraStats = now.stats
|
|
177
180
|
importers =
|
|
178
181
|
scope === 'workspace'
|
|
179
182
|
? new Map<string, string>()
|
|
@@ -191,13 +194,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
191
194
|
? lockHash
|
|
192
195
|
: xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
|
|
193
196
|
const digests: Digests = { lock, importers }
|
|
194
|
-
seen.set(workspaceRoot, {
|
|
195
|
-
size: st.size,
|
|
196
|
-
mtimeMs: st.mtimeMs,
|
|
197
|
-
extras: rels,
|
|
198
|
-
extraStats: await statsOf(workspaceRoot, rels),
|
|
199
|
-
digests,
|
|
200
|
-
})
|
|
197
|
+
seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
|
|
201
198
|
return digests
|
|
202
199
|
}
|
|
203
200
|
const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
|
|
@@ -23,7 +23,7 @@ import { formatDuration, formatSummarySection, neverStarted, type RunContext } f
|
|
|
23
23
|
import { isGroupTask } from '../graph/index.js'
|
|
24
24
|
import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
|
|
25
25
|
import { isCacheHit } from './telemetry.js'
|
|
26
|
-
import { failedLabel, outcomeWord } from './events.js'
|
|
26
|
+
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
27
27
|
|
|
28
28
|
export interface Logger {
|
|
29
29
|
/** Header / footer / status text. Written verbatim, one trailing \n added. */
|
|
@@ -139,8 +139,7 @@ function ghaFence(body: string, token: string): string {
|
|
|
139
139
|
return `::stop-commands::${token}\n${body}::${token}::\n`
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
const cacheWordOf = (
|
|
143
|
-
n.config.cache === undefined ? 'no-cache' : 'miss'
|
|
142
|
+
const cacheWordOf = (o: TaskOutcome): 'miss' | 'no-cache' => (ranNoCache(o) ? 'no-cache' : 'miss')
|
|
144
143
|
|
|
145
144
|
export function resolveOutputView(
|
|
146
145
|
options: {
|
|
@@ -440,7 +439,7 @@ export function defaultLogger(
|
|
|
440
439
|
(view.mode === 'focused' && isPrimary(node) && !isGroupTask(node) && requestedCount <= 1)
|
|
441
440
|
|
|
442
441
|
const failureRow = (node: TaskNode, outcome: TaskOutcome): string =>
|
|
443
|
-
formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(
|
|
442
|
+
formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(outcome)) +
|
|
444
443
|
flakyNote(outcome, colors)
|
|
445
444
|
|
|
446
445
|
return {
|
|
@@ -453,6 +452,12 @@ export function defaultLogger(
|
|
|
453
452
|
// A kept server's held partial line is older than this line: held
|
|
454
453
|
// past it, a server's last words printed below its own exit notice.
|
|
455
454
|
flushKept()
|
|
455
|
+
// A live-streamed server keeps writing after its frame closes, so a
|
|
456
|
+
// summary row or "exited with code" can follow its partial line.
|
|
457
|
+
if (streamMidLine) {
|
|
458
|
+
writer.write('\n')
|
|
459
|
+
streamMidLine = false
|
|
460
|
+
}
|
|
456
461
|
writer.write(`${line}\n`)
|
|
457
462
|
},
|
|
458
463
|
runStart(info) {
|
|
@@ -654,7 +659,7 @@ export function defaultLogger(
|
|
|
654
659
|
done++
|
|
655
660
|
if (outcome.status === 'failed') {
|
|
656
661
|
failed++
|
|
657
|
-
if (
|
|
662
|
+
if (ranNoCache(outcome)) noCache++
|
|
658
663
|
} else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
|
|
659
664
|
// A persistent task's outcome arrives at READY; the child
|
|
660
665
|
// keeps running until the orchestrator SIGTERMs it at run
|
|
@@ -678,7 +683,7 @@ export function defaultLogger(
|
|
|
678
683
|
switch (outcome.status) {
|
|
679
684
|
case 'success':
|
|
680
685
|
succeeded++
|
|
681
|
-
if (
|
|
686
|
+
if (ranNoCache(outcome)) noCache++
|
|
682
687
|
break
|
|
683
688
|
case 'cache-hit':
|
|
684
689
|
if (outcome.restored === false) upToDate++
|
|
@@ -407,11 +407,18 @@ function unchangedKeyNote(
|
|
|
407
407
|
at: number
|
|
408
408
|
} | null
|
|
409
409
|
)?.at
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
// (admission.ts), which runs a task downstream of
|
|
413
|
-
// saves it. Blaming `--no-cache` / `--force` named a
|
|
414
|
-
|
|
410
|
+
// The previous run executed the task and succeeded with the cache
|
|
411
|
+
// writable beside a failure, and no entry older than this run holds the
|
|
412
|
+
// key: the continue-taint (admission.ts), which runs a task downstream of
|
|
413
|
+
// a failure and never saves it. Blaming `--no-cache` / `--force` named a
|
|
414
|
+
// flag nobody passed, and a prune when this run saved the key named an
|
|
415
|
+
// eviction that never happened.
|
|
416
|
+
if (
|
|
417
|
+
(createdAt === undefined || createdAt >= this_.startedAt) &&
|
|
418
|
+
this_.status === 'success' &&
|
|
419
|
+
prev.status === 'success' &&
|
|
420
|
+
prev.cacheHit === 0
|
|
421
|
+
) {
|
|
415
422
|
const failedWithWrites = (id: string | null): number => {
|
|
416
423
|
if (id === null) return 0
|
|
417
424
|
const row = db
|
|
@@ -420,9 +427,13 @@ function unchangedKeyNote(
|
|
|
420
427
|
const writes = row?.p.split(',').some((axis) => axis === 'lW' || axis === 'rW') === true
|
|
421
428
|
return writes ? (row?.f ?? 0) : 0
|
|
422
429
|
}
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
430
|
+
const before = failedWithWrites(prev.runId)
|
|
431
|
+
if (before > 0) {
|
|
432
|
+
const now = failedWithWrites(runId)
|
|
433
|
+
if (createdAt === undefined && now > 0) {
|
|
434
|
+
return `cache key unchanged — neither run saved it: each ran beside a failed task (${now} failed in this one), and a task run past a failed dependency (--continue) is never cached`
|
|
435
|
+
}
|
|
436
|
+
return `cache key unchanged — the previous run on this key ran beside a failed task (${before} failed in it) and was not saved: a task run past a failed dependency (--continue) is never cached, so there was nothing to hit`
|
|
426
437
|
}
|
|
427
438
|
}
|
|
428
439
|
if (createdAt !== undefined && createdAt >= this_.startedAt) {
|
|
@@ -70,8 +70,8 @@ export interface SaveMissArgs {
|
|
|
70
70
|
nestedProjectDirs: string[]
|
|
71
71
|
gitFilesCache?: GitFilesCache | undefined
|
|
72
72
|
/** Declared `cache.outputs.files` / `.workspaceFiles`. */
|
|
73
|
-
outputs: string[]
|
|
74
|
-
wsOutputs: string[]
|
|
73
|
+
outputs: readonly string[]
|
|
74
|
+
wsOutputs: readonly string[]
|
|
75
75
|
/**
|
|
76
76
|
* An ADDITIVE task's own set (item 588): the files its run added or
|
|
77
77
|
* changed under its declared outputs, resolved by execute-task against
|
|
@@ -171,6 +171,13 @@ export interface RunOptions {
|
|
|
171
171
|
* else is treated as the literal file path (cwd-relative).
|
|
172
172
|
*/
|
|
173
173
|
summarize?: string
|
|
174
|
+
/**
|
|
175
|
+
* Text printed just above the run's footer, given the outcomes the
|
|
176
|
+
* footer counts, after the `summaryTable`: nothing prints below the
|
|
177
|
+
* footer (owner), so a caller's end-of-run report (`--report`) rides
|
|
178
|
+
* here. Not called on a refused run.
|
|
179
|
+
*/
|
|
180
|
+
beforeFooter?: (outcomes: readonly TaskOutcome[], ok: boolean) => string
|
|
174
181
|
/**
|
|
175
182
|
* If set, write a Chrome-trace JSON profile of the run's wallclock
|
|
176
183
|
* spans. Path is cwd-relative. Default `profile.json` is selected
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -12,8 +12,15 @@
|
|
|
12
12
|
// side-effect-free pure probes.
|
|
13
13
|
|
|
14
14
|
import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
|
|
15
|
-
import { FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
-
import {
|
|
15
|
+
import { cachesNothing, FULL_CACHE_POLICY } from '../cache/index.js'
|
|
16
|
+
import { ranNoCache } from './events.js'
|
|
17
|
+
import {
|
|
18
|
+
idleServers,
|
|
19
|
+
isGroupTask,
|
|
20
|
+
runGraph,
|
|
21
|
+
type TaskNode,
|
|
22
|
+
type TaskOutcome,
|
|
23
|
+
} from '../graph/index.js'
|
|
17
24
|
import type { HistoryProvider } from './history.js'
|
|
18
25
|
import { computeGroupKey, computeTaskHash } from './task-hash.js'
|
|
19
26
|
import { keyUpstream } from './upstream.js'
|
|
@@ -24,6 +31,7 @@ export type CacheStatus =
|
|
|
24
31
|
| 'miss' // caching enabled but no entry — would execute
|
|
25
32
|
| 'no-cache' // task opts out of caching (no `cache` block) or --no-cache
|
|
26
33
|
| 'group' // no `exec`; just an aggregator
|
|
34
|
+
| 'not-started' // a server every dependant of which restores early: the run never spawns it
|
|
27
35
|
|
|
28
36
|
export interface PlannedTask {
|
|
29
37
|
node: TaskNode
|
|
@@ -108,6 +116,12 @@ export interface PlanArgs {
|
|
|
108
116
|
history?: HistoryProvider
|
|
109
117
|
/** Placement lookup (`placement.ts`): >1 executor declared, or a `noop` task. */
|
|
110
118
|
executorOf?: (id: string) => string | undefined
|
|
119
|
+
/**
|
|
120
|
+
* The tasks the run's short-circuit would restore ahead of their deps on
|
|
121
|
+
* a local hit (stable key, no workspace-output reach). A server whose
|
|
122
|
+
* every dependant is one of those and a local hit is never spawned.
|
|
123
|
+
*/
|
|
124
|
+
restorable?: ReadonlySet<string>
|
|
111
125
|
}
|
|
112
126
|
|
|
113
127
|
/**
|
|
@@ -156,9 +170,8 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
156
170
|
// policy-aware cache layer, which itself respects read gating.
|
|
157
171
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
158
172
|
const reads = policy.localRead || policy.remoteRead
|
|
159
|
-
const writes = policy.localWrite || policy.remoteWrite
|
|
160
173
|
let status: CacheStatus
|
|
161
|
-
if (node
|
|
174
|
+
if (ranNoCache({ node, cacheOff: cachesNothing(policy) })) {
|
|
162
175
|
status = 'no-cache'
|
|
163
176
|
} else if (!reads) {
|
|
164
177
|
status = 'miss'
|
|
@@ -171,6 +184,17 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
171
184
|
},
|
|
172
185
|
})
|
|
173
186
|
|
|
187
|
+
if (args.restorable !== undefined) {
|
|
188
|
+
const restoreTier = new Set(
|
|
189
|
+
[...args.restorable].filter((id) => cacheStatusById.get(id) === 'hit-local'),
|
|
190
|
+
)
|
|
191
|
+
const dependents = new Map<string, string[]>()
|
|
192
|
+
for (const node of args.nodes.values())
|
|
193
|
+
for (const dep of node.deps) dependents.set(dep, [...(dependents.get(dep) ?? []), node.id])
|
|
194
|
+
for (const id of idleServers(args.nodes, dependents, restoreTier))
|
|
195
|
+
cacheStatusById.set(id, 'not-started')
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
const tasks: PlannedTask[] = []
|
|
175
199
|
for (const id of args.nodes.keys()) {
|
|
176
200
|
const o = outcomes.get(id)
|
|
@@ -107,6 +107,25 @@ export function hasHook(
|
|
|
107
107
|
return false
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* An in-place stage reads nothing a hook returns: Vite's `config` hook
|
|
112
|
+
* returns a partial config, and one written that way here loaded, changed
|
|
113
|
+
* nothing and said nothing. Returning the object it was handed is harmless.
|
|
114
|
+
*/
|
|
115
|
+
function assertEditedInPlace(
|
|
116
|
+
plugin: VxPlugin,
|
|
117
|
+
hook: 'config' | 'project' | 'graph',
|
|
118
|
+
returned: unknown,
|
|
119
|
+
edited: unknown,
|
|
120
|
+
what: string,
|
|
121
|
+
): void {
|
|
122
|
+
if (returned === undefined || returned === edited) return
|
|
123
|
+
throw new UserError(
|
|
124
|
+
`plugin '${plugin.name}' failed in ${hook}: returned ${describeValue(returned)}, which core ` +
|
|
125
|
+
`ignores — edit ${what} in place`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
110
129
|
/** `config` stage: every plugin edits the workspace config in place. */
|
|
111
130
|
export async function applyConfigHooks(
|
|
112
131
|
plugins: readonly VxPlugin[],
|
|
@@ -117,7 +136,8 @@ export async function applyConfigHooks(
|
|
|
117
136
|
): Promise<void> {
|
|
118
137
|
for (const plugin of plugins) {
|
|
119
138
|
if (plugin.config === undefined) continue
|
|
120
|
-
await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
|
|
139
|
+
const returned = await safe(plugin, 'config', () => plugin.config!(workspace, ctx))
|
|
140
|
+
assertEditedInPlace(plugin, 'config', returned, workspace, 'the workspace config')
|
|
121
141
|
afterEach?.(plugin)
|
|
122
142
|
}
|
|
123
143
|
}
|
|
@@ -158,7 +178,8 @@ export async function applyProjectHooks(
|
|
|
158
178
|
): Promise<void> {
|
|
159
179
|
for (const plugin of plugins) {
|
|
160
180
|
if (plugin.project === undefined) continue
|
|
161
|
-
await safe(plugin, 'project', () => plugin.project!(config, ctx))
|
|
181
|
+
const returned = await safe(plugin, 'project', () => plugin.project!(config, ctx))
|
|
182
|
+
assertEditedInPlace(plugin, 'project', returned, config, "the project's config")
|
|
162
183
|
afterEach?.(plugin)
|
|
163
184
|
}
|
|
164
185
|
}
|
|
@@ -184,7 +205,8 @@ export async function applyGraphHooks(
|
|
|
184
205
|
let last: VxPlugin | undefined
|
|
185
206
|
for (const plugin of plugins) {
|
|
186
207
|
if (plugin.graph === undefined) continue
|
|
187
|
-
await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
|
|
208
|
+
const returned = await safe(plugin, 'graph', () => plugin.graph!(nodes, ctx))
|
|
209
|
+
assertEditedInPlace(plugin, 'graph', returned, nodes, 'the task graph')
|
|
188
210
|
await safe(plugin, 'graph', () => checkNodeShapes(nodes))
|
|
189
211
|
afterEach?.(plugin)
|
|
190
212
|
last = plugin
|
|
@@ -142,9 +142,9 @@ export interface VxPlugin {
|
|
|
142
142
|
* many times per run, so it must be cheap and synchronous; a throw, or
|
|
143
143
|
* an answer that is a Promise, is reported once and the plugin admits
|
|
144
144
|
* from then on — a policy never
|
|
145
|
-
* breaks a run. Restore-tier hits
|
|
146
|
-
* local resources
|
|
147
|
-
* must admit. Core keeps no notion of what a task needs: a plugin that
|
|
145
|
+
* breaks a run. Restore-tier hits, tasks on an executor pool and groups
|
|
146
|
+
* hold no local resources: never asked, never in `ctx.running`. When
|
|
147
|
+
* several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
|
|
148
148
|
* packs memory or CPU learns or declares the numbers itself
|
|
149
149
|
* (`@vzn/vx-schedule-history` packs what past executions used).
|
|
150
150
|
*/
|
|
@@ -341,7 +341,12 @@ export interface FingerprintContext extends BaseContext {
|
|
|
341
341
|
}
|
|
342
342
|
|
|
343
343
|
export interface AdmitContext {
|
|
344
|
-
/**
|
|
344
|
+
/**
|
|
345
|
+
* The tasks holding a worker on this machine right now, in dispatch
|
|
346
|
+
* order. A persistent task leaves at ready, when it gives its worker
|
|
347
|
+
* back: it runs until the graph ends, so a policy that counted it would
|
|
348
|
+
* hold its dependants with no completion left to ask again.
|
|
349
|
+
*/
|
|
345
350
|
readonly running: readonly TaskNode[]
|
|
346
351
|
/** The run's worker count — the ceiling the count gate already applies. */
|
|
347
352
|
readonly concurrency: number
|
|
@@ -440,7 +445,8 @@ function pluginPackageName(dir: string): string {
|
|
|
440
445
|
for (let d = dir; ;) {
|
|
441
446
|
let text: string | undefined
|
|
442
447
|
try {
|
|
443
|
-
|
|
448
|
+
// A byte-order mark is stripped, as npm, Node and discovery strip it.
|
|
449
|
+
text = readFileSync(path.join(d, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
|
|
444
450
|
} catch {
|
|
445
451
|
/* not here; look one level up */
|
|
446
452
|
}
|
|
@@ -600,7 +606,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
600
606
|
const { plugins, bus, workspaceRoot, cacheDir } = args
|
|
601
607
|
const warn = args.warn ?? ((m) => console.error(m))
|
|
602
608
|
const disposers: Array<() => void> = []
|
|
603
|
-
|
|
609
|
+
// By object, not name: one package can export two plugins.
|
|
610
|
+
const disabled = new Set<Plugin>()
|
|
604
611
|
|
|
605
612
|
for (const plugin of plugins) {
|
|
606
613
|
if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
|
|
@@ -614,6 +621,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
614
621
|
throw new UserError(`plugin '${plugin.name}' setup is not a function`)
|
|
615
622
|
}
|
|
616
623
|
|
|
624
|
+
const fail = (where: string, err: unknown): void => {
|
|
625
|
+
if (disabled.has(plugin)) return
|
|
626
|
+
disabled.add(plugin)
|
|
627
|
+
warn(
|
|
628
|
+
`[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
629
|
+
)
|
|
630
|
+
}
|
|
617
631
|
const ctx: PluginContext = {
|
|
618
632
|
workspaceRoot,
|
|
619
633
|
cacheDir,
|
|
@@ -624,7 +638,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
624
638
|
bus: {
|
|
625
639
|
emit: (event) => bus.emit(event),
|
|
626
640
|
subscribe(subscriber) {
|
|
627
|
-
|
|
641
|
+
// Isolated like an `on` handler: the bus itself swallows a throw.
|
|
642
|
+
const dispose = bus.subscribe((event) => {
|
|
643
|
+
if (disabled.has(plugin)) return
|
|
644
|
+
let ret: unknown
|
|
645
|
+
try {
|
|
646
|
+
ret = subscriber(event)
|
|
647
|
+
} catch (err) {
|
|
648
|
+
fail('a bus subscriber', err)
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
if (ret instanceof Promise) ret.catch((err: unknown) => fail('a bus subscriber', err))
|
|
652
|
+
})
|
|
628
653
|
disposers.push(dispose)
|
|
629
654
|
return dispose
|
|
630
655
|
},
|
|
@@ -637,15 +662,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
637
662
|
`ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
|
|
638
663
|
)
|
|
639
664
|
}
|
|
640
|
-
const
|
|
641
|
-
if (disabled.has(plugin.name)) return
|
|
642
|
-
disabled.add(plugin.name)
|
|
643
|
-
warn(
|
|
644
|
-
`[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
|
|
645
|
-
)
|
|
646
|
-
}
|
|
665
|
+
const onFail = (err: unknown): void => fail(hook, err)
|
|
647
666
|
const dispose = bus.subscribe((event) => {
|
|
648
|
-
if (disabled.has(plugin
|
|
667
|
+
if (disabled.has(plugin)) return
|
|
649
668
|
// Not awaited: the bus is synchronous and plugin work runs off the
|
|
650
669
|
// critical path. A handler's rejection is caught like its throw.
|
|
651
670
|
let ret: unknown
|
|
@@ -680,10 +699,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
680
699
|
break
|
|
681
700
|
}
|
|
682
701
|
} catch (err) {
|
|
683
|
-
|
|
702
|
+
onFail(err)
|
|
684
703
|
return
|
|
685
704
|
}
|
|
686
|
-
if (ret instanceof Promise) ret.catch(
|
|
705
|
+
if (ret instanceof Promise) ret.catch(onFail)
|
|
687
706
|
})
|
|
688
707
|
disposers.push(dispose)
|
|
689
708
|
},
|
|
@@ -692,6 +711,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
|
|
|
692
711
|
try {
|
|
693
712
|
await plugin.setup(ctx)
|
|
694
713
|
} catch (err) {
|
|
714
|
+
// Earlier plugins' subscriptions too: the caller never gets the
|
|
715
|
+
// disposer, and a bus that outlives the run would keep them.
|
|
716
|
+
for (const d of disposers) d()
|
|
695
717
|
throw new PluginSetupError(
|
|
696
718
|
plugin,
|
|
697
719
|
`plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -368,8 +368,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
368
368
|
? options.projects.filter((p) => projects.has(p))
|
|
369
369
|
: [...projects.keys()]
|
|
370
370
|
|
|
371
|
-
|
|
372
|
-
|
|
371
|
+
// A bare name skips the default `build`: it runs nothing, and matched,
|
|
372
|
+
// `vx run build` in a project declaring none ran 0 tasks, exit 0 (X-102).
|
|
373
|
+
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
374
|
+
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
375
|
+
let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
|
|
373
376
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
374
377
|
// A name a loaded project declares is no typo, whether or not the load
|
|
375
378
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -750,14 +753,20 @@ async function declaredNowhere(
|
|
|
750
753
|
return undeclaredIn(unresolved, all.projects)
|
|
751
754
|
}
|
|
752
755
|
|
|
753
|
-
/**
|
|
756
|
+
/**
|
|
757
|
+
* The bare names in `unresolved` no project in `projects` declares; `a#b`
|
|
758
|
+
* forms pass through. Every project has a `build`, its default one if
|
|
759
|
+
* nothing declares it, and a bare name does not select that (X-102).
|
|
760
|
+
*/
|
|
754
761
|
function undeclaredIn(
|
|
755
762
|
unresolved: readonly string[],
|
|
756
763
|
projects: LoadedProjects['projects'],
|
|
757
764
|
): string[] {
|
|
758
765
|
const declared = new Set<string>()
|
|
759
766
|
for (const p of projects.values()) {
|
|
760
|
-
for (const t of Object.
|
|
767
|
+
for (const [t, task] of Object.entries(p.config.tasks ?? {})) {
|
|
768
|
+
if (!isDefaultBuild(task)) declared.add(t)
|
|
769
|
+
}
|
|
761
770
|
}
|
|
762
771
|
return unresolved.filter((t) => t.includes('#') || !declared.has(t))
|
|
763
772
|
}
|
|
@@ -18,6 +18,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
|
|
|
18
18
|
import { tallyOutcomes, type Tally } from './tally.js'
|
|
19
19
|
import type { FlakyFinding } from './failure-mode.js'
|
|
20
20
|
import { isCacheHit } from './telemetry.js'
|
|
21
|
+
import { ranNoCache } from './events.js'
|
|
21
22
|
|
|
22
23
|
export interface SummarizeArgs {
|
|
23
24
|
/** Empty string → default path; otherwise the explicit file path. */
|
|
@@ -92,7 +93,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
|
|
|
92
93
|
// the flag a consumer computing a hit rate cannot tell a miss from a
|
|
93
94
|
// task that could never hit (its `hash` is still set — dependents fold
|
|
94
95
|
// it). Present only when true, so every other row is byte-identical.
|
|
95
|
-
...(o
|
|
96
|
+
...(ranNoCache(o) ? { noCache: true } : {}),
|
|
96
97
|
// Present only when the run proved the task flaky: this exact key has
|
|
97
98
|
// both passed and failed on record (this run counted), or the task
|
|
98
99
|
// needed a retry. A consumer gating on `failed` can tell a break from a
|