@vzn/vx 0.0.581 → 0.0.584

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.581",
3
+ "version": "0.0.584",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.581",
25
- "@vzn/vx-linux-arm64": "0.0.581",
26
- "@vzn/vx-darwin-x64": "0.0.581",
27
- "@vzn/vx-darwin-arm64": "0.0.581"
24
+ "@vzn/vx-linux-x64": "0.0.584",
25
+ "@vzn/vx-linux-arm64": "0.0.584",
26
+ "@vzn/vx-darwin-x64": "0.0.584",
27
+ "@vzn/vx-darwin-arm64": "0.0.584"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/lock.ts CHANGED
@@ -90,8 +90,8 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
90
90
  if (leaks.length > 0) {
91
91
  process.stderr.write(
92
92
  `vx lock: ${LOCKFILE_NAME} is committed, and these configs evaluated to a secret value:\n` +
93
- leaks.map((l) => ` ${l}\n`).join('') +
94
- `let the shell expand it ($API_TOKEN in the command, the name in exec.env.passThrough) instead of reading process.env in the config\n`,
93
+ leaks.map((l) => ` ${l.at}\n`).join('') +
94
+ `let the shell expand it ($${leaks[0]!.name} in the command, the name in exec.env.passThrough) instead of reading process.env in the config\n`,
95
95
  )
96
96
  return 1
97
97
  }
@@ -111,7 +111,7 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
111
111
  * such a value would be published, and masking it would freeze a `***`
112
112
  * that `--frozen` runs (L-42).
113
113
  */
