@vzn/vx 0.0.637 → 0.0.638

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.
Files changed (76) hide show
  1. package/docs/caching.md +41 -14
  2. package/docs/cli.md +115 -73
  3. package/docs/execution.md +1 -1
  4. package/docs/features.md +6 -5
  5. package/docs/schema.md +24 -15
  6. package/package.json +5 -5
  7. package/schemas/plan.json +2 -2
  8. package/skills/vx/SKILL.md +13 -2
  9. package/src/bin.ts +13 -7
  10. package/src/cache/cache.ts +21 -2
  11. package/src/cache/chained-cache.ts +4 -0
  12. package/src/cache/index.ts +9 -1
  13. package/src/cache/inputs.ts +0 -24
  14. package/src/cache/layer.ts +7 -1
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/plugin-cache.ts +248 -0
  17. package/src/cache/policy.ts +9 -0
  18. package/src/cache/run-history.ts +68 -0
  19. package/src/cache/run-store.ts +77 -0
  20. package/src/cache/schema.ts +38 -9
  21. package/src/cache/scoped-cache.ts +15 -2
  22. package/src/cli/cache.ts +33 -16
  23. package/src/cli/completions.ts +9 -5
  24. package/src/cli/help.ts +16 -7
  25. package/src/cli/index.ts +25 -11
  26. package/src/cli/info.ts +6 -5
  27. package/src/cli/init.ts +14 -15
  28. package/src/cli/last.ts +14 -7
  29. package/src/cli/lock.ts +39 -27
  30. package/src/cli/plan-format.ts +5 -3
  31. package/src/cli/plugin-commands.ts +77 -6
  32. package/src/cli/plugin-templates.ts +1 -1
  33. package/src/cli/run.ts +14 -12
  34. package/src/cli/select.ts +18 -9
  35. package/src/cli/show.ts +66 -40
  36. package/src/cli/task-verb.ts +4 -4
  37. package/src/cli/upgrade.ts +20 -11
  38. package/src/cli/watch-filter.ts +7 -2
  39. package/src/cli/watch-judge.ts +6 -1
  40. package/src/cli/watch.ts +44 -20
  41. package/src/cli/why.ts +10 -1
  42. package/src/cli/workspace-config.ts +2 -2
  43. package/src/graph/scheduler.ts +8 -0
  44. package/src/graph/task-graph.ts +32 -4
  45. package/src/index.ts +11 -1
  46. package/src/orchestrator/affected-tasks.ts +8 -3
  47. package/src/orchestrator/doctor.ts +1 -1
  48. package/src/orchestrator/execute-task.ts +26 -43
  49. package/src/orchestrator/failure-mode.ts +19 -10
  50. package/src/orchestrator/framed-output.ts +1 -2
  51. package/src/orchestrator/history.ts +60 -4
  52. package/src/orchestrator/index.ts +2 -1
  53. package/src/orchestrator/logger.ts +9 -2
  54. package/src/orchestrator/metrics.ts +7 -3
  55. package/src/orchestrator/plugin-host.ts +14 -4
  56. package/src/orchestrator/plugin.ts +80 -8
  57. package/src/orchestrator/prepare.ts +51 -7
  58. package/src/orchestrator/projects.ts +3 -0
  59. package/src/orchestrator/remote-prefetch.ts +9 -5
  60. package/src/orchestrator/run-records.ts +1 -1
  61. package/src/orchestrator/run.ts +126 -27
  62. package/src/orchestrator/status-line.ts +1 -1
  63. package/src/orchestrator/summary.ts +69 -44
  64. package/src/orchestrator/telemetry.ts +1 -1
  65. package/src/settings.ts +12 -1
  66. package/src/{orchestrator → util}/colors.ts +5 -0
  67. package/src/util/config-frame.ts +31 -2
  68. package/src/util/errors.ts +32 -0
  69. package/src/util/index.ts +8 -1
  70. package/src/util/refusal-notice.ts +111 -0
  71. package/src/util/report.ts +31 -0
  72. package/src/util/ui.ts +186 -0
  73. package/src/workspace/affected.ts +20 -23
  74. package/src/workspace/dependency-rules.ts +12 -4
  75. package/src/workspace/migration.ts +87 -91
  76. package/src/workspace/workspace.ts +5 -4
