@vzn/vx 0.0.650 → 0.0.652
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 +7 -23
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/schemas/show.json +14 -20
- package/src/bin.ts +21 -31
- package/src/cache/archive.ts +1 -1
- package/src/cache/cache.ts +221 -30
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/history-stream.ts +134 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/inputs.ts +77 -14
- package/src/cache/layer.ts +17 -6
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +5 -3
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +56 -17
- package/src/cache/schema.ts +15 -2
- package/src/cli/cache.ts +28 -18
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +14 -25
- package/src/cli/index.ts +28 -9
- package/src/cli/info.ts +16 -8
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +246 -204
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +45 -52
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +3 -2
- package/src/cli/watch-set.ts +4 -4
- package/src/cli/watch.ts +94 -52
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/config.ts +47 -30
- package/src/exec/executor.ts +42 -2
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +317 -23
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/definition.ts +16 -3
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +17 -11
- package/src/graph/task-graph.ts +51 -24
- package/src/handoff.ts +10 -7
- package/src/history/failure-mode.ts +24 -1
- package/src/history/history.ts +5 -0
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +14 -6
- package/src/index.ts +7 -3
- package/src/orchestrator/admission.ts +1 -1
- package/src/orchestrator/command-windows.ts +1 -1
- package/src/orchestrator/deferred-outputs.ts +1 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +5 -7
- package/src/orchestrator/excluded-keys.ts +1 -1
- package/src/orchestrator/execute-task.ts +67 -20
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +5 -6
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/keyed-projects.ts +28 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +58 -11
- package/src/orchestrator/persistent-task.ts +5 -1
- package/src/orchestrator/placement.ts +1 -1
- package/src/orchestrator/plan.ts +7 -3
- package/src/orchestrator/plugin-host.ts +67 -2
- package/src/orchestrator/plugin.ts +53 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +62 -13
- package/src/orchestrator/run-keys.ts +1 -1
- package/src/orchestrator/run-records.ts +88 -59
- package/src/orchestrator/run-report.ts +1 -1
- package/src/orchestrator/run.ts +145 -41
- package/src/orchestrator/sandbox-request.ts +52 -9
- package/src/orchestrator/shell-verdict.ts +9 -0
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/stable-keys.ts +17 -18
- package/src/orchestrator/task-artifacts.ts +1 -1
- package/src/orchestrator/task-hash.ts +21 -7
- package/src/orchestrator/telemetry.ts +5 -1
- package/src/report/events.ts +6 -14
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +283 -175
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +74 -39
- package/src/report/plain-output.ts +3 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +105 -136
- package/src/settings.ts +8 -8
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +191 -14
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +21 -3
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +33 -1
- package/src/util/tail.ts +4 -1
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-schema.ts +70 -63
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +36 -1
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +16 -9
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// A run keys a claimed file from what `vx lock` recorded of it in
|
|
2
|
+
// `.vx/dependencies.lock.json`, as it takes configs from the config lock:
|
|
3
|
+
// the digests are read, never derived, so no run parses a lockfile for
|
|
4
|
+
// its key. The record holds only while the file's bytes hash to what was
|
|
5
|
+
// recorded: one that moved (an install) refuses the run before any key is
|
|
6
|
+
// folded, naming the file and `vx lock`. A present file with no record,
|
|
7
|
+
// or no lock at all, refuses when a claimant asks for it: an absent record
|
|
8
|
+
// is never read as "no dependencies". The other files the digests read
|
|
9
|
+
// (`extras`: a `link:` directory, a patch) are handed to the claimant with
|
|
10
|
+
// their recorded hashes: a linked directory is edited with no install, so
|
|
11
|
+
// one that moved is the claimant's to digest anew, not a refusal.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
DEPENDENCIES_LOCK_NAME,
|
|
15
|
+
type DependenciesLock,
|
|
16
|
+
lockKey,
|
|
17
|
+
parseDependenciesLock,
|
|
18
|
+
recordedDigests,
|
|
19
|
+
} from '../workspace/index.js'
|
|
20
|
+
import { UserError, xxh3hex } from '../util/index.js'
|
|
21
|
+
import type { KeyHookContext, LockedFile } from './plugin.js'
|
|
22
|
+
|
|
23
|
+
const refused = (message: string): UserError => new UserError(message, 'VX_E_LOCK')
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `KeyHookContext.locked` for one run, over the claimed files' bytes as
|
|
27
|
+
* the run read them (`readClaimedFiles`; absent ones are missing) and the
|
|
28
|
+
* lock's bytes (null when there is none). Each recorded claimed file is
|
|
29
|
+
* checked first: one whose bytes moved, or that is gone, throws here.
|
|
30
|
+
*/
|
|
31
|
+
export function lockedFiles(
|
|
32
|
+
workspaceRoot: string,
|
|
33
|
+
claimed: readonly string[],
|
|
34
|
+
bytes: ReadonlyMap<string, Uint8Array>,
|
|
35
|
+
lockBytes: Uint8Array | null,
|
|
36
|
+
): KeyHookContext['locked'] {
|
|
37
|
+
const lock: DependenciesLock | null = parseDependenciesLock(lockBytes)
|
|
38
|
+
const records = new Map<string, LockedFile>()
|
|
39
|
+
for (const file of claimed) {
|
|
40
|
+
const rec = lock === null ? undefined : recordedDigests(lock, file)
|
|
41
|
+
if (rec === undefined) continue
|
|
42
|
+
const now = bytes.get(file)
|
|
43
|
+
if (now === undefined || xxh3hex(now) !== rec.hash) {
|
|
44
|
+
throw refused(`${file} changed since \`vx lock\`: run \`vx lock\``)
|
|
45
|
+
}
|
|
46
|
+
records.set(file, {
|
|
47
|
+
extras: rec.extras,
|
|
48
|
+
digest(projectDir) {
|
|
49
|
+
const dir = lockKey(workspaceRoot, projectDir)
|
|
50
|
+
if (!rec.has(dir))
|
|
51
|
+
throw refused(`${dir} is not in ${DEPENDENCIES_LOCK_NAME}: run \`vx lock\``)
|
|
52
|
+
return rec.of(dir)
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
return async (file) => {
|
|
57
|
+
const rec = records.get(file)
|
|
58
|
+
if (rec !== undefined || !bytes.has(file)) return rec
|
|
59
|
+
if (lockBytes === null) throw refused(`no ${DEPENDENCIES_LOCK_NAME}: run \`vx lock\``)
|
|
60
|
+
if (lock === null) throw refused(`${DEPENDENCIES_LOCK_NAME} is invalid, run \`vx lock\``)
|
|
61
|
+
throw refused(`${file} is not in ${DEPENDENCIES_LOCK_NAME}: run \`vx lock\``)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -7,11 +7,18 @@
|
|
|
7
7
|
// fallback for a project the file does not list and the `--affected`
|
|
8
8
|
// diff are one implementation here, so a third lockfile is a parser.
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
10
|
+
// A run's key reads each project's digest from `.vx/dependencies.lock.json`
|
|
11
|
+
// (`KeyHookContext.locked`), never the file: only `vx lock` and
|
|
12
|
+
// `--affected` digest it, and a run whose extra file (a `link:` directory,
|
|
13
|
+
// edited with no install) moved since the lock, as the record is stale
|
|
14
|
+
// for it then. What the `discover` and `project` stages read
|
|
15
|
+
// (the edges, the recorded names) is memoised in cache.db
|
|
16
|
+
// (`lockfile_memos`, one row per lockfile and claimant) by the file's xxh3,
|
|
17
|
+
// so a warm run pays one read + hash + one row read, never a parse; within
|
|
18
|
+
// one process (a run, a watch cycle) the file's size, mtime, ctime and
|
|
19
|
+
// inode gate even the read. A run's read leaves the digests out (most of a
|
|
20
|
+
// parse: 9-25 ms on this repo's bun.lock, against 4-13 for the text), and
|
|
21
|
+
// `vx lock` adds them to the memo.
|
|
15
22
|
|
|
16
23
|
import path from 'node:path'
|
|
17
24
|
import { stat } from 'node:fs/promises'
|
|
@@ -104,21 +111,27 @@ export interface LockfileClaimHooks {
|
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
interface Digests {
|
|
107
|
-
/** xxh3 of the lockfile bytes; '' when the file is absent. */
|
|
114
|
+
/** xxh3 of the lockfile bytes and the extra files' hashes; '' when the file is absent. */
|
|
108
115
|
readonly lock: string
|
|
109
|
-
|
|
116
|
+
/** xxh3 of the lockfile bytes alone. */
|
|
117
|
+
readonly bytes: string
|
|
118
|
+
/** The extra files the digests read. */
|
|
119
|
+
readonly extras: readonly string[]
|
|
120
|
+
/** Each importer's digest; undefined when the read that made this left them out. */
|
|
121
|
+
readonly importers: ReadonlyMap<string, string> | undefined
|
|
110
122
|
readonly edges: ReadonlyMap<string, LockfileImporter>
|
|
111
123
|
readonly recorded: ReadonlyMap<string, ReadonlySet<string>> | undefined
|
|
112
124
|
}
|
|
113
125
|
|
|
114
126
|
/**
|
|
115
|
-
* A root-relative extra file's content hash, '' when absent
|
|
116
|
-
* the root is
|
|
117
|
-
*
|
|
127
|
+
* A root-relative extra file's content hash, '' when absent. A path above
|
|
128
|
+
* the root is read too: a `file:` or `link:` directory beside the checkout
|
|
129
|
+
* is what a task imports, and left unread an edit in it moved no key. One
|
|
130
|
+
* there but unreadable (EACCES, a symlink loop) refuses, as a settings file
|
|
131
|
+
* does: folded as '', it keyed as no file at all.
|
|
118
132
|
*/
|
|
119
|
-
async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
|
|
120
|
-
|
|
121
|
-
const abs = path.join(workspaceRoot, rel)
|
|
133
|
+
export async function hashExtra(workspaceRoot: string, rel: string): Promise<string> {
|
|
134
|
+
const abs = path.resolve(workspaceRoot, rel)
|
|
122
135
|
try {
|
|
123
136
|
return xxh3hex(await Bun.file(abs).bytes())
|
|
124
137
|
} catch (err) {
|
|
@@ -168,7 +181,7 @@ function identityOf(st: { size: number; mtimeMs: number; ctimeMs: number; ino: n
|
|
|
168
181
|
async function statsOf(workspaceRoot: string, rels: readonly string[]): Promise<string[]> {
|
|
169
182
|
return Promise.all(
|
|
170
183
|
rels.map((rel) =>
|
|
171
|
-
stat(path.
|
|
184
|
+
stat(path.resolve(workspaceRoot, rel)).then(
|
|
172
185
|
(st) => (st.isDirectory() ? hashExtra(workspaceRoot, rel) : identityOf(st)),
|
|
173
186
|
() => 'gone',
|
|
174
187
|
),
|
|
@@ -234,8 +247,17 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
234
247
|
}
|
|
235
248
|
// Per run: every `key` call of one run gets the same context object.
|
|
236
249
|
const perRun = new WeakMap<object, Promise<Digests>>()
|
|
250
|
+
const lockedPerRun = new WeakMap<
|
|
251
|
+
object,
|
|
252
|
+
Promise<((dir: string) => string | undefined) | undefined>
|
|
253
|
+
>()
|
|
237
254
|
|
|
238
|
-
|
|
255
|
+
/** `withDigests`: each importer's digest too, which only `vx lock` and `--affected` read. */
|
|
256
|
+
const load = async (
|
|
257
|
+
workspaceRoot: string,
|
|
258
|
+
cacheDir: string,
|
|
259
|
+
withDigests: boolean,
|
|
260
|
+
): Promise<Digests> => {
|
|
239
261
|
const full = path.join(workspaceRoot, file)
|
|
240
262
|
let identity: string
|
|
241
263
|
try {
|
|
@@ -243,6 +265,8 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
243
265
|
} catch {
|
|
244
266
|
const digests: Digests = {
|
|
245
267
|
lock: '',
|
|
268
|
+
bytes: '',
|
|
269
|
+
extras: [],
|
|
246
270
|
importers: new Map(),
|
|
247
271
|
edges: new Map(),
|
|
248
272
|
recorded: undefined,
|
|
@@ -251,7 +275,11 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
251
275
|
return digests
|
|
252
276
|
}
|
|
253
277
|
const last = seen.get(workspaceRoot)
|
|
254
|
-
if (
|
|
278
|
+
if (
|
|
279
|
+
last !== undefined &&
|
|
280
|
+
last.identity === identity &&
|
|
281
|
+
(!withDigests || last.digests.importers !== undefined)
|
|
282
|
+
) {
|
|
255
283
|
const now = await statsOf(workspaceRoot, last.extras)
|
|
256
284
|
if (now.every((s, i) => s === last.extraStats[last.extras[i]!])) {
|
|
257
285
|
if (last.unsaved !== undefined && writeLockfileMemo(cacheDir, file, claimant, last.unsaved))
|
|
@@ -270,17 +298,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
270
298
|
let edges: ReadonlyMap<string, LockfileImporter> = new Map()
|
|
271
299
|
let recorded: ReadonlyMap<string, readonly string[]> | undefined
|
|
272
300
|
let unsaved: LockfileMemo | undefined
|
|
273
|
-
|
|
301
|
+
let served = false
|
|
302
|
+
if (memo !== undefined && (!withDigests || memo.importers !== null)) {
|
|
274
303
|
const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
|
|
275
304
|
if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
|
|
305
|
+
served = true
|
|
276
306
|
extras = memo.extras
|
|
277
307
|
extraStats = now.stats
|
|
278
|
-
importers = new Map(Object.entries(memo.importers))
|
|
308
|
+
importers = memo.importers === null ? undefined : new Map(Object.entries(memo.importers))
|
|
279
309
|
edges = new Map(Object.entries(memo.edges as Record<string, LockfileImporter>))
|
|
280
310
|
recorded = memo.recorded === undefined ? undefined : new Map(Object.entries(memo.recorded))
|
|
281
311
|
}
|
|
282
312
|
}
|
|
283
|
-
if (
|
|
313
|
+
if (!served) {
|
|
284
314
|
const text = decode(bytes)
|
|
285
315
|
const rels = [...new Set(options.extraFiles?.(text) ?? [])].sort()
|
|
286
316
|
const now = await hashExtras(workspaceRoot, rels)
|
|
@@ -289,7 +319,9 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
289
319
|
importers =
|
|
290
320
|
scope === 'workspace'
|
|
291
321
|
? new Map<string, string>()
|
|
292
|
-
:
|
|
322
|
+
: withDigests
|
|
323
|
+
? digest(text, new Map(Object.entries(extras)))
|
|
324
|
+
: undefined
|
|
293
325
|
edges = options.importers?.(text) ?? new Map()
|
|
294
326
|
recorded = options.recorded?.(text)
|
|
295
327
|
if (memoises) {
|
|
@@ -297,7 +329,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
297
329
|
version,
|
|
298
330
|
lock: lockHash,
|
|
299
331
|
extras,
|
|
300
|
-
importers: Object.fromEntries(importers),
|
|
332
|
+
importers: importers === undefined ? null : Object.fromEntries(importers),
|
|
301
333
|
edges: Object.fromEntries(edges),
|
|
302
334
|
...(recorded !== undefined ? { recorded: Object.fromEntries(recorded) } : {}),
|
|
303
335
|
}
|
|
@@ -311,6 +343,8 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
311
343
|
: xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
|
|
312
344
|
const digests: Digests = {
|
|
313
345
|
lock,
|
|
346
|
+
bytes: lockHash,
|
|
347
|
+
extras: rels,
|
|
314
348
|
importers,
|
|
315
349
|
edges,
|
|
316
350
|
recorded:
|
|
@@ -327,10 +361,36 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
327
361
|
})
|
|
328
362
|
return digests
|
|
329
363
|
}
|
|
364
|
+
/**
|
|
365
|
+
* Each project's digest for this run: the lock's, unless an extra file
|
|
366
|
+
* it records moved since (then a digest of the file as it is now, as
|
|
367
|
+
* `vx lock` would record it); undefined when the file is absent.
|
|
368
|
+
*/
|
|
369
|
+
const lockedOnce = (
|
|
370
|
+
ctx: KeyHookContext,
|
|
371
|
+
): Promise<((dir: string) => string | undefined) | undefined> => {
|
|
372
|
+
let p = lockedPerRun.get(ctx)
|
|
373
|
+
if (p === undefined) {
|
|
374
|
+
p = (async () => {
|
|
375
|
+
const rec = await ctx.locked(file)
|
|
376
|
+
if (rec === undefined) return undefined
|
|
377
|
+
const rels = Object.keys(rec.extras)
|
|
378
|
+
const now = await Promise.all(rels.map((rel) => hashExtra(ctx.workspaceRoot, rel)))
|
|
379
|
+
if (now.every((h, i) => h === rec.extras[rels[i]!])) {
|
|
380
|
+
return (dir: string) => rec.digest(dir)
|
|
381
|
+
}
|
|
382
|
+
const digests = await load(ctx.workspaceRoot, ctx.cacheDir, true)
|
|
383
|
+
const importers = digests.importers!
|
|
384
|
+
return (dir: string) => digestFor(importers, importerOf(ctx.workspaceRoot, dir))
|
|
385
|
+
})()
|
|
386
|
+
lockedPerRun.set(ctx, p)
|
|
387
|
+
}
|
|
388
|
+
return p
|
|
389
|
+
}
|
|
330
390
|
const loadOnce = (ctx: KeyHookContext): Promise<Digests> => {
|
|
331
391
|
let p = perRun.get(ctx)
|
|
332
392
|
if (p === undefined) {
|
|
333
|
-
p = load(ctx.workspaceRoot, ctx.cacheDir)
|
|
393
|
+
p = load(ctx.workspaceRoot, ctx.cacheDir, false)
|
|
334
394
|
perRun.set(ctx, p)
|
|
335
395
|
}
|
|
336
396
|
return p
|
|
@@ -378,6 +438,24 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
378
438
|
}
|
|
379
439
|
return names
|
|
380
440
|
},
|
|
441
|
+
// What `vx lock` commits, so `--affected` reads the base's digests
|
|
442
|
+
// instead of parsing the base's file.
|
|
443
|
+
async digests(_file: string, ctx: FingerprintContext) {
|
|
444
|
+
if (scope === 'workspace') return undefined
|
|
445
|
+
const digests = await load(ctx.workspaceRoot, ctx.cacheDir, true)
|
|
446
|
+
if (digests.lock === '') return undefined
|
|
447
|
+
const importers = digests.importers!
|
|
448
|
+
return {
|
|
449
|
+
hash: digests.bytes,
|
|
450
|
+
projects: Object.fromEntries(
|
|
451
|
+
ctx.projects.map((p) => [
|
|
452
|
+
p.name,
|
|
453
|
+
digestFor(importers, importerOf(ctx.workspaceRoot, p.dir)),
|
|
454
|
+
]),
|
|
455
|
+
),
|
|
456
|
+
...(digests.extras.length > 0 ? { extras: digests.extras } : {}),
|
|
457
|
+
}
|
|
458
|
+
},
|
|
381
459
|
...(options.extraFiles !== undefined
|
|
382
460
|
? {
|
|
383
461
|
// The memo records which files these exact bytes name, for this
|
|
@@ -393,19 +471,20 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
393
471
|
: {}),
|
|
394
472
|
},
|
|
395
473
|
async key(task, ctx) {
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
[part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
|
|
474
|
+
if (scope === 'project') {
|
|
475
|
+
const digestOf = await lockedOnce(ctx)
|
|
476
|
+
const digest = digestOf?.(task.projectDir)
|
|
477
|
+
return digest === undefined ? undefined : { [part]: digest }
|
|
401
478
|
}
|
|
479
|
+
const digests = await loadOnce(ctx)
|
|
480
|
+
return digests.lock === '' ? undefined : { [part]: digests.lock }
|
|
402
481
|
},
|
|
403
482
|
async importers(ctx) {
|
|
404
|
-
const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
|
|
483
|
+
const digests = await load(ctx.workspaceRoot, ctx.cacheDir, false)
|
|
405
484
|
return digests.lock === '' ? undefined : digests.edges
|
|
406
485
|
},
|
|
407
486
|
async recorded(ctx) {
|
|
408
|
-
const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
|
|
487
|
+
const digests = await load(ctx.workspaceRoot, ctx.cacheDir, false)
|
|
409
488
|
return digests.lock === '' ? undefined : digests.recorded
|
|
410
489
|
},
|
|
411
490
|
}
|
|
@@ -109,7 +109,7 @@ export interface SaveMissArgs {
|
|
|
109
109
|
workspaceRoot: string
|
|
110
110
|
nestedProjectDirs: string[]
|
|
111
111
|
gitFilesCache?: GitFilesCache | undefined
|
|
112
|
-
/** Declared `
|
|
112
|
+
/** Declared `outputs.files` / `.workspaceFiles`. */
|
|
113
113
|
outputs: readonly string[]
|
|
114
114
|
wsOutputs: readonly string[]
|
|
115
115
|
/**
|
|
@@ -119,7 +119,7 @@ export interface SaveMissArgs {
|
|
|
119
119
|
* would also take the upstream's files the task adds beside.
|
|
120
120
|
*/
|
|
121
121
|
ownOutputFiles?: string[] | undefined
|
|
122
|
-
/** `ownOutputFiles` for `
|
|
122
|
+
/** `ownOutputFiles` for `outputs.workspaceFiles` (A-43). */
|
|
123
123
|
ownWsOutputFiles?: string[] | undefined
|
|
124
124
|
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
125
125
|
captured: readonly TaskInputComponent[]
|
|
@@ -146,6 +146,8 @@ export interface SaveMissArgs {
|
|
|
146
146
|
export interface SaveFacts {
|
|
147
147
|
saveMs?: number
|
|
148
148
|
artifactBytes?: number
|
|
149
|
+
/** The saved files' bytes, as a hit's `outputBytes` counts them: the row's `outputs`. */
|
|
150
|
+
outputBytes?: number
|
|
149
151
|
/** The entry landed: the outcome's `saved`, set only once it has. */
|
|
150
152
|
saved?: true
|
|
151
153
|
}
|
|
@@ -160,19 +162,38 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
160
162
|
// An additive task owns only the files it wrote (item 588), no directory.
|
|
161
163
|
const tree =
|
|
162
164
|
a.ownOutputFiles !== undefined
|
|
163
|
-
? { files: a.ownOutputFiles, emptyDirs: [] }
|
|
165
|
+
? { files: a.ownOutputFiles, emptyDirs: [], special: [] }
|
|
164
166
|
: await resolveOutputTree({
|
|
165
167
|
projectDir: node.projectDir,
|
|
166
168
|
outputs: a.outputs,
|
|
167
169
|
nestedProjectDirs: a.nestedProjectDirs,
|
|
170
|
+
special: true,
|
|
168
171
|
})
|
|
169
172
|
const outputFiles = tree.files
|
|
170
173
|
const wsTree =
|
|
171
174
|
a.ownWsOutputFiles !== undefined
|
|
172
|
-
? { files: a.ownWsOutputFiles, emptyDirs: [] }
|
|
173
|
-
: await resolveWorkspaceOutputTree({
|
|
175
|
+
? { files: a.ownWsOutputFiles, emptyDirs: [], special: [] }
|
|
176
|
+
: await resolveWorkspaceOutputTree({
|
|
177
|
+
workspaceRoot: a.workspaceRoot,
|
|
178
|
+
outputs: a.wsOutputs,
|
|
179
|
+
special: true,
|
|
180
|
+
})
|
|
174
181
|
const wsOutputFiles = wsTree.files
|
|
175
182
|
endResolve()
|
|
183
|
+
// A FIFO, socket or device holds no bytes to store (opening a FIFO waits
|
|
184
|
+
// for a writer, nx#35228), so it stays out of the entry; said, because a
|
|
185
|
+
// hit restores the tree without it.
|
|
186
|
+
const special = [
|
|
187
|
+
...tree.special.map((p) => relPosix(node.projectDir, p)),
|
|
188
|
+
...wsTree.special.map((p) => relPosix(a.workspaceRoot, p)),
|
|
189
|
+
]
|
|
190
|
+
if (special.length > 0) {
|
|
191
|
+
warnOf(log)(
|
|
192
|
+
`[vx] ${node.id}: ${special.join(', ')} ${special.length === 1 ? 'is' : 'are'} not a regular file or symlink ` +
|
|
193
|
+
`(a FIFO, socket or device): vx does not cache ${special.length === 1 ? 'it' : 'them'}, so a hit restores the outputs without ${special.length === 1 ? 'it' : 'them'} ` +
|
|
194
|
+
`— emit a file there, or take ${special.length === 1 ? 'it' : 'them'} back with a '!' entry`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
176
197
|
// The mirror of the input warning: declared outputs that resolve to
|
|
177
198
|
// nothing save an empty artifact, and the next hit "restores" it —
|
|
178
199
|
// the build that ran nowhere looks like a build that ran. `files: []`
|
|
@@ -189,14 +210,14 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
189
210
|
// (project dir === workspace root) the write SUCCEEDS into that
|
|
190
211
|
// scratch and the task exits 0, so this warning is the only thing that
|
|
191
212
|
// says the build produced nothing (item 444). A declared
|
|
192
|
-
// `
|
|
213
|
+
// `outputs` is not a write grant; `exec.sandbox.allow.write` is.
|
|
193
214
|
const sandboxed = node.config.exec?.sandbox !== undefined
|
|
194
215
|
const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
|
|
195
216
|
// A `workspaceFiles` directory linked out of the workspace drops every
|
|
196
217
|
// file (M-65); a project one is refused by the resolver (X-88).
|
|
197
218
|
const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
|
|
198
219
|
warnOf(log)(
|
|
199
|
-
`[vx] ${node.id}:
|
|
220
|
+
`[vx] ${node.id}: outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
|
|
200
221
|
`an empty artifact is saved; a later hit restores nothing` +
|
|
201
222
|
(linkedOut !== undefined
|
|
202
223
|
? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
|
|
@@ -239,6 +260,8 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
239
260
|
},
|
|
240
261
|
})
|
|
241
262
|
endSave()
|
|
263
|
+
const bytes = cache.outputBytes?.(a.hash)
|
|
264
|
+
if (bytes !== undefined) facts.outputBytes = bytes
|
|
242
265
|
if (a.measure === true) {
|
|
243
266
|
facts.saveMs = Math.round(performance.now() - saveStart)
|
|
244
267
|
// No local artifact (local writes off): no size to say.
|
|
@@ -294,9 +317,26 @@ export type UnsavedArgs = Pick<
|
|
|
294
317
|
* A miss that ran here and saves nothing (it failed, the policy writes
|
|
295
318
|
* nothing, an upstream failed, its key no longer held) still wrote its
|
|
296
319
|
* outputs, and a same-project reader after it must see them as it sees a
|
|
297
|
-
* save's: resolved and marked the same way.
|
|
320
|
+
* save's: resolved and marked the same way. Returns their bytes, the
|
|
321
|
+
* row's `outputs`: blank, an unsaved miss read as one that wrote nothing.
|
|
298
322
|
*/
|
|
299
|
-
export async function markUnsaved(a: UnsavedArgs): Promise<
|
|
323
|
+
export async function markUnsaved(a: UnsavedArgs): Promise<number> {
|
|
324
|
+
const { outputFiles, wsOutputFiles } = await resolveUnsaved(a)
|
|
325
|
+
markWritten(a, outputFiles, wsOutputFiles)
|
|
326
|
+
return sizeOf(a.node.projectDir, outputFiles) + sizeOf(a.workspaceRoot, wsOutputFiles)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** An unsaved miss nothing reads after: only the bytes of its outputs, its row's `outputs`. */
|
|
330
|
+
export async function unsavedOutputBytes(
|
|
331
|
+
a: Pick<UnsavedArgs, 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'outputs' | 'wsOutputs'>,
|
|
332
|
+
): Promise<number> {
|
|
333
|
+
const { outputFiles, wsOutputFiles } = await resolveUnsaved(a)
|
|
334
|
+
return sizeOf(a.node.projectDir, outputFiles) + sizeOf(a.workspaceRoot, wsOutputFiles)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function resolveUnsaved(
|
|
338
|
+
a: Pick<UnsavedArgs, 'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'outputs' | 'wsOutputs'>,
|
|
339
|
+
): Promise<{ outputFiles: string[]; wsOutputFiles: string[] }> {
|
|
300
340
|
const endResolve = span('miss: resolve outputs')
|
|
301
341
|
const outputFiles = await resolveOutputs({
|
|
302
342
|
projectDir: a.node.projectDir,
|
|
@@ -308,7 +348,14 @@ export async function markUnsaved(a: UnsavedArgs): Promise<void> {
|
|
|
308
348
|
outputs: a.wsOutputs,
|
|
309
349
|
})
|
|
310
350
|
endResolve()
|
|
311
|
-
|
|
351
|
+
return { outputFiles, wsOutputFiles }
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The bytes of `files` (absolute, or under `base`); one gone since it was listed counts none. */
|
|
355
|
+
function sizeOf(base: string, files: readonly string[]): number {
|
|
356
|
+
let n = 0
|
|
357
|
+
for (const f of files) n += statSync(path.resolve(base, f), { throwIfNoEntry: false })?.size ?? 0
|
|
358
|
+
return n
|
|
312
359
|
}
|
|
313
360
|
|
|
314
361
|
/**
|
|
@@ -319,7 +366,7 @@ export async function markUnsaved(a: UnsavedArgs): Promise<void> {
|
|
|
319
366
|
* these paths. On a 1000-package cold run this removes ~one synchronous
|
|
320
367
|
* `git ls-files` spawn per project (the single largest cold-run cost —
|
|
321
368
|
* 22% of CPU in profiling). Contract: outputs must be declared — an
|
|
322
|
-
* executed task that writes files outside `
|
|
369
|
+
* executed task that writes files outside `outputs.files` which a
|
|
323
370
|
* same-project downstream task reads is undeclared behavior (the restore
|
|
324
371
|
* path already assumes it).
|
|
325
372
|
*/
|
|
@@ -19,6 +19,7 @@ import { warnOf } from '../report/index.js'
|
|
|
19
19
|
import type { ExecuteArgs } from './execute-task.js'
|
|
20
20
|
import {
|
|
21
21
|
commandWriteReach,
|
|
22
|
+
dependencyReads,
|
|
22
23
|
mayWriteFingerprint,
|
|
23
24
|
type Placeholder,
|
|
24
25
|
placeholderSweeper,
|
|
@@ -135,6 +136,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
135
136
|
undefined,
|
|
136
137
|
args.nestedProjectDirs,
|
|
137
138
|
args.cacheDir,
|
|
139
|
+
await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot),
|
|
138
140
|
)
|
|
139
141
|
placeholders = sb.placeholders
|
|
140
142
|
serverSandbox = sb.sandbox
|
|
@@ -283,7 +285,9 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
|
|
|
283
285
|
const message = err instanceof Error ? err.message : String(err)
|
|
284
286
|
// The task's OWN stream, not the process's: the frame is where a
|
|
285
287
|
// reader looks for why a task failed, and a run with a custom logger
|
|
286
|
-
// (an embedder) never saw a bare stderr write at all.
|
|
288
|
+
// (an embedder) never saw a bare stderr write at all. What waited on
|
|
289
|
+
// an open line goes first.
|
|
290
|
+
ends.end()
|
|
287
291
|
log.taskStderr(
|
|
288
292
|
node,
|
|
289
293
|
ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
|
|
@@ -337,7 +337,7 @@ export function placeTasks(
|
|
|
337
337
|
projectDir: node.projectDir,
|
|
338
338
|
command: node.config.exec!.command,
|
|
339
339
|
pinnedLocal: pinned.has(node.id),
|
|
340
|
-
cacheable: node.config.cache
|
|
340
|
+
cacheable: node.config.cache === true,
|
|
341
341
|
},
|
|
342
342
|
executorLabel,
|
|
343
343
|
(e) => pinsOf(e).has(node.id) || !routes(e),
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -25,7 +25,7 @@ export type CacheStatus =
|
|
|
25
25
|
| 'hit-local' // entry exists in local cache
|
|
26
26
|
| 'hit-remote' // entry exists in the remote layer (would be fetched)
|
|
27
27
|
| 'miss' // caching enabled but no entry — would execute
|
|
28
|
-
| 'no-cache' // task opts out of caching (no `cache`
|
|
28
|
+
| 'no-cache' // task opts out of caching (no `cache: true`) or --no-cache
|
|
29
29
|
| 'group' // no `exec`; just an aggregator
|
|
30
30
|
| 'not-started' // a server every dependant of which restores early: the run never spawns it
|
|
31
31
|
| 'skipped' // `exec.platforms` leave this machine out, or a task it depends on is skipped
|
|
@@ -186,7 +186,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
186
186
|
// so nothing is probed. Whether that run is a miss or no cache at all
|
|
187
187
|
// is the WRITE axes': `--force` and `--cache-policy=local:w` save what they
|
|
188
188
|
// run, and the plan called them `no-cache`, the label a task with no
|
|
189
|
-
// `cache`
|
|
189
|
+
// `cache: true` gets (item 991). The probe below routes through the
|
|
190
190
|
// policy-aware cache layer, which itself respects read gating.
|
|
191
191
|
const policy = args.cachePolicy ?? FULL_CACHE_POLICY
|
|
192
192
|
const reads = policy.localRead || policy.remoteRead
|
|
@@ -225,7 +225,11 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
|
|
|
225
225
|
)
|
|
226
226
|
const dependents = new Map<string, string[]>()
|
|
227
227
|
for (const node of args.nodes.values())
|
|
228
|
-
for (const dep of node.deps)
|
|
228
|
+
for (const dep of node.deps) {
|
|
229
|
+
const ds = dependents.get(dep)
|
|
230
|
+
if (ds === undefined) dependents.set(dep, [node.id])
|
|
231
|
+
else ds.push(node.id)
|
|
232
|
+
}
|
|
229
233
|
for (const id of idleServers(args.nodes, dependents, restoreTier))
|
|
230
234
|
cacheStatusById.set(id, 'not-started')
|
|
231
235
|
}
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
DiscoverContext,
|
|
34
34
|
ExecutorContext,
|
|
35
35
|
FingerprintChange,
|
|
36
|
+
FingerprintDigests,
|
|
36
37
|
FingerprintContext,
|
|
37
38
|
GraphHookContext,
|
|
38
39
|
KeyHookContext,
|
|
@@ -44,6 +45,7 @@ import type {
|
|
|
44
45
|
ConfigContext,
|
|
45
46
|
} from './plugin.js'
|
|
46
47
|
import type { ProjectMeta } from '../workspace/index.js'
|
|
48
|
+
import { hashExtra } from './lockfile-claim.js'
|
|
47
49
|
|
|
48
50
|
/** `a string`, `an array`, `null`, `a number` — for a refusal that names what came back. */
|
|
49
51
|
function describeValue(v: unknown): string {
|
|
@@ -392,6 +394,65 @@ export async function claimedAffected(
|
|
|
392
394
|
ctx: FingerprintContext,
|
|
393
395
|
): Promise<Set<string> | undefined> {
|
|
394
396
|
const answer = await safe(plugin, 'fingerprint', () => plugin.fingerprint!.affected(change, ctx))
|
|
397
|
+
return projectNames(plugin, answer, ctx)
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* `FingerprintClaim.digests`' answer for `file`, each extra file it names
|
|
402
|
+
* with its hash now (`hashExtra`, as a run checks it); `undefined` when the
|
|
403
|
+
* plugin has none. A digest of a name no project carries, or a value that
|
|
404
|
+
* is not a string, is refused by name: `vx lock` would commit it.
|
|
405
|
+
*/
|
|
406
|
+
export async function claimedDigests(
|
|
407
|
+
plugin: VxPlugin,
|
|
408
|
+
file: string,
|
|
409
|
+
ctx: FingerprintContext,
|
|
410
|
+
): Promise<
|
|
411
|
+
| {
|
|
412
|
+
hash: string
|
|
413
|
+
projects: FingerprintDigests['projects']
|
|
414
|
+
extras?: Readonly<Record<string, string>>
|
|
415
|
+
}
|
|
416
|
+
| undefined
|
|
417
|
+
> {
|
|
418
|
+
const digests = plugin.fingerprint?.digests
|
|
419
|
+
if (digests === undefined) return undefined
|
|
420
|
+
const answer: unknown = await safe(plugin, 'fingerprint', () =>
|
|
421
|
+
digests.call(plugin.fingerprint, file, ctx),
|
|
422
|
+
)
|
|
423
|
+
if (answer === undefined) return undefined
|
|
424
|
+
const { hash, projects, extras } = (answer ?? {}) as Partial<FingerprintDigests>
|
|
425
|
+
if (
|
|
426
|
+
typeof hash !== 'string' ||
|
|
427
|
+
typeof projects !== 'object' ||
|
|
428
|
+
projects === null ||
|
|
429
|
+
Object.values(projects).some((d) => typeof d !== 'string') ||
|
|
430
|
+
(extras !== undefined &&
|
|
431
|
+
(!Array.isArray(extras) || extras.some((rel) => typeof rel !== 'string')))
|
|
432
|
+
) {
|
|
433
|
+
throw new UserError(
|
|
434
|
+
`plugin '${plugin.name}' failed in fingerprint: digests returned ${describeValue(answer)}, not { hash, projects: { name: digest }, extras?: [path] }`,
|
|
435
|
+
)
|
|
436
|
+
}
|
|
437
|
+
const known = new Set(ctx.projects.map((p) => p.name))
|
|
438
|
+
const stray = Object.keys(projects).find((n) => !known.has(n))
|
|
439
|
+
if (stray !== undefined) {
|
|
440
|
+
throw new UserError(
|
|
441
|
+
`plugin '${plugin.name}' failed in fingerprint: digest of ${JSON.stringify(stray)}, which is not a project in this workspace`,
|
|
442
|
+
)
|
|
443
|
+
}
|
|
444
|
+
if (extras === undefined || extras.length === 0) return { hash, projects }
|
|
445
|
+
const hashed = await Promise.all(
|
|
446
|
+
extras.map(async (rel) => [rel, await hashExtra(ctx.workspaceRoot, rel)] as const),
|
|
447
|
+
)
|
|
448
|
+
return { hash, projects, extras: Object.fromEntries(hashed) }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function projectNames(
|
|
452
|
+
plugin: VxPlugin,
|
|
453
|
+
answer: unknown,
|
|
454
|
+
ctx: FingerprintContext,
|
|
455
|
+
): Promise<Set<string> | undefined> {
|
|
395
456
|
if (answer === undefined) return undefined
|
|
396
457
|
// A plain object is no list: `for…of` threw "is not iterable", naming
|
|
397
458
|
// neither the plugin nor the hook.
|
|
@@ -460,6 +521,8 @@ export async function claimedExtraFiles(
|
|
|
460
521
|
export interface ScheduleRun {
|
|
461
522
|
readonly step?: <T>(id: string, work: () => Promise<T>) => Promise<T>
|
|
462
523
|
readonly refined?: (merged: ReadonlyMap<string, number>) => void
|
|
524
|
+
/** Where `ctx.flaky` names go; none in a plan. */
|
|
525
|
+
readonly flaky?: (taskIds: Iterable<string>) => void
|
|
463
526
|
}
|
|
464
527
|
|
|
465
528
|
/**
|
|
@@ -472,7 +535,7 @@ export interface ScheduleRun {
|
|
|
472
535
|
export async function applyScheduleHooks(
|
|
473
536
|
plugins: readonly VxPlugin[],
|
|
474
537
|
nodes: ReadonlyMap<string, TaskNode>,
|
|
475
|
-
ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining'>,
|
|
538
|
+
ctx: Omit<ScheduleHookContext, 'cache' | 'refine' | 'onRefine' | 'refining' | 'flaky'>,
|
|
476
539
|
cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
|
|
477
540
|
run: ScheduleRun = {},
|
|
478
541
|
): Promise<ReadonlyMap<string, number>> {
|
|
@@ -525,7 +588,8 @@ export async function applyScheduleHooks(
|
|
|
525
588
|
const onRefine = (listener: () => void): void => {
|
|
526
589
|
if (run.refined !== undefined) listeners.push({ plugin, i, listener })
|
|
527
590
|
}
|
|
528
|
-
const
|
|
591
|
+
const flaky = (taskIds: Iterable<string>): void => run.flaky?.(taskIds)
|
|
592
|
+
const own = { ...ctx, cache: cacheFor(plugin.name), refine, onRefine, refining, flaky }
|
|
529
593
|
const call = (): Promise<ReadonlyMap<string, number> | undefined> =>
|
|
530
594
|
safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
|
|
531
595
|
const weights = await (run.step === undefined
|
|
@@ -827,6 +891,7 @@ const EXECUTOR_HOOKS = [
|
|
|
827
891
|
'footer',
|
|
828
892
|
'machines',
|
|
829
893
|
'idealMs',
|
|
894
|
+
'advice',
|
|
830
895
|
'profile',
|
|
831
896
|
'running',
|
|
832
897
|
] as const
|