114
- function secretsIn(projects: Record<string, LockfileEntry>): string[] {
114
+ function secretsIn(projects: Record<string, LockfileEntry>): Array<{ at: string; name: string }> {
115
115
  const named = new Set<string>()
116
116
  for (const entry of Object.values(projects)) {
117
117
  for (const task of Object.values(entry.config.tasks ?? {}))
@@ -123,11 +123,11 @@ function secretsIn(projects: Record<string, LockfileEntry>): string[] {
123
123
  secrets.push([value, name])
124
124
  }
125
125
  if (secrets.length === 0) return []
126
- const found: string[] = []
126
+ const found: Array<{ at: string; name: string }> = []
127
127
  const walk = (project: string, at: string, v: unknown): void => {
128
128
  if (typeof v === 'string') {
129
129
  for (const [value, name] of secrets)
130
- if (v.includes(value)) found.push(`${project}: ${at} holds $${name}`)
130
+ if (v.includes(value)) found.push({ at: `${project}: ${at} holds $${name}`, name })
131
131
  } else if (Array.isArray(v)) {
132
132
  v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
133
133
  } else if (v !== null && typeof v === 'object') {
@@ -270,10 +270,9 @@ export function makeRootEventFilter(
270
270
  * project nested there: the key's boundary (`computeNestedProjectDirs`)
271
271
  * leaves such a file out, so it is no edit of `ownDir`'s. A root project
272
272
  * watched its whole tree and ran a cycle for every edit in a nested one
273
- * (X-42). The fences are the projects with a config, which fence a key
274
- * whatever the plugins; a config-less one may not, and an edit there
275
- * still counts. The fence's own config is let through: it coming or going
276
- * moves the boundary, and the cycle it starts re-reads the set.
273
+ * (X-42). The fences are every project, config or not, as the key's are
274
+ * (X-57). The fence's own config is let through: it may give the project
275
+ * tasks, and the cycle it starts re-reads the set.
277
276
  */
278
277
  export function makeFence(fenceDirs: readonly string[]): (ownDir: string, abs: string) => boolean {
279
278
  const fences = fenceDirs.map((d) => path.resolve(d))
package/src/cli/watch.ts CHANGED
@@ -316,14 +316,13 @@ interface WatchLoopArgs {
316
316
  memberBases: readonly string[]
317
317
  /** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
318
318
  packageDirs: ReadonlySet<string>
319
- /** Every project with a config, in scope or not: a project's key leaves out what lies in one nested under it. */
319
+ /** Every project, in scope or not: a project's key leaves out what lies in one nested under it. */
320
320
  fenceDirs: readonly string[]
321
321
  /** The watched set again, after a cycle that followed an event which can change it. */
322
322
  rediscover: () => Promise<Rediscovered>
323
323
  }
324
324
 
325
- const fenceDirs = (all: readonly ProjectMeta[]): string[] =>
326
- all.filter((p) => p.configPath !== null).map((p) => p.dir)
325
+ const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.dir)
327
326
 
328
327
  interface Rediscovered {
329
328
  projects: readonly ProjectMeta[]
@@ -267,6 +267,9 @@ export interface ScheduleOptions {
267
267
  * output is reported `cache-hit` regardless of a dep failing);
268
268
  * - leaves the tier when `execute` throws `RestoreDemoted`, and is
269
269
  * dispatched again once its deps are done, with the dep check.
270
+ * A persistent task nobody requested whose every dependant is in the
271
+ * tier starts only when one of them is demoted; once they have all
272
+ * finished it settles without an outcome, never spawned.
270
273
  * It still runs through `execute()` (so the logger frame is unchanged);
271
274
  * the orchestrator's execute reuses the up-front probe, so there is no
272
275
  * second cache.get. When undefined/empty, behavior is byte-identical.
@@ -463,6 +466,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
463
466
  // they are exec-tier tasks, gated on their deps like any other.
464
467
  const demoted = new Set<string>()
465
468
  const inRestoreTier = (id: string): boolean => restoreTier?.has(id) === true && !demoted.has(id)
469
+ // A server pulled in only for restore-tier hits serves nobody: it waits
470
+ // (`dormant`) until a dependant is demoted, which starts it, or until
471
+ // every dependant has finished, which settles it unspawned. Settled
472
+ // without an outcome: it is not part of the run, as a pruned task is not.
473
+ const idle = idleServers(nodes, dependents, restoreTier)
474
+ const dormant = new Set<string>()
475
+ let settledIdle = 0
466
476
  const execReady = new ReadyHeap(priority)
467
477
  const restoreReady = new ReadyHeap(priority)
468
478
  // A restore-tier task is dep-independent (a stable hit's restore needs
@@ -580,6 +590,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
580
590
  const finishOne = (id: string, outcome: TaskOutcome): void => {
581
591
  if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
582
592
  outcomes.set(id, outcome)
593
+ // A dormant server is asked again: the dispatch settles it once its
594
+ // last dependant is in.
595
+ if (dormant.size > 0) {
596
+ for (const p of (nodes.get(id) as TaskNode).deps) if (dormant.delete(p)) execReady.push(p)
597
+ }
583
598
  // Observer hook (the logger's taskComplete). Crash-isolated: a
584
599
  // throwing observer must NOT break scheduling — it would otherwise
585
600
  // skip the dependent-enqueue + tick below and hang the run. Same
@@ -724,7 +739,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
724
739
  while (execReady.size > 0) {
725
740
  const seq = execReady.peekSeq()
726
741
  const id = execReady.pop() as string
727
- if (willSkip(id)) return id
742
+ if (willSkip(id) || idle.has(id)) return id
728
743
  if (exclusive?.has(id) === true) {
729
744
  if (!busy() && (!admitActive || admits(id))) return id
730
745
  parked.push([id, seq])
@@ -779,6 +794,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
779
794
  })
780
795
  continue
781
796
  }
797
+ if (idle.has(id)) {
798
+ readyAt.delete(id)
799
+ if (dependents.get(id)!.every((d) => outcomes.has(d))) {
800
+ settledIdle++
801
+ release(id)
802
+ } else dormant.add(id)
803
+ continue
804
+ }
782
805
 
783
806
  const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
784
807
  readyAt.delete(id)
@@ -847,6 +870,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
847
870
  leave()
848
871
  untrack()
849
872
  demoted.add(id)
873
+ for (const p of node.deps) {
874
+ if (idle.delete(p) && dormant.delete(p)) {
875
+ execReady.push(p)
876
+ readyAt.set(p, Date.now())
877
+ }
878
+ }
850
879
  if (pending.get(id) === 0) {
851
880
  execReady.push(id)
852
881
  readyAt.set(id, Date.now())
@@ -919,7 +948,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
919
948
  // takes none), so a task still holding one has no outcome yet, and
920
949
  // the lane counters could add nothing — deleting both from this
921
950
  // condition survived the whole core suite (item 644).
922
- if (outcomes.size === nodes.size) {
951
+ if (outcomes.size + settledIdle === nodes.size) {
923
952
  resolved = true
924
953
  resolve(outcomes)
925
954
  }
@@ -928,3 +957,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
928
957
  tick()
929
958
  })
930
959
  }
960
+
961
+ /** Persistent tasks nobody asked for whose every dependant is a restore-tier hit. */
962
+ function idleServers(
963
+ nodes: ReadonlyMap<string, TaskNode>,
964
+ dependents: ReadonlyMap<string, string[]>,
965
+ restoreTier: ReadonlySet<string> | undefined,
966
+ ): Set<string> {
967
+ const out = new Set<string>()
968
+ if (restoreTier === undefined || restoreTier.size === 0) return out
969
+ for (const n of nodes.values()) {
970
+ if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
971
+ const ds = dependents.get(n.id)
972
+ if (ds !== undefined && ds.every((d) => restoreTier.has(d))) out.add(n.id)
973
+ }
974
+ return out
975
+ }
@@ -929,6 +929,14 @@ function collideAcross(
929
929
  ;(files.outputsAddedToBy ??= []).push(...own)
930
930
  return
931
931
  }
932
+ // Already ordered: "make one depend on the other" would send the
933
+ // reader after an edge the config has. Name what keeps it out.
934
+ const outside = wsGlobs[own.indexOf(null)]!
935
+ throw new UserError(
936
+ `${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
937
+ `cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
938
+ `and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
939
+ )
932
940
  }
933
941
  throw new UserError(
934
942
  `${head} and the run still reports success. Give each task its own output path` +
@@ -357,12 +357,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
357
357
  const { projects, configured: projectsWithConfigs } = loaded
358
358
  mark('load configs')
359
359
 
360
- // Boundary geometry considers every config-bearing project in the
361
- // workspace, loaded or not — an out-of-scope nested project must
362
- // still fence its files off from its parent's globs.
363
- const nestedDirsByProject = computeNestedProjectDirs(
364
- projectsWithConfigs.map((m) => ({ name: m.name, dir: m.dir })),
365
- )
360
+ // Boundary geometry considers every workspace project, loaded or not,
361
+ // config or not: `--affected` gives a changed path to the deepest project
362
+ // dir, and a config-less one keys its files under its default `build`, so
363
+ // a parent's glob that folded (or cleaned) them crossed a boundary the
364
+ // selection drew (X-57).
365
+ const nestedDirsByProject = computeNestedProjectDirs(projectMetas)
366
366
 
367
367
  const candidateProjects = options.projects
368
368
  ? options.projects.filter((p) => projects.has(p))
@@ -155,8 +155,7 @@ export interface LoadedProjects {
155
155
  projects: Map<string, ProjectEntry>
156
156
  /**
157
157
  * Every project that can carry tasks: it has a config file, or a plugin
158
- * fills the `project` stage and may give it tasks. Boundary geometry
159
- * fences all of them, loaded or not.
158
+ * fills the `project` stage and may give it tasks.
160
159
  */
161
160
  configured: readonly ProjectMeta[]
162
161
  }
@@ -179,7 +178,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
179
178
  // scripts mapped onto packages that never wrote a `vx.config.ts`). It
180
179
  // then loads as `{ tasks: {} }` — nothing to evaluate, nothing to freeze
181
180
  // — and the stage runs on that like on any loaded config. With no
182
- // `project` plugin a plain run never seeds (or fences) a config-less
181
+ // `project` plugin a plain run never seeds a config-less
183
182
  // package, but loads one a loaded project's closure reaches: it gets the
184
183
  // default `build`, so a dependant bundling its source is keyed on it
185
184
  // behind `^build`. Left out, its edit replayed the dependant's stale