@vzn/vx 0.0.644 → 0.0.646
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +22 -48
- package/package.json +5 -5
- package/schemas/init.json +5 -0
- package/schemas/last.json +15 -0
- package/skills/vx/SKILL.md +1 -1
- package/src/bin.ts +1 -1
- package/src/cache/archive.ts +12 -76
- package/src/cache/cache.ts +72 -42
- package/src/cache/git-inputs.ts +55 -10
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +61 -11
- package/src/cache/layer.ts +30 -8
- package/src/cache/layered-cache.ts +138 -44
- package/src/cache/output-index.ts +25 -11
- package/src/cache/plugin-cache.ts +9 -14
- package/src/cache/remote-lane.ts +60 -22
- package/src/cache/run-history.ts +13 -6
- package/src/cache/schema.ts +19 -2
- package/src/cache/scoped-cache.ts +3 -80
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/help.ts +8 -10
- package/src/cli/index.ts +0 -12
- package/src/cli/init.ts +159 -25
- package/src/cli/last.ts +4 -2
- package/src/cli/plan-format.ts +6 -2
- package/src/cli/plugin-commands.ts +98 -6
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +2 -8
- package/src/cli/select.ts +5 -3
- package/src/cli/upgrade.ts +5 -6
- package/src/config.ts +2 -4
- package/src/exec/executor.ts +112 -9
- package/src/exec/index.ts +2 -0
- package/src/exec/proc-sample.ts +67 -5
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +11 -4
- package/src/graph/scheduler.ts +59 -8
- package/src/history/history.ts +119 -7
- package/src/history/metrics.ts +53 -16
- package/src/index.ts +16 -1
- package/src/orchestrator/affected-tasks.ts +0 -18
- package/src/orchestrator/execute-args.ts +3 -0
- package/src/orchestrator/execute-task.ts +28 -7
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +11 -4
- package/src/orchestrator/index.ts +18 -1
- package/src/orchestrator/local-floor.ts +104 -0
- package/src/orchestrator/local-shortcircuit.ts +26 -7
- package/src/orchestrator/miss-save.ts +10 -4
- package/src/orchestrator/options.ts +0 -8
- package/src/orchestrator/persistent-task.ts +14 -0
- package/src/orchestrator/placement.ts +84 -9
- package/src/orchestrator/plan.ts +7 -1
- package/src/orchestrator/plugin-host.ts +22 -7
- package/src/orchestrator/plugin.ts +48 -7
- package/src/orchestrator/prepare.ts +14 -49
- package/src/orchestrator/run-context.ts +39 -1
- package/src/orchestrator/run-keys.ts +164 -0
- package/src/orchestrator/run-records.ts +6 -0
- package/src/orchestrator/run.ts +95 -6
- package/src/orchestrator/sandbox-request.ts +9 -3
- package/src/orchestrator/task-artifacts.ts +142 -0
- package/src/orchestrator/task-hash.ts +12 -4
- package/src/orchestrator/task-process.ts +16 -0
- package/src/orchestrator/telemetry-host.ts +2 -1
- package/src/orchestrator/telemetry.ts +60 -17
- package/src/report/forecast.ts +6 -27
- package/src/report/framed-output.ts +41 -5
- package/src/report/logger.ts +60 -20
- package/src/report/status-line.ts +38 -18
- package/src/report/summary.ts +75 -9
- package/src/settings.ts +8 -7
- package/src/util/colors.ts +16 -1
- package/src/util/index.ts +1 -0
- package/src/util/paths.ts +30 -0
- package/src/util/ui.ts +4 -1
- package/src/workspace/affected.ts +0 -39
- package/src/workspace/config-bytecode.ts +276 -0
- package/src/workspace/index.ts +0 -2
- package/src/workspace/migration.ts +4 -32
- package/src/workspace/project-loader.ts +92 -14
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -24,6 +24,7 @@ import { maskedLine, nearest, type PluginSecrets, SecretBroker, UserError } from
|
|
|
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'
|
|
27
|
+
import type { CacheFacts, TaskArtifacts } from './task-artifacts.js'
|
|
27
28
|
import type { TelemetryContext, TelemetrySink } from './telemetry.js'
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -334,6 +335,20 @@ export interface CommandContext extends BaseContext {
|
|
|
334
335
|
* workspace's cache plugins supply, under the run's scope. Opened on first use.
|
|
335
336
|
*/
|
|
336
337
|
readonly cache: PluginCache
|
|
338
|
+
/**
|
|
339
|
+
* Task entries by key in the workspace's cache, under a run's policy and
|
|
340
|
+
* scope (a laptop writes no remote unless the workspace's policy names
|
|
341
|
+
* it): what a verb that runs a run's tasks on this machine saves and
|
|
342
|
+
* restores through (`TaskArtifacts`). Opened on first use.
|
|
343
|
+
*/
|
|
344
|
+
readonly artifacts: TaskArtifacts
|
|
345
|
+
/**
|
|
346
|
+
* `artifacts` read as another machine's run would read them: under its
|
|
347
|
+
* refs, with the access its remotes lent (`CacheFacts`, asked on each
|
|
348
|
+
* request, so a token it lends again replaces the last). Opened on first
|
|
349
|
+
* use; until `facts` answers, under this machine's own.
|
|
350
|
+
*/
|
|
351
|
+
artifactsFor(facts: () => CacheFacts | undefined): TaskArtifacts
|
|
337
352
|
}
|
|
338
353
|
|
|
339
354
|
/** `config` runs before the cache dir is known — it may be what the hook changes. */
|
|
@@ -346,10 +361,9 @@ export interface WorkspaceHookContext {
|
|
|
346
361
|
* What a CI knows of the run's branch (owner, 2026-10-17): `scope`, the
|
|
347
362
|
* cache scope (`'vznjs/vx:feat-x'`; `VX_CACHE_SCOPE` beats it, and it
|
|
348
363
|
* beats the one git names), and `base`, the commit the branch is compared
|
|
349
|
-
* with (a pull request's base), for a bare `--affected`
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
* owner 2026-10-18). A later plugin's field wins.
|
|
364
|
+
* with (a pull request's base), for a bare `--affected`. `trusted`: the
|
|
365
|
+
* run has no scope, whatever git names (a merge queue's run, owner
|
|
366
|
+
* 2026-10-18). The scope keys plugin entries only. A later plugin's field wins.
|
|
353
367
|
*/
|
|
354
368
|
export interface BranchFacts {
|
|
355
369
|
scope?: string
|
|
@@ -501,16 +515,35 @@ export interface ScheduleHookContext extends BaseContext {
|
|
|
501
515
|
* from a remote). Ignored in a plan and once the run's tasks are done.
|
|
502
516
|
*/
|
|
503
517
|
refine(weights: ReadonlyMap<string, number>): void
|
|
518
|
+
/**
|
|
519
|
+
* Calls `listener` each time another plugin `refine`s: the late data it
|
|
520
|
+
* waited for (timings fetched from a remote) has landed in history, so a
|
|
521
|
+
* plugin that read history in `schedule` may read it again. Never called
|
|
522
|
+
* in a plan.
|
|
523
|
+
*/
|
|
524
|
+
onRefine(listener: () => void): void
|
|
504
525
|
}
|
|
505
526
|
|
|
506
527
|
/** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
|
|
507
528
|
export interface CacheContext extends BaseContext {
|
|
508
529
|
/**
|
|
509
|
-
* The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none
|
|
510
|
-
*
|
|
511
|
-
* claims (an OIDC branch) for every call it makes.
|
|
530
|
+
* The run's cache scope (`'vznjs/vx:feat-x'`), `undefined` when none: what
|
|
531
|
+
* plugins' `ctx.cache` entries and stored runs are keyed apart by.
|
|
512
532
|
*/
|
|
513
533
|
readonly scope: string | undefined
|
|
534
|
+
/**
|
|
535
|
+
* The refs the run reads, in order (`['refs/heads/feat-x', 'refs/heads/main']`):
|
|
536
|
+
* its own branch, the branch it merges into, the default branch. A remote
|
|
537
|
+
* keys or scopes its entries by them; core keys task entries by none.
|
|
538
|
+
*/
|
|
539
|
+
readonly refs: readonly string[]
|
|
540
|
+
/**
|
|
541
|
+
* The token a run lent this plugin's remote (`RemoteCacheLayer.lend`),
|
|
542
|
+
* on a machine that runs that run's tasks (`CommandContext.artifactsFor`);
|
|
543
|
+
* undefined anywhere else. Read it per request: a run lends again before
|
|
544
|
+
* a token ends. A plugin uses it only when it has no access of its own.
|
|
545
|
+
*/
|
|
546
|
+
readonly lent?: () => string | undefined
|
|
514
547
|
}
|
|
515
548
|
|
|
516
549
|
export interface OutputContext extends BaseContext {
|
|
@@ -523,6 +556,14 @@ export interface OutputContext extends BaseContext {
|
|
|
523
556
|
export interface ExecutorContext extends BaseContext {
|
|
524
557
|
/** The run's worker count — an executor that paces itself reads it here. */
|
|
525
558
|
readonly concurrency: number
|
|
559
|
+
/** Task entries by key in the run's cache, for a task another machine ran (`TaskArtifacts`). */
|
|
560
|
+
readonly artifacts: TaskArtifacts
|
|
561
|
+
/**
|
|
562
|
+
* What a machine that runs this run's tasks reads the cache under
|
|
563
|
+
* (`CacheFacts`): the run's refs, and each remote's lent access, asked
|
|
564
|
+
* now. Ask again before `expiresAt`.
|
|
565
|
+
*/
|
|
566
|
+
cacheFacts(): Promise<CacheFacts>
|
|
526
567
|
}
|
|
527
568
|
|
|
528
569
|
export interface PluginContext extends PluginSetupContext {
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
writesRunStore,
|
|
27
27
|
type PluginCache,
|
|
28
28
|
type ResolvedRemote,
|
|
29
|
-
BranchScope,
|
|
30
29
|
applyGitEnumeration,
|
|
31
30
|
gitPathspecs,
|
|
32
31
|
MAX_SCOPED_PATHSPECS,
|
|
@@ -34,14 +33,11 @@ import {
|
|
|
34
33
|
lazyGitEnumeration,
|
|
35
34
|
measureFsClockLag,
|
|
36
35
|
DEFAULT_STORE,
|
|
37
|
-
type StoreOptions,
|
|
38
36
|
} from '../cache/index.js'
|
|
39
37
|
import {
|
|
40
38
|
buildPackageGraph,
|
|
41
39
|
computeNestedProjectDirs,
|
|
42
40
|
findWorkspaceRoot,
|
|
43
|
-
type AffectedChanges,
|
|
44
|
-
branchChanges,
|
|
45
41
|
configuredBaseBranch,
|
|
46
42
|
type LoadReads,
|
|
47
43
|
PROJECT_CONFIG_FILENAMES,
|
|
@@ -81,13 +77,13 @@ import {
|
|
|
81
77
|
lockReader,
|
|
82
78
|
type LoadedProjects,
|
|
83
79
|
} from './projects.js'
|
|
84
|
-
import { keptByAffected
|
|
80
|
+
import { keptByAffected } from './affected-tasks.js'
|
|
85
81
|
import { applyGraphStage } from './graph-stage.js'
|
|
86
82
|
import { keyExcludedDependencies } from './excluded-keys.js'
|
|
87
83
|
import { FingerprintWatch } from './fingerprint-watch.js'
|
|
88
84
|
import type { VxPlugin } from './plugin.js'
|
|
89
85
|
import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
|
|
90
|
-
import { currentBranch, resolveCacheScope } from './run-context.js'
|
|
86
|
+
import { cacheRefs, currentBranch, resolveCacheScope } from './run-context.js'
|
|
91
87
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
92
88
|
import { type Logger, warnOf } from '../report/index.js'
|
|
93
89
|
import type { RunOptions } from './options.js'
|
|
@@ -120,6 +116,8 @@ export interface PreparedRun {
|
|
|
120
116
|
cacheScope: string | undefined
|
|
121
117
|
/** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
|
|
122
118
|
remotes: ResolvedRemote[]
|
|
119
|
+
/** The refs the run reads, in order (`CacheContext.refs`). */
|
|
120
|
+
cacheRefs: readonly string[]
|
|
123
121
|
/**
|
|
124
122
|
* Scheduling priorities from plugins' `schedule` stage (task id → weight,
|
|
125
123
|
* merged over the structural baseline by the scheduler). Empty when no
|
|
@@ -291,33 +289,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
291
289
|
reused !== undefined
|
|
292
290
|
? Promise.resolve(reused.projects)
|
|
293
291
|
: discoverProjects(workspaceRoot, readLock, plugins, cacheDir, warnOf(log), git)
|
|
294
|
-
// What the branch changed decides which tasks read the scope (owner,
|
|
295
|
-
// 2026-10-17). Only a scoped run's first remote call asks git: a run the
|
|
296
|
-
// local cache answers makes no remote call, and diffing beside discovery
|
|
297
|
-
// cost it 20-60 ms at 1k-5k projects.
|
|
298
|
-
const scopeChanges: (() => Promise<AffectedChanges>) | undefined =
|
|
299
|
-
cacheScope === undefined
|
|
300
|
-
? undefined
|
|
301
|
-
: options.scopeChanges !== undefined
|
|
302
|
-
? async () => options.scopeChanges!
|
|
303
|
-
: () =>
|
|
304
|
-
branchChanges(
|
|
305
|
-
workspaceRoot,
|
|
306
|
-
options.scopeBase ?? branchFacts.base,
|
|
307
|
-
baseBranch,
|
|
308
|
-
discovering,
|
|
309
|
-
{
|
|
310
|
-
untracked: async () => (await git.start()).untracked,
|
|
311
|
-
// A scoped enumeration lists only its projects: null asks git.
|
|
312
|
-
links: async () => {
|
|
313
|
-
const g = await git.start()
|
|
314
|
-
if (!g.scopes.includes('.')) return null
|
|
315
|
-
const out: string[] = []
|
|
316
|
-
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
317
|
-
return out
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
|
-
)
|
|
321
292
|
const projectMetas = await discovering
|
|
322
293
|
mark('discover projects')
|
|
323
294
|
|
|
@@ -383,8 +354,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
383
354
|
DEFAULT_STORE.embeddings) as number,
|
|
384
355
|
decompress: (resolveSetting('cache.store.decompress', workspaceConfig).value ??
|
|
385
356
|
DEFAULT_STORE.decompress) as boolean,
|
|
386
|
-
verify: (resolveSetting('cache.store.verify', workspaceConfig).value ??
|
|
387
|
-
DEFAULT_STORE.verify) as StoreOptions['verify'],
|
|
388
357
|
},
|
|
389
358
|
)
|
|
390
359
|
const unwritable = localCache.readOnlyNotice()
|
|
@@ -519,9 +488,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
519
488
|
// cache plugin.
|
|
520
489
|
let cache: CacheLayer
|
|
521
490
|
const remotes: ResolvedRemote[] = []
|
|
522
|
-
// A scope only picks remote keys (owner, 2026-10-17): the local store
|
|
523
|
-
// keeps every branch's entries under the task keys.
|
|
524
|
-
const branchScope = cacheScope === undefined ? undefined : new BranchScope(cacheScope)
|
|
525
491
|
if (options.remoteCache) {
|
|
526
492
|
remotes.push({
|
|
527
493
|
remote: options.remoteCache,
|
|
@@ -530,18 +496,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
530
496
|
onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
|
|
531
497
|
})
|
|
532
498
|
}
|
|
499
|
+
// An injected remote is no plugin's: nothing reads refs for it.
|
|
500
|
+
const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch)
|
|
533
501
|
try {
|
|
534
502
|
cache = options.remoteCache
|
|
535
|
-
? new LayeredCache(localCache, remotes,
|
|
503
|
+
? new LayeredCache(localCache, remotes, options.uploadConcurrency)
|
|
536
504
|
: await resolveCache(plugins, {
|
|
537
505
|
workspaceRoot,
|
|
538
506
|
cacheDir,
|
|
539
507
|
warn: warnOf(log),
|
|
540
508
|
scope: cacheScope,
|
|
509
|
+
refs,
|
|
541
510
|
localCache,
|
|
542
511
|
policy,
|
|
543
512
|
remotes,
|
|
544
|
-
...(branchScope !== undefined ? { branch: branchScope } : {}),
|
|
545
513
|
...(options.uploadConcurrency !== undefined
|
|
546
514
|
? { uploadConcurrency: options.uploadConcurrency }
|
|
547
515
|
: {}),
|
|
@@ -590,6 +558,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
590
558
|
cachePolicy: policy,
|
|
591
559
|
cacheScope,
|
|
592
560
|
remotes,
|
|
561
|
+
cacheRefs: refs,
|
|
593
562
|
keepRunEntry: undefined,
|
|
594
563
|
priorities: () => Promise.resolve(new Map()),
|
|
595
564
|
branch,
|
|
@@ -776,13 +745,6 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
776
745
|
if (hasHook(plugins, 'key')) {
|
|
777
746
|
await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: warnOf(log) })
|
|
778
747
|
}
|
|
779
|
-
// The final inputs decide which tasks the branch changed: they read
|
|
780
|
-
// the scope, every other task the default key.
|
|
781
|
-
if (branchScope !== undefined && scopeChanges !== undefined) {
|
|
782
|
-
branchScope.routeTasks(async () =>
|
|
783
|
-
keyedByChange(nodes, await scopeChanges(), projects, packageGraph),
|
|
784
|
-
)
|
|
785
|
-
}
|
|
786
748
|
refuseFiltersThroughGroups(nodes)
|
|
787
749
|
const exclude = options.excludeDependencies
|
|
788
750
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
@@ -847,6 +809,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
847
809
|
cachePolicy: policy,
|
|
848
810
|
cacheScope,
|
|
849
811
|
remotes,
|
|
812
|
+
cacheRefs: refs,
|
|
850
813
|
priorities,
|
|
851
814
|
branch,
|
|
852
815
|
cacheFor,
|
|
@@ -958,7 +921,7 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
|
|
|
958
921
|
}
|
|
959
922
|
}
|
|
960
923
|
|
|
961
|
-
const namesRemote = (spec: string | undefined): boolean =>
|
|
924
|
+
export const namesRemote = (spec: string | undefined): boolean =>
|
|
962
925
|
spec !== undefined && /remote:[rw]/.test(spec)
|
|
963
926
|
|
|
964
927
|
/**
|
|
@@ -966,7 +929,9 @@ const namesRemote = (spec: string | undefined): boolean =>
|
|
|
966
929
|
* since the grammar lives in the cache module the workspace module may not
|
|
967
930
|
* import. Undefined when it names none.
|
|
968
931
|
*/
|
|
969
|
-
function workspaceCachePolicy(
|
|
932
|
+
export function workspaceCachePolicy(
|
|
933
|
+
config: WorkspaceConfig | null | undefined,
|
|
934
|
+
): CachePolicy | undefined {
|
|
970
935
|
const spec = config?.cache?.policy
|
|
971
936
|
if (spec === undefined) return undefined
|
|
972
937
|
try {
|
|
@@ -553,6 +553,44 @@ export function localBranchScope(
|
|
|
553
553
|
return branchCacheScope(repo, name)
|
|
554
554
|
}
|
|
555
555
|
|
|
556
|
+
/**
|
|
557
|
+
* The refs a remote reads, in order (2026-10-20): the run's own branch,
|
|
558
|
+
* then the branch it merges into, then the default branch, each once. On
|
|
559
|
+
* CI from its env (a GitHub pull request's head branch, a merge queue's
|
|
560
|
+
* queue ref, a GitLab pipeline's ref), else from git: the checked-out
|
|
561
|
+
* branch, then the default (`origin/HEAD`). A remote plugin keys or scopes
|
|
562
|
+
* its entries by them (`scope` on turboCache/nxCache); core keys nothing
|
|
563
|
+
* by them.
|
|
564
|
+
*/
|
|
565
|
+
export function cacheRefs(
|
|
566
|
+
workspaceRoot: string,
|
|
567
|
+
baseBranch: string | undefined,
|
|
568
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
569
|
+
): string[] {
|
|
570
|
+
const set = (v: string | undefined): string | undefined =>
|
|
571
|
+
v !== undefined && v.trim() !== '' ? v.trim() : undefined
|
|
572
|
+
const head = (b: string | undefined) => (b === undefined ? undefined : `refs/heads/${b}`)
|
|
573
|
+
let own: string | undefined
|
|
574
|
+
let base: string | undefined
|
|
575
|
+
if (env['GITHUB_ACTIONS'] === 'true') {
|
|
576
|
+
own = head(set(env['GITHUB_HEAD_REF'])) ?? set(env['GITHUB_REF'])
|
|
577
|
+
base = head(set(env['GITHUB_BASE_REF']))
|
|
578
|
+
const queue = /^refs\/heads\/gh-readonly-queue\/(.+)\/pr-\d+-[0-9a-f]+$/.exec(own ?? '')
|
|
579
|
+
if (queue !== null) base = head(queue[1])
|
|
580
|
+
} else if (env['GITLAB_CI'] === 'true') {
|
|
581
|
+
const tag = set(env['CI_COMMIT_TAG'])
|
|
582
|
+
own = tag !== undefined ? `refs/tags/${tag}` : head(set(env['CI_COMMIT_REF_NAME']))
|
|
583
|
+
base = head(set(env['CI_MERGE_REQUEST_TARGET_BRANCH_NAME']))
|
|
584
|
+
} else {
|
|
585
|
+
own = head(
|
|
586
|
+
(readHeadDirect(workspaceRoot) ?? captureGitContext(workspaceRoot, null, env)).branch ??
|
|
587
|
+
undefined,
|
|
588
|
+
)
|
|
589
|
+
}
|
|
590
|
+
const trunk = head(baseBranch ?? captureDefaultBranch(env, workspaceRoot) ?? undefined)
|
|
591
|
+
return [...new Set([own, base, trunk].filter((r): r is string => r !== undefined))]
|
|
592
|
+
}
|
|
593
|
+
|
|
556
594
|
/**
|
|
557
595
|
* The repository's path on its host (`owner/repo`; a GitLab subgroup's
|
|
558
596
|
* `group/sub/repo` whole, as `gitlab()` names it); undefined for a local path.
|
|
@@ -591,7 +629,7 @@ function gitConfigSections(workspaceRoot: string): Map<string, Map<string, strin
|
|
|
591
629
|
* The run's cache scope (owner, 2026-10-17): `VX_CACHE_SCOPE` (empty: none),
|
|
592
630
|
* else a plugin's (`setBranch`, `github()` on Actions; null: the plugin
|
|
593
631
|
* named the run trusted), else, with a cache plugin to share it, the
|
|
594
|
-
* branch's from git. Undefined:
|
|
632
|
+
* branch's from git. Undefined: no scope. It keys plugin entries only.
|
|
595
633
|
*/
|
|
596
634
|
export function resolveCacheScope(
|
|
597
635
|
workspaceRoot: string,
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// The run key memo: every up-front key a warm run derived, read back whole
|
|
2
|
+
// when nothing any of them folds has moved. Deriving them again cost 250 ms
|
|
3
|
+
// of a 1,070 ms warm run at 9,600 tasks even with every fold memo row
|
|
4
|
+
// answering (2026-10-22): the per-task walk itself, not the files.
|
|
5
|
+
//
|
|
6
|
+
// The token is everything `deriveStableKeys` reads: each node's id, edges,
|
|
7
|
+
// resolved config, plugin key parts and files token, the declared env
|
|
8
|
+
// values, the forward args and the code that derives. A run the token cannot
|
|
9
|
+
// vouch for (a keyless or runtime-probed task, a declaration no files token
|
|
10
|
+
// covers) derives as before.
|
|
11
|
+
|
|
12
|
+
import { readdirSync, statSync } from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import type { GitFilesCache } from '../cache/index.js'
|
|
15
|
+
import type { TaskNode } from '../graph/index.js'
|
|
16
|
+
import { xxh3hex } from '../util/index.js'
|
|
17
|
+
import type { StableKey } from './stable-keys.js'
|
|
18
|
+
import { hashTaskConfig, isKeyless, taskFilesToken, type HashCache } from './task-hash.js'
|
|
19
|
+
|
|
20
|
+
/** Moves every remembered run: bump it when what a token stands for changes. */
|
|
21
|
+
const RUN_MEMO_VERSION = 'run-memo-v1'
|
|
22
|
+
|
|
23
|
+
export interface RunKeysArgs {
|
|
24
|
+
nodes: Map<string, TaskNode>
|
|
25
|
+
workspaceRoot: string
|
|
26
|
+
claimedFiles: readonly string[]
|
|
27
|
+
forwardArgs?: readonly string[] | undefined
|
|
28
|
+
nestedDirsByProject: Map<string, string[]>
|
|
29
|
+
gitFilesCache: GitFilesCache
|
|
30
|
+
hashCache: HashCache
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a run's up-front pass yields, as the memo holds it. */
|
|
34
|
+
export interface RunKeys {
|
|
35
|
+
stableKeys: StableKey[]
|
|
36
|
+
uncachedKeys: Map<string, string>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The memo row's name (the selection) and the token its keys stand for, or
|
|
41
|
+
* undefined when a node's key reads something the token cannot name.
|
|
42
|
+
*/
|
|
43
|
+
export function runKeysToken(args: RunKeysArgs): { id: string; token: string } | undefined {
|
|
44
|
+
if (args.hashCache.folds === undefined) return undefined
|
|
45
|
+
const parts: string[] = [
|
|
46
|
+
RUN_MEMO_VERSION,
|
|
47
|
+
codeIdentity(),
|
|
48
|
+
args.workspaceRoot,
|
|
49
|
+
JSON.stringify(args.forwardArgs ?? []),
|
|
50
|
+
JSON.stringify([...args.claimedFiles].sort()),
|
|
51
|
+
JSON.stringify([...args.nestedDirsByProject]),
|
|
52
|
+
// Which outputs git ignores decides which a reader may see (`readableOutputs`).
|
|
53
|
+
[...args.gitFilesCache.ignoredOutputs].sort().join('\0'),
|
|
54
|
+
]
|
|
55
|
+
const ids: string[] = []
|
|
56
|
+
const envNames = new Set<string>()
|
|
57
|
+
for (const node of args.nodes.values()) {
|
|
58
|
+
const cfg = node.config
|
|
59
|
+
if (isKeyless(cfg) || node.excludedUpstream !== undefined) return undefined
|
|
60
|
+
const inputs = cfg.inputs
|
|
61
|
+
if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
let files = ''
|
|
65
|
+
if (inputs !== undefined) {
|
|
66
|
+
if ((inputs.workspaceFiles?.length ?? 0) > 0) return undefined
|
|
67
|
+
// No positive entry resolves no files (`filesPlan`).
|
|
68
|
+
if (inputs.files === undefined || inputs.files.some((e) => !e.startsWith('!'))) {
|
|
69
|
+
const token = taskFilesToken({
|
|
70
|
+
node,
|
|
71
|
+
workspaceRoot: args.workspaceRoot,
|
|
72
|
+
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
73
|
+
gitFilesCache: args.gitFilesCache,
|
|
74
|
+
hashCache: args.hashCache,
|
|
75
|
+
})
|
|
76
|
+
if (token === undefined) return undefined
|
|
77
|
+
files = token
|
|
78
|
+
}
|
|
79
|
+
for (const name of inputs.env ?? []) envNames.add(name)
|
|
80
|
+
}
|
|
81
|
+
ids.push(node.id)
|
|
82
|
+
parts.push(
|
|
83
|
+
node.id,
|
|
84
|
+
node.projectName,
|
|
85
|
+
node.projectDir,
|
|
86
|
+
node.deps.join('\u0001'),
|
|
87
|
+
(node.orderOnly ?? []).join('\u0001'),
|
|
88
|
+
node.requested ? '1' : '0',
|
|
89
|
+
hashTaskConfig(cfg, args.hashCache),
|
|
90
|
+
JSON.stringify(node.keyParts ?? []),
|
|
91
|
+
JSON.stringify(node.addsToOutputsOf ?? []),
|
|
92
|
+
JSON.stringify(node.outputsAddedToBy ?? []),
|
|
93
|
+
files,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
for (const name of [...envNames].sort()) {
|
|
97
|
+
const value = process.env[name]
|
|
98
|
+
parts.push(value === undefined ? name : `${name}=${value}`)
|
|
99
|
+
}
|
|
100
|
+
return { id: `\0run\0${xxh3hex(ids.join('\0'))}`, token: xxh3hex(parts.join('\0')) }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The keys remembered under `token`, mapped back onto this run's nodes. */
|
|
104
|
+
export function readRunKeys(
|
|
105
|
+
args: RunKeysArgs,
|
|
106
|
+
at: { id: string; token: string },
|
|
107
|
+
): RunKeys | undefined {
|
|
108
|
+
const row = args.hashCache.folds?.get(at.id, at.token)
|
|
109
|
+
if (row === undefined) return undefined
|
|
110
|
+
let parsed: { s: [string, string][]; u: [string, string][] }
|
|
111
|
+
try {
|
|
112
|
+
parsed = JSON.parse(row)
|
|
113
|
+
} catch {
|
|
114
|
+
return undefined
|
|
115
|
+
}
|
|
116
|
+
const stableKeys: StableKey[] = []
|
|
117
|
+
for (const [id, hash] of parsed.s) {
|
|
118
|
+
const node = args.nodes.get(id)
|
|
119
|
+
if (node === undefined) return undefined
|
|
120
|
+
stableKeys.push({ hash, node })
|
|
121
|
+
}
|
|
122
|
+
return { stableKeys, uncachedKeys: new Map(parsed.u) }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function writeRunKeys(
|
|
126
|
+
args: RunKeysArgs,
|
|
127
|
+
at: { id: string; token: string },
|
|
128
|
+
keys: RunKeys,
|
|
129
|
+
): void {
|
|
130
|
+
args.hashCache.folds?.set(
|
|
131
|
+
at.id,
|
|
132
|
+
at.token,
|
|
133
|
+
JSON.stringify({
|
|
134
|
+
s: keys.stableKeys.map((k) => [k.node.id, k.hash]),
|
|
135
|
+
u: [...keys.uncachedKeys],
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let identity: string | undefined
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The code that derives the keys: a compiled binary is its file; a source
|
|
144
|
+
* checkout is every module under `src/`, so an edit to the derivation from
|
|
145
|
+
* source never reads back a key the old code made.
|
|
146
|
+
*/
|
|
147
|
+
function codeIdentity(): string {
|
|
148
|
+
if (identity !== undefined) return identity
|
|
149
|
+
// `Bun.main`, not `import.meta.dir`: `--minify` inlines the build
|
|
150
|
+
// machine's source path, so an installed binary walked a `src/` that is
|
|
151
|
+
// not there, threw, and lost every warm run's short circuit (2026-10-22).
|
|
152
|
+
if (Bun.main.startsWith('/$bunfs/')) {
|
|
153
|
+
const st = statSync(process.execPath)
|
|
154
|
+
return (identity = `${process.execPath}:${st.size}:${st.mtimeMs}:${st.ino}`)
|
|
155
|
+
}
|
|
156
|
+
const src = path.resolve(import.meta.dir, '..')
|
|
157
|
+
const stamps: string[] = []
|
|
158
|
+
for (const rel of readdirSync(src, { recursive: true, encoding: 'utf8' }).sort()) {
|
|
159
|
+
if (!rel.endsWith('.ts')) continue
|
|
160
|
+
const st = statSync(path.join(src, rel))
|
|
161
|
+
stamps.push(`${rel}:${st.size}:${st.mtimeMs}`)
|
|
162
|
+
}
|
|
163
|
+
return (identity = xxh3hex(stamps.join('\0')))
|
|
164
|
+
}
|
|
@@ -113,6 +113,11 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
113
113
|
runId,
|
|
114
114
|
...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
|
|
115
115
|
...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
|
|
116
|
+
...(o.peakCores !== undefined ? { peakCores: o.peakCores } : {}),
|
|
117
|
+
...(o.avgRssBytes !== undefined ? { avgRssBytes: o.avgRssBytes } : {}),
|
|
118
|
+
...(o.where !== undefined ? { placement: o.where } : {}),
|
|
119
|
+
...(o.whereCores !== undefined ? { placementCores: o.whereCores } : {}),
|
|
120
|
+
...(o.whereMemoryBytes !== undefined ? { placementMemoryBytes: o.whereMemoryBytes } : {}),
|
|
116
121
|
...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
|
|
117
122
|
...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
|
|
118
123
|
cacheHit: isCacheHit(o.status),
|
|
@@ -126,6 +131,7 @@ export function assembleRunRecords(input: RunRecordsInput): RunRecords {
|
|
|
126
131
|
...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
|
|
127
132
|
...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
|
|
128
133
|
...(remoteError !== undefined ? { remoteError } : {}),
|
|
134
|
+
...(o.keylessBy !== undefined ? { keylessBy: o.keylessBy } : {}),
|
|
129
135
|
})
|
|
130
136
|
if (o.status === 'failed') failedCount++
|
|
131
137
|
if (o.status === 'cache-hit') hitLocalCount++
|