@@ -14,6 +14,9 @@ import {
14
14
  type CacheLayer,
15
15
  type CachePolicy,
16
16
  LayeredCache,
17
+ absentPluginCache,
18
+ type PluginCache,
19
+ type PluginCacheRemote,
17
20
  } from '../cache/index.js'
18
21
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
19
22
  import { maskedLine, settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
@@ -29,6 +32,7 @@ import type {
29
32
  KeyHookContext,
30
33
  DiscoveredProject,
31
34
  ProjectHookContext,
35
+ RunReport,
32
36
  ScheduleHookContext,
33
37
  VxPlugin,
34
38
  WorkspaceHookContext,
@@ -385,12 +389,14 @@ export async function claimedAffected(
385
389
  export async function applyScheduleHooks(
386
390
  plugins: readonly VxPlugin[],
387
391
  nodes: ReadonlyMap<string, TaskNode>,
388
- ctx: ScheduleHookContext,
392
+ ctx: Omit<ScheduleHookContext, 'cache'>,
393
+ cacheFor: (plugin: string) => PluginCache = () => absentPluginCache,
389
394
  ): Promise<ReadonlyMap<string, number>> {
390
395
  const merged = new Map<string, number>()
391
396
  for (const plugin of plugins) {
392
397
  if (plugin.schedule === undefined) continue
393
- const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, ctx))
398
+ const own = { ...ctx, cache: cacheFor(plugin.name) }
399
+ const weights = await safe(plugin, 'schedule', () => plugin.schedule!(nodes, own))
394
400
  if (weights === undefined) continue
395
401
  // Iterating a string destructures its characters into `[id, w]` pairs
396
402
  // that match no task — a plugin returning `'fast'` used to be a no-op
@@ -478,6 +484,8 @@ export function buildAdmission(
478
484
  export interface CacheHost extends CacheContext {
479
485
  readonly localCache: Cache
480
486
  readonly policy: CachePolicy
487
+ /** Filled with each remote and what its policy lets it do, for the plugins' `ctx.cache`. */
488
+ readonly remotes?: PluginCacheRemote[]
481
489
  }
482
490
 
483
491
  /**
@@ -492,7 +500,7 @@ export async function resolveCache(
492
500
  plugins: readonly VxPlugin[],
493
501
  host: CacheHost,
494
502
  ): Promise<CacheLayer> {
495
- const { localCache, policy, ...ctx } = host
503
+ const { localCache, policy, remotes, ...ctx } = host
496
504
  const layers: CacheLayer[] = []
497
505
  const owners: string[] = []
498
506
  for (const plugin of plugins) {
@@ -515,6 +523,7 @@ export async function resolveCache(
515
523
  remoteRead: policy.remoteRead && access?.read !== false,
516
524
  remoteWrite: policy.remoteWrite && access?.write !== false,
517
525
  }
526
+ remotes?.push({ remote, read: own.remoteRead, write: own.remoteWrite })
518
527
  layers.push(
519
528
  new LayeredCache(localCache, remote, {
520
529
  policy: own,
@@ -628,12 +637,13 @@ export async function resolveExecutors(
628
637
  export async function teardownPlugins(
629
638
  plugins: readonly VxPlugin[],
630
639
  warn: (message: string) => void,
640
+ report?: RunReport,
631
641
  ): Promise<void> {
632
642
  for (const plugin of plugins) {
633
643
  if (plugin.teardown === undefined) continue
634
644
  const ms = teardownTimeoutMs()
635
645
  try {
636
- const settled = await settleWithin(Promise.resolve(plugin.teardown()), ms)
646
+ const settled = await settleWithin(Promise.resolve(plugin.teardown(report)), ms)
637
647
  // Same rule as the flush above. A teardown that never settles has left
638
648
  // whatever it owns un-released, and the run exits anyway — silence would
639
649
  // present that as a clean shutdown.
@@ -11,13 +11,19 @@
11
11
  import { readFileSync } from 'node:fs'
12
12
  import path from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
14
- import type { Cache, RemoteCacheLayer } from '../cache/index.js'
14
+ import {
15
+ absentPluginCache,
16
+ type Cache,
17
+ type PluginCache,
18
+ type RemoteCacheLayer,
19
+ } from '../cache/index.js'
15
20
  import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
16
21
  import type { TaskExecutor } from '../exec/index.js'
17
- import type { TaskNode, TaskOutcome } from '../graph/index.js'
22
+ import type { TaskNode, TaskOutcome, TaskStatus } from '../graph/index.js'
18
23
  import { maskedLine, nearest, UserError } from '../util/index.js'
19
24
  import type { ProjectMeta } from '../workspace/index.js'
20
25
  import type { EventBus, RunStartInfo } from './events.js'
26
+ import type { CiContext } from './run-context.js'
21
27
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
22
28
  import type { Logger } from './logger.js'
23
29
 
@@ -221,15 +227,51 @@ export interface VxPlugin {
221
227
  * Optional one-time setup per run (validate the workspace, open a
222
228
  * connection, read a token). Throwing aborts the run with a clean
223
229
  * UserError naming the plugin. It is not a guard over the pipeline: the
224
- * config, project, cache, graph, key and schedule stages have run by the
225
- * time it is called, and a plan (`--dry`, `planRun`) never calls it. It
226
- * precedes the executors, the telemetry sinks, admission and the first
227
- * task (tests/plugin-e2e.test.ts).
230
+ * config, project, cache, graph and key stages have run by the time it
231
+ * is called, and a plan (`--dry`, `planRun`) never calls it. It
232
+ * precedes the schedule stage, the executors, the telemetry sinks,
233
+ * admission and the first task (tests/plugin-e2e.test.ts).
228
234
  */
229
235
  setup?(ctx: PluginSetupContext): void | Promise<void>
230
236
 
231
- /** Optional teardown at end-of-run (flush a sink, close a socket). Errors are logged, never thrown. */
232
- teardown?(): void | Promise<void>
237
+ /**
238
+ * Optional teardown at end-of-run (flush a sink, close a socket). Errors
239
+ * are logged, never thrown. A run that ran to its end passes its
240
+ * `RunReport`; every other exit (a refused setup, a stop) passes none.
241
+ */
242
+ teardown?(run?: RunReport): void | Promise<void>
243
+ }
244
+
245
+ /**
246
+ * What a finished run hands each plugin's `teardown`: its facts and every
247
+ * task, with the names core kept the task's bytes under. Core keeps them
248
+ * only when a remote takes the run's writes (CI by default): a failed
249
+ * task's log and the files its outputs left, an uncached task's log. Read
250
+ * them with `ctx.cache.getRun(name)`. Core keeps no manifest of its own; a
251
+ * plugin that wants one writes it from here (`ctx.cache.put`).
252
+ */
253
+ export interface RunReport {
254
+ /** The `run_id` the run's history rows and telemetry carry. */
255
+ readonly runId: string
256
+ /** `HEAD`'s commit, null outside a git worktree. */
257
+ readonly commit: string | null
258
+ /** What the CI provider says about this run: provider, run URL, pipeline, job, attempt. */
259
+ readonly ciRun: CiContext
260
+ readonly ok: boolean
261
+ /** Every task the run decided, in completion order; group tasks are left out. */
262
+ readonly tasks: readonly RunReportTask[]
263
+ }
264
+
265
+ export interface RunReportTask {
266
+ readonly id: string
267
+ readonly status: TaskStatus
268
+ readonly durationMs: number
269
+ /** The task's cache key, when one was computed. */
270
+ readonly key?: string
271
+ /** Its output (both streams, secrets masked), when core kept it: `ctx.cache.getRun(log)`. */
272
+ readonly log?: string
273
+ /** A tar of the files its declared outputs left, when core kept it: `ctx.cache.getRun(outputs)`. */
274
+ readonly outputs?: string
233
275
  }
234
276
 
235
277
  /** Shared, read-only context every capability factory receives. */
@@ -242,6 +284,12 @@ interface BaseContext {
242
284
 
243
285
  /** What `setup` receives: the run's lifecycle, observe-only. */
244
286
  export interface PluginSetupContext extends BaseContext {
287
+ /** This plugin's entries in the workspace's cache (`PluginCache`). */
288
+ readonly cache: PluginCache
289
+ /** The run is on CI (`CI` set, or a provider core detects). */
290
+ readonly ci: boolean
291
+ /** The run's branch (`currentBranch`): `run.branch`, else git's, else the CI provider's. */
292
+ readonly branch: string | null
245
293
  /**
246
294
  * Subscribe a lifecycle handler; it leaves with the run. A throw or a
247
295
  * rejection warns once and switches the plugin's handlers off for the run.
@@ -271,6 +319,12 @@ export interface CommandContext extends BaseContext {
271
319
  * which `vx` a PATH holds.
272
320
  */
273
321
  readonly vx: readonly string[]
322
+ /**
323
+ * This plugin's entries in the workspace's cache, as `setup` reaches them
324
+ * (`PluginCache`), read-only: the local store, then each remote the
325
+ * workspace's cache plugins supply, under the run's scope. Opened on first use.
326
+ */
327
+ readonly cache: PluginCache
274
328
  }
275
329
 
276
330
  /** `config` runs before the cache dir is known — it may be what the hook changes. */
@@ -327,6 +381,13 @@ export interface ProjectHookContext extends BaseContext {
327
381
  * process.
328
382
  */
329
383
  readonly projects: readonly ProjectMeta[]
384
+ /**
385
+ * True when `vx lock` (or `--check`) visits: the one pass that checks the
386
+ * configs, so the place to report what a plugin's mapping could not say.
387
+ * A run checks nothing (the lock model), so such a report there repeats
388
+ * on every run.
389
+ */
390
+ readonly lock: boolean
330
391
  }
331
392
 
332
393
  export interface GraphHookContext extends BaseContext {
@@ -386,6 +447,8 @@ export interface ScheduleHookContext extends BaseContext {
386
447
  readonly localCache: Cache
387
448
  /** The run's branch (`currentBranch`), the timings a forecast reads first: pass it to `LocalHistoryProvider`. */
388
449
  readonly branch: string | null
450
+ /** This plugin's entries in the workspace's cache, as `setup` has them. */
451
+ readonly cache: PluginCache
389
452
  }
390
453
 
391
454
  /** A `cache` hook's context: the workspace and the run's scope, never the local store or the policy, which core owns. */
@@ -619,6 +682,12 @@ export interface InstallPluginsArgs {
619
682
  * `run:status` channel.
620
683
  */
621
684
  warn?: (message: string) => void
685
+ /** Each plugin's `ctx.cache`, by plugin name. Default: a cache with no layer. */
686
+ cache?: (plugin: string) => PluginCache
687
+ /** Default false. */
688
+ ci?: boolean
689
+ /** Default null. */
690
+ branch?: string | null
622
691
  }
623
692
 
624
693
  /**
@@ -675,6 +744,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
675
744
  workspaceRoot,
676
745
  cacheDir,
677
746
  warn,
747
+ cache: args.cache?.(plugin.name) ?? absentPluginCache,
748
+ ci: args.ci ?? false,
749
+ branch: args.branch ?? null,
678
750
  // A subscription made past the hooks leaves with the run like a
679
751
  // hook's does: the bus can outlive the run (`RunOptions.bus`), and
680
752
  // one left behind hears every later run on it (item 635).
@@ -19,6 +19,12 @@ import {
19
19
  parseCachePolicy,
20
20
  GitFilesCache,
21
21
  LayeredCache,
22
+ absentPluginCache,
23
+ pluginCache,
24
+ putRunEntry,
25
+ writesRunStore,
26
+ type PluginCache,
27
+ type PluginCacheRemote,
22
28
  ScopedCache,
23
29
  applyGitEnumeration,
24
30
  gitPathspecs,
@@ -116,6 +122,13 @@ export interface PreparedRun {
116
122
  * Learned on first ask, once per run.
117
123
  */
118
124
  branch: () => string | null
125
+ /** Each plugin's `ctx.cache`: the run's local store and remotes, under its policy and scope. */
126
+ cacheFor: (plugin: string) => PluginCache
127
+ /**
128
+ * Keep `bytes` of this run under `name` (run-store.ts); undefined when no
129
+ * remote takes the run's writes, and so core keeps nothing.
130
+ */
131
+ keepRunEntry: ((name: string, bytes: Uint8Array) => Promise<boolean>) | undefined
119
132
  nodes: Map<string, TaskNode>
120
133
  /**
121
134
  * The tasks `--exclude-dependencies` took out of the schedule: keyed as a
@@ -451,6 +464,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
451
464
  // winning prevents double-wrapping when the workspace also declares a
452
465
  // cache plugin.
453
466
  let cache: CacheLayer
467
+ const remotes: PluginCacheRemote[] = []
468
+ if (options.remoteCache) {
469
+ const { remoteCache } = options
470
+ remotes.push({ remote: remoteCache, read: policy.remoteRead, write: policy.remoteWrite })
471
+ }
454
472
  try {
455
473
  cache = options.remoteCache
456
474
  ? new LayeredCache(localCache, options.remoteCache, {
@@ -464,6 +482,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
464
482
  scope: cacheScope,
465
483
  localCache,
466
484
  policy,
485
+ remotes,
467
486
  })
468
487
  } catch (err) {
469
488
  // A cache plugin that throws or returns something off-contract is
@@ -509,8 +528,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
509
528
  localCache,
510
529
  hasRemoteLayer,
511
530
  cachePolicy: policy,
531
+ keepRunEntry: undefined,
512
532
  priorities: () => Promise.resolve(new Map()),
513
533
  branch,
534
+ cacheFor: () => absentPluginCache,
514
535
  nodes: new Map(),
515
536
  keyOnly: new Map(),
516
537
  unresolvedTasks,
@@ -708,16 +729,35 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
708
729
  })
709
730
  }
710
731
  }
732
+ const pluginCaches = new Map<string, PluginCache>()
733
+ const cacheFor = (plugin: string): PluginCache => {
734
+ let own = pluginCaches.get(plugin)
735
+ if (own === undefined) {
736
+ own = pluginCache(plugin, {
737
+ db: localCache.dbHandle(),
738
+ policy,
739
+ remotes,
740
+ scope: cacheScope,
741
+ })
742
+ pluginCaches.set(plugin, own)
743
+ }
744
+ return own
745
+ }
711
746
  let scheduled: Promise<ReadonlyMap<string, number>> | undefined
712
747
  const priorities = (): Promise<ReadonlyMap<string, number>> =>
713
748
  (scheduled ??= hasHook(plugins, 'schedule')
714
- ? applyScheduleHooks(plugins, nodes, {
715
- workspaceRoot,
716
- cacheDir,
717
- warn: (m) => log.status(m),
718
- localCache,
719
- branch: branch(),
720
- })
749
+ ? applyScheduleHooks(
750
+ plugins,
751
+ nodes,
752
+ {
753
+ workspaceRoot,
754
+ cacheDir,
755
+ warn: (m) => log.status(m),
756
+ localCache,
757
+ branch: branch(),
758
+ },
759
+ cacheFor,
760
+ )
721
761
  : Promise.resolve(new Map()))
722
762
 
723
763
  return {
@@ -731,6 +771,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
731
771
  cachePolicy: policy,
732
772
  priorities,
733
773
  branch,
774
+ cacheFor,
775
+ keepRunEntry: writesRunStore(remotes)
776
+ ? (name, bytes) => putRunEntry(remotes, cacheScope, name, bytes)
777
+ : undefined,
734
778
  nodes,
735
779
  keyOnly,
736
780
  unresolvedTasks,
@@ -163,6 +163,8 @@ interface LoadProjectsBase {
163
163
  */
164
164
  configs: () => Promise<Lockfile>
165
165
  warn: (message: string) => void
166
+ /** `vx lock`'s audit: the `project` stage's `ctx.lock`. */
167
+ lock?: boolean
166
168
  /**
167
169
  * Entries a load in this same process already produced with the same
168
170
  * cache dir and lock (the CLI's selection pass, which stages every
@@ -323,6 +325,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
323
325
  name: meta.name,
324
326
  dir: meta.dir,
325
327
  projects: args.projectMetas,
328
+ lock: args.lock === true,
326
329
  },
327
330
  (plugin) =>
328
331
  validateProjectConfig(
@@ -12,8 +12,9 @@
12
12
  // GET per key.
13
13
  //
14
14
  // HARD SCOPE: this runs ONLY when a remote layer is configured. It
15
- // NEVER touches the local cache (no local get / no isOutputsCurrent /
16
- // no stat pass) — it derives keys and probes REMOTE only. Local-only
15
+ // asks the local index only which keys it holds (no local get / no
16
+ // isOutputsCurrent), and probes the remote for the rest: a run the local
17
+ // cache answers makes no remote call (M, 2026-10-15). Local-only
17
18
  // runs are byte-identical to before; the caller gates on the layer's
18
19
  // own `hasRemote`, so a third-party remote layer gets the overlap too.
19
20
  // The two batch-probe hooks are OPTIONAL on the contract: a remote layer
@@ -75,8 +76,11 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
75
76
  // would 404. When the remote can't batch (`null` — an older remote, reads
76
77
  // disabled, or a layer that doesn't implement the hook at all), fall back to
77
78
  // prefetching every stable key, exactly as before.
78
- let toPrefetch = stableKeys
79
- const uniqueHashes = [...new Set(stableKeys.map((k) => k.hash))]
79
+ const held =
80
+ (await args.cache.localHasMany?.([...new Set(stableKeys.map((k) => k.hash))])) ?? null
81
+ let toPrefetch = held === null ? stableKeys : stableKeys.filter((k) => !held.has(k.hash))
82
+ if (toPrefetch.length === 0) return
83
+ const uniqueHashes = [...new Set(toPrefetch.map((k) => k.hash))]
80
84
  const present = (await args.cache.remoteHasMany?.(uniqueHashes)) ?? null
81
85
  if (present !== null) {
82
86
  args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
@@ -85,7 +89,7 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
85
89
  // economy: deleting it alone GETs nothing extra, deleting both GETs
86
90
  // every absent key. What it decides is which hashes the pump asks
87
91
  // the layer for, and that is what the row pins (item 643, 645).
88
- toPrefetch = stableKeys.filter((k) => present.has(k.hash))
92
+ toPrefetch = toPrefetch.filter((k) => present.has(k.hash))
89
93
  if (toPrefetch.length === 0) return
90
94
  }
91
95
 
@@ -27,7 +27,7 @@ export interface RunRecordsInput {
27
27
  ok: boolean
28
28
  command: string
29
29
  requestedTasks: readonly string[]
30
- /** Already compacted (`compactCachePolicy`), the form the header row stores. */
30
+ /** Already formatted (`formatCachePolicy`), the form the header row stores. */
31
31
  cachePolicy: string
32
32
  concurrency: number
33
33
  flow: InvocationRecord['flow']