@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
2
3
  import {
3
4
  ArtifactVanishedError,
@@ -43,7 +44,7 @@ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '..
43
44
  import {
44
45
  killGraceMs,
45
46
  MASKED,
46
- maskedEmitter,
47
+ maskedStreams,
47
48
  outputMatcher,
48
49
  printable,
49
50
  relPosix,
@@ -67,15 +68,22 @@ import {
67
68
  type WithheldLink,
68
69
  withheldLinkLine,
69
70
  } from './sandbox-request.js'
70
- import { markUnsaved, saveMiss, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
71
+ import {
72
+ bracketLiteralHint,
73
+ markUnsaved,
74
+ saveMiss,
75
+ type OutputDirSnapshot,
76
+ type SaveFacts,
77
+ } from './miss-save.js'
71
78
  import type { FingerprintWatch } from './fingerprint-watch.js'
72
- import { restoreHit } from './hit-restore.js'
79
+ import { currentHit, restoreHit } from './hit-restore.js'
73
80
  import type { MissExplainer } from '../history/index.js'
74
81
  import { shellVerdict } from './shell-verdict.js'
75
82
  import { sandboxFix } from './sandbox-fix.js'
76
83
  // The hit path's entry stays importable from here (tests).
77
84
  export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
78
85
  import type { DeferredOutputs } from './deferred-outputs.js'
86
+ import type { TreeLoad } from './telemetry.js'
79
87
  import { type Logger, warnOf } from '../report/index.js'
80
88
  import {
81
89
  computeGroupHash,
@@ -94,6 +102,7 @@ import {
94
102
  forgetUndeclaredWrites,
95
103
  isAborted,
96
104
  taskBinDirs,
105
+ serverSpec,
97
106
  taskEnv,
98
107
  taskTimeoutMs,
99
108
  } from './task-process.js'
@@ -109,6 +118,8 @@ export interface ExecuteArgs {
109
118
  log: Logger
110
119
  /** The executor this task was PLACED on (run.ts, before scheduling). */
111
120
  executor: TaskExecutor
121
+ /** Told the task executes, so the executor's demand counts it again (`ExecutorDemand.executing`). */
122
+ executing?: ((id: string) => void) | undefined
112
123
  /**
113
124
  * `exec.remote: 'only'` with no remote executor to take it: succeed
114
125
  * WITHOUT executing, cleaning, restoring or saving anything on this
@@ -161,7 +172,7 @@ export interface ExecuteArgs {
161
172
  */
162
173
  liveChildren?: Set<ReturnType<typeof Bun.spawn>>
163
174
  /** Sample a spawned task's process tree (`TelemetrySource.track`); absent when nobody samples. */
164
- track?: (taskId: string, pid: number) => () => void
175
+ track?: (taskId: string, pid: number) => () => TreeLoad | undefined
165
176
  /** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
166
177
  explainMiss?: MissExplainer
167
178
  /**
@@ -270,6 +281,12 @@ export interface ExecuteArgs {
270
281
  noDependants?: true
271
282
  /** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
272
283
  terminal?: true
284
+ /** A persistent task every dependant of which starts it where it runs: never spawned here. */
285
+ carried?: true
286
+ /** The persistent tasks its `servers` executor starts where it runs it. */
287
+ servers?: readonly TaskNode[]
288
+ /** Each of `servers`' nested project dirs, in its order. */
289
+ serversNested?: readonly (readonly string[])[]
273
290
  /** When each command of the run ran and where it may write (command-windows.ts). */
274
291
  commandWindows?: CommandWindows
275
292
  }
@@ -300,6 +317,28 @@ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
300
317
  return executeCachedTask(args)
301
318
  }
302
319
 
320
+ /**
321
+ * A probed hit `executeTask` would restore, settled now when its tree is
322
+ * already current (`currentHit`): the thunk replays its output and returns
323
+ * the outcome. Undefined dispatches it as usual. A dispatch, a slot and the
324
+ * promises around it were ~10 µs of each of a warm run's hits.
325
+ */
326
+ export function settleProbedHit(args: ExecuteArgs): (() => TaskOutcome) | undefined {
327
+ const probed = args.preProbed
328
+ if (probed?.hit == null || !readsCache(args) || args.remoteOnlyNoop === true) return undefined
329
+ if (isGroupTask(args.node) || args.node.config.exec?.persistent !== undefined) return undefined
330
+ // A moved fingerprint is said, and demotes the hit, on the dispatch.
331
+ if (args.fingerprintWatch?.moved() !== undefined) return undefined
332
+ const restore = {
333
+ args,
334
+ hash: probed.hash,
335
+ hit: probed.hit,
336
+ cacheOpStart: performance.now(),
337
+ taskStartNs: process.hrtime.bigint() - args.runStartHrTimeNs,
338
+ }
339
+ return currentHit(restore)
340
+ }
341
+
303
342
  /** Whether this task reads the cache: a cached, not remote-only task with a read axis on. */
304
343
  function readsCache(args: ExecuteArgs): boolean {
305
344
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
@@ -358,7 +397,7 @@ async function restoreOrMiss(
358
397
  /**
359
398
  * What a write to an input during the command means, each field highest
360
399
  * first: the env var (`layers.run`), the task's `cache.inputStability`,
361
- * the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
400
+ * the workspace's, then `'immutable'` and `'warn'` (2026-10-12), so
362
401
  * by default any such write withholds the save, on CI too, since a
363
402
  * concurrent task can change an input and put it back.
364
403
  */
@@ -498,8 +537,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
498
537
  filterUpstreamHashes(present, cfg.inputs?.tasks, node.projectName, node.id).map(([id]) => id),
499
538
  )
500
539
  const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
501
- const keyless =
502
- isKeyless(cfg) || foldedTasks.some((u) => u.keyless === true || isKeyless(u.node.config))
540
+ const keylessBy = isKeyless(cfg)
541
+ ? node.id
542
+ : foldedTasks
543
+ .map((u) => u.keylessBy ?? (isKeyless(u.node.config) ? u.node.id : undefined))
544
+ .find((id) => id !== undefined)
545
+ const keyless = keylessBy !== undefined
503
546
  const reads = policy.localRead || policy.remoteRead
504
547
  const willRead = !remoteOnly && cfgCacheable && !keyless && reads
505
548
  const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
@@ -539,7 +582,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
539
582
  // The local executor keeps its own timer from the spawn (it signals the
540
583
  // process group), so its request carries no clock: armed here, it also
541
584
  // timed the sandbox's wrap, and a wrap slower than the timeout aborted
542
- // the request before the spawn — a task reported killed, never run.
585
+ // the request before the spawn — a task reported killed, never run. So
586
+ // does an executor that declares `timeouts`.
543
587
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
544
588
  let timeoutFired = false
545
589
  // The attempt's listener on the run's stop signal, taken off once the
@@ -560,7 +604,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
560
604
  }
561
605
  clearTimeout(timeoutTimer)
562
606
  timeoutFired = false
563
- if (effectiveTimeout !== undefined && !local) {
607
+ if (effectiveTimeout !== undefined && !local && args.executor.timeouts !== true) {
564
608
  timeoutTimer = setTimeout(() => {
565
609
  timeoutFired = true
566
610
  stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
@@ -588,7 +632,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
588
632
  const why = timeoutFired ? `timeout (${effectiveTimeout}ms)` : 'stop'
589
633
  log.taskStderr(
590
634
  node,
591
- `vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
635
+ `${midLine ? '\n' : ''}vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
592
636
  )
593
637
  const reason: unknown = signal.reason
594
638
  resolve({
@@ -692,8 +736,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
692
736
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
693
737
  // `cache.outputs.clean: false` leaves the outputs to the first attempt's
694
738
  // command, and the save stores whatever they hold after it, a leftover
695
- // included: the user opted out of the clean-tree guarantee (M,
696
- // 2026-10-15). A retry still starts clean. An additive task never cleans
739
+ // included: the user opted out of the clean-tree guarantee
740
+ // (2026-10-15). A retry still starts clean. An additive task never cleans
697
741
  // by glob anyway.
698
742
  const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
699
743
 
@@ -787,11 +831,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
787
831
  for (const c of captured)
788
832
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
789
833
  c.hash = MASKED + c.hash
834
+ // The values of secret-named variables, masked in what the task prints
835
+ // and what the cache keeps of it (L-11); null when there are none.
836
+ const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
790
837
  // A runtime probe's row is named by its command, which a TS config may
791
838
  // build from `process.env`: the run history stored it and `vx why`
792
839
  // printed it (L-11).
793
- const nameSecrets =
794
- captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
840
+ const nameSecrets = captured.length > 0 ? secrets : null
795
841
  if (nameSecrets !== null) {
796
842
  // Two probes that differ only by a secret mask to one name, and a row
797
843
  // is stored once per name: numbered, the second's change still shows.
@@ -819,7 +865,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
819
865
  if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
820
866
  warnOf(log)(
821
867
  `[vx] ${node.id}: inputs matched no files (${declaredInputs.join(', ')}) — ` +
822
- `the key will not change when this project's source does`,
868
+ `the key will not change when this project's source does` +
869
+ bracketLiteralHint([
870
+ ...(cfg.inputs?.files ?? []).map((g) => [node.projectDir, g] as const),
871
+ ...(cfg.inputs?.workspaceFiles ?? []).map((g) => [args.workspaceRoot, g] as const),
872
+ ]),
823
873
  )
824
874
  }
825
875
 
@@ -892,6 +942,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
892
942
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
893
943
  args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
894
944
  }
945
+ // What the attempt before this one left, put back if this one is
946
+ // withdrawn: it stands, with its output and its refusals.
947
+ const standing = { output, violations, flushMasked }
895
948
  violations = []
896
949
  const endReq = span('miss: build request')
897
950
  const req = await buildRequest(isLocalExecutor(args.executor))
@@ -900,6 +953,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
900
953
  // cleans or builds its request, after the loop's own check: the retry
901
954
  // is withdrawn and the attempt before it stands.
902
955
  if (attempt > 1 && args.failFast?.aborted === true) {
956
+ output = standing.output
957
+ violations = standing.violations
958
+ flushMasked = standing.flushMasked
903
959
  clearTimeout(timeoutTimer)
904
960
  unlistenStop?.()
905
961
  unlistenStop = undefined
@@ -921,6 +977,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
921
977
  node.projectDir,
922
978
  commandWriteReach(node, args.workspaceRoot),
923
979
  ) ?? (() => {})
980
+ args.executing?.(node.id)
924
981
  try {
925
982
  running = Promise.resolve(args.executor.execute(req))
926
983
  } catch (thrown) {
@@ -940,8 +997,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
940
997
  const why = secrets?.mask(raw.message) ?? raw.message
941
998
  if (remoteOnly) {
942
999
  raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
1000
+ } else if (args.executor.exclusive === true) {
1001
+ raw = new UserError(`${why}, and ${args.executor.name} runs no task on this machine`)
943
1002
  } else {
944
- warnOf(log)(`[vx] ${node.id}: ${why} — running it here`)
1003
+ warnOf(log)(
1004
+ `[vx] ${node.id}: ${executorLabel(args.executor)} gave it back: ${why} — running it here`,
1005
+ )
945
1006
  const local = await localExecutor().execute(await buildRequest(true))
946
1007
  assertExecuteResult('local', node.id, local)
947
1008
  return local
@@ -964,9 +1025,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
964
1025
  unlistenStop?.()
965
1026
  unlistenStop = undefined
966
1027
  flushMasked()
967
- untrack?.()
1028
+ load = untrack?.() ?? load
968
1029
  untrack = undefined
969
1030
  })
1031
+ .catch((thrown: unknown) => {
1032
+ // The scheduler prints the throw's line into the task's stream,
1033
+ // after the masked tail the finally above flushed.
1034
+ if (midLine) log.taskStderr(node, '\n')
1035
+ throw thrown
1036
+ })
970
1037
  endExec()
971
1038
  if (secrets !== null)
972
1039
  res = {
@@ -1006,7 +1073,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1006
1073
  // before the strace pass shipped, false since, and the branch below is
1007
1074
  // live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
1008
1075
  let code = res.exitCode
1009
- // ANY violation fails the task (owner, 2026-09-05). A sandboxed task
1076
+ // ANY violation fails the task (2026-09-05). A sandboxed task
1010
1077
  // declares what it touches; a denial means it touched something else,
1011
1078
  // and an artifact built while reading — or failing to read — something
1012
1079
  // the cache key never folded is not a safe thing to replay. A task that
@@ -1022,7 +1089,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1022
1089
  // 137 (item 1063).
1023
1090
  if (res.timedOut) {
1024
1091
  const how = res.signal === 'SIGKILL' ? 'SIGKILL after the SIGTERM grace' : 'SIGTERM'
1025
- log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`)
1092
+ log.taskStderr(
1093
+ node,
1094
+ `${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`,
1095
+ )
1026
1096
  // Force a non-zero classification even if the child TRAPPED SIGTERM and
1027
1097
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
1028
1098
  // pattern). Without this a timed-out task is classified `success` and its
@@ -1037,7 +1107,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1037
1107
  // about what sent it (259). One frame line names the rule. A spawn that
1038
1108
  // threw ran no shell: its 127 is vx's, and the line would send the
1039
1109
  // reader after a command that exists (A-41). A run's stop killed it
1040
- // itself: the SIGKILL past the grace read as the OOM killer's.
1110
+ // itself: the SIGKILL past the grace read as the OOM killer's. A
1111
+ // SIGINT/SIGTERM death waits a turn for a pending handler first, as the
1112
+ // shutdown check below does: a Ctrl-C reaches the child and vx together.
1113
+ if (
1114
+ !res.timedOut &&
1115
+ (res.signal === 'SIGINT' || res.signal === 'SIGTERM') &&
1116
+ args.stopSignal?.aborted !== true
1117
+ )
1118
+ await new Promise((resolve) => setTimeout(resolve, 0))
1041
1119
  if (!res.timedOut && res.spawnFailed !== true && args.stopSignal?.aborted !== true) {
1042
1120
  const verdict = shellVerdict({
1043
1121
  code,
@@ -1049,7 +1127,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1049
1127
  })
1050
1128
  // The line quotes the command's first word, which a config may have
1051
1129
  // built from a secret: masked as the task's own output is (L-37).
1052
- if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
1130
+ if (verdict !== undefined)
1131
+ log.taskStderr(node, `${midLine ? '\n' : ''}${secrets?.mask(verdict) ?? verdict}\n`)
1053
1132
  }
1054
1133
  return { result: res, exitCode: code }
1055
1134
  }
@@ -1065,7 +1144,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1065
1144
  await args.deferred.materializeFor(node)
1066
1145
  } catch (err) {
1067
1146
  const msg = err instanceof Error ? err.message : String(err)
1068
- log.taskStderr(node, `\n[vx] ${msg}\n`)
1147
+ // Nothing ran: the note is the frame's first line.
1148
+ log.taskStderr(node, `[vx] ${msg}\n`)
1069
1149
  return {
1070
1150
  node,
1071
1151
  status: 'failed',
@@ -1078,17 +1158,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1078
1158
  }
1079
1159
  }
1080
1160
 
1081
- // The values of secret-named variables, masked in what the task prints
1082
- // and what the cache keeps of it (L-11); null when there are none.
1083
- const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
1084
1161
  let flushMasked = (): void => {}
1162
+ // The attempt's output, both streams, last ended mid-line: vx's own line
1163
+ // (a retry, a timeout, the shell's verdict, an abandoned or thrown
1164
+ // executor) starts its own, and only then: a newline always before it
1165
+ // printed a blank line under the OUTPUT rule or the task's last line. Glued, a frame read
1166
+ // `partialvx: retrying …`.
1167
+ let midLine = false
1085
1168
  // The attempt's output as the logger got it (masked): the entry's when
1086
1169
  // the task saves, else only what a failure reports (`failedOutput`).
1087
1170
  let output: BoundedCapture | undefined
1088
- const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
1171
+ // Masked again whole: the live stream lets a held piece go after a pause.
1172
+ const storeMask = secrets ?? undefined
1173
+ const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
1089
1174
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1090
1175
  // The sampling of the attempt's process tree, stopped once it settles.
1091
- let untrack: (() => void) | undefined
1176
+ let untrack: (() => TreeLoad | undefined) | undefined
1177
+ // What the last attempt's samples saw, for the run history.
1178
+ let load: TreeLoad | undefined
1092
1179
  // The entry's command is shown by `vx why` and sent with the entry to a
1093
1180
  // remote cache: a value a config interpolated stays out of both.
1094
1181
  const storedCommand = secrets?.mask(step.command) ?? step.command
@@ -1160,6 +1247,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1160
1247
  hash,
1161
1248
  wallclockStartNs,
1162
1249
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
1250
+ ...(result.where !== undefined ? { where: result.where } : {}),
1163
1251
  }
1164
1252
  }
1165
1253
 
@@ -1171,7 +1259,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1171
1259
  })
1172
1260
  log.taskStderr(
1173
1261
  node,
1174
- `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
1262
+ `${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
1175
1263
  )
1176
1264
  }
1177
1265
 
@@ -1181,20 +1269,19 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1181
1269
  ? new BoundedCapture()
1182
1270
  : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1183
1271
  output = kept
1272
+ midLine = false
1184
1273
  const toOut = (t: string): void => {
1185
1274
  kept.push(t)
1275
+ if (t.length > 0) midLine = !t.endsWith('\n')
1186
1276
  log.taskStdout(node, t)
1187
1277
  }
1188
1278
  const toErr = (t: string): void => {
1189
1279
  kept.push(t, true)
1280
+ if (t.length > 0) midLine = !t.endsWith('\n')
1190
1281
  log.taskStderr(node, t)
1191
1282
  }
1192
- const out = secrets && maskedEmitter(secrets, toOut)
1193
- const err = secrets && maskedEmitter(secrets, toErr)
1194
- flushMasked = () => {
1195
- out?.end()
1196
- err?.end()
1197
- }
1283
+ const masked = secrets && maskedStreams(secrets, toOut, toErr)
1284
+ flushMasked = () => masked?.end()
1198
1285
  const base: ExecuteRequest = {
1199
1286
  taskId: node.id,
1200
1287
  workspaceRoot: args.workspaceRoot,
@@ -1204,8 +1291,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1204
1291
  env,
1205
1292
  envDefine: step.env?.define ?? {},
1206
1293
  capture,
1207
- onStdout: out ? (chunk) => out.push(chunk) : toOut,
1208
- onStderr: err ? (chunk) => err.push(chunk) : toErr,
1294
+ onStdout: masked ? masked.out : toOut,
1295
+ onStderr: masked ? masked.err : toErr,
1209
1296
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
1210
1297
  ...(args.track !== undefined
1211
1298
  ? {
@@ -1218,19 +1305,29 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1218
1305
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
1219
1306
  ...(inputs !== undefined ? { inputs } : {}),
1220
1307
  ...(cfgCacheable ? { cacheKey: hash } : {}),
1308
+ ...(cfgCacheable && !remoteOnly && !willSave ? { unsaved: true } : {}),
1221
1309
  ...(remoteOnly ? { remoteOnly: true } : {}),
1310
+ ...(node.platform !== undefined ? { platform: node.platform } : {}),
1222
1311
  ...(deferralRequested ? { download: 'deferred' as const } : {}),
1223
1312
  // `--force`/`--no-cache` reach a remote executor's private record
1224
1313
  // through this flag — the policy gates above only cover vx's OWN cache.
1225
1314
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
1226
1315
  outputs: { files: outputs, workspaceFiles: wsOutputs },
1227
1316
  ...(args.terminal === true ? { terminal: true as const } : {}),
1317
+ ...(args.servers === undefined
1318
+ ? {}
1319
+ : {
1320
+ servers: args.servers.map((s, i) =>
1321
+ serverSpec(s, args.workspaceRoot, args.serversNested?.[i] ?? []),
1322
+ ),
1323
+ }),
1228
1324
  }
1229
1325
  // The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
1230
1326
  // below is vx's work, not the task's. Armed first, a 60 ms timeout
1231
1327
  // expired before the spawn and failed `echo` as timed out, unrun.
1232
1328
  if (!userSandbox) return { ...base, signal: requestSignal(local) }
1233
- await args.armSandbox?.()
1329
+ // A remote executor enforces it where it runs the task.
1330
+ if (args.executor.remote !== true) await args.armSandbox?.()
1234
1331
  const sb = await sandboxRequestFor(
1235
1332
  node,
1236
1333
  step.sandbox!,
@@ -1250,7 +1347,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1250
1347
  let movedFails: string | undefined
1251
1348
  let own: Awaited<ReturnType<typeof ownOutputs>>
1252
1349
  let saved = false
1253
- if (effectiveExitCode === 0 && willSave && deferralRequested) {
1350
+ if (
1351
+ effectiveExitCode === 0 &&
1352
+ willSave &&
1353
+ (deferralRequested || result.outputs?.kind === 'deferred')
1354
+ ) {
1254
1355
  // The outputs never landed here: no artifact, no rows. A partial local
1255
1356
  // record (a row with no artifact) is exactly the corrupt-entry shape
1256
1357
  // `restoreOutputs` refuses, so writing none is the only clean answer.
@@ -1376,6 +1477,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1376
1477
  */
1377
1478
  async function movedSinceKey(): Promise<string | null | undefined> {
1378
1479
  if (described!.hash !== hash) return null
1480
+ // Changed where an executor ran it: this disk never shows it.
1481
+ if (result.changed !== undefined && result.changed.length > 0) {
1482
+ const changed = new Set(result.changed.map((f) => path.join(args.workspaceRoot, f)))
1483
+ const there = keyedInputs(described!.facts).find((f) => changed.has(f.path))
1484
+ if (there !== undefined) return there.path
1485
+ }
1379
1486
  return (
1380
1487
  (await movedInput(
1381
1488
  keyedInputs(described!.facts),
@@ -1463,10 +1570,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1463
1570
  : {}),
1464
1571
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1465
1572
  ...(unkeyed ? { unkeyed: true as const } : {}),
1466
- ...(keyless ? { keyless: true as const } : {}),
1573
+ ...(keylessBy !== undefined ? { keylessBy } : {}),
1467
1574
  ...(saved ? { saved: true as const } : {}),
1468
1575
  ...(effectiveExitCode !== 0 && output !== undefined
1469
- ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined) }
1576
+ ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined, storeMask) }
1470
1577
  : {}),
1471
1578
  ...(args.keepRun !== undefined &&
1472
1579
  (effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
@@ -1477,10 +1584,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1477
1584
  ...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
1478
1585
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1479
1586
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1587
+ ...(load !== undefined ? { peakCores: load.peakCores, avgRssBytes: load.avgRssBytes } : {}),
1480
1588
  ...(result.where !== undefined ? { where: result.where } : {}),
1481
- ...(deferralRequested && result.outputs?.kind === 'deferred'
1482
- ? { outputs: 'deferred' as const }
1589
+ ...(result.where !== undefined && result.machine !== undefined
1590
+ ? {
1591
+ whereCores: result.machine.cores,
1592
+ whereMemoryBytes: result.machine.memoryBytes,
1593
+ ...(result.machine.cpuShare === undefined ? {} : { cpuShare: result.machine.cpuShare }),
1594
+ }
1483
1595
  : {}),
1596
+ ...(result.outputs?.kind === 'deferred' ? { outputs: 'deferred' as const } : {}),
1484
1597
  wallclockStartNs,
1485
1598
  wallclockEndNs,
1486
1599
  ...(finalViolations.length > 0
@@ -85,7 +85,7 @@ export class FingerprintWatch {
85
85
  this.said = true
86
86
  warnOf(log)(
87
87
  `[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which a plugin ` +
88
- `folds into every key: nothing keyed before it is restored or saved from here on`,
88
+ `puts into every key: nothing keyed before it is restored or saved from here on`,
89
89
  )
90
90
  }
91
91
  }
@@ -0,0 +1,140 @@
1
+ // The task graph memo: the graph a run built, read back whole when nothing
2
+ // it is built from has moved. Building 1,206 tasks' graph was 4.5 ms of a
3
+ // warm run (2026-10-23), nearly all of it code a short process runs cold;
4
+ // reading it back is half that.
5
+ //
6
+ // A run's configs come from the lock and nowhere else when no plugin fills
7
+ // the `project` stage, so the token names them by the lock's bytes instead
8
+ // of serializing them (3 ms at 1,206 tasks): the bytes, the projects
9
+ // discovered and loaded, their package edges, the requested tasks, the
10
+ // rules, the root and the code are everything `buildTaskGraph` reads. A
11
+ // scoped load hands undeclared `^name`s back instead of refusing them, so
12
+ // it is never remembered.
13
+
14
+ import type { FoldMemo } from '../cache/index.js'
15
+ import { buildTaskGraph, declaredTask, type TaskNode } from '../graph/index.js'
16
+ import { xxh3hex } from '../util/index.js'
17
+ import type { ProjectMeta } from '../workspace/index.js'
18
+ import { codeIdentity } from './run-keys.js'
19
+
20
+ type BuildGraphOptions = Parameters<typeof buildTaskGraph>[0]
21
+
22
+ /** Moves every remembered graph: bump it when what a token stands for changes. */
23
+ const GRAPH_MEMO_VERSION = 'graph-memo-v1'
24
+
25
+ /** What names a run's configs: the lock they were read from and the projects discovered. */
26
+ export interface GraphMemo {
27
+ folds: FoldMemo
28
+ lock: string
29
+ metas: readonly ProjectMeta[]
30
+ }
31
+
32
+ /**
33
+ * A node as the memo holds it: its project and task, requested, its deps
34
+ * and the tasks it adds outputs to as positions in the list (ids in full
35
+ * were 123 KB for 1,206 tasks, and parsing them back cost what the walk
36
+ * saved), and the globs that add to its own outputs.
37
+ */
38
+ type Row = [
39
+ project: string,
40
+ task: string,
41
+ requested: 0 | 1,
42
+ deps: number[],
43
+ adds?: number[] | null,
44
+ added?: string[] | null,
45
+ ]
46
+
47
+ /**
48
+ * `buildTaskGraph`, answered from the memo when it built this graph before,
49
+ * and the token that names the graph (undefined when not remembered).
50
+ */
51
+ export function memoGraph(
52
+ options: BuildGraphOptions,
53
+ memo: GraphMemo | undefined,
54
+ ): { nodes: Map<string, TaskNode>; token: string | undefined } {
55
+ if (memo === undefined || options.undeclaredDeps !== undefined) {
56
+ return { nodes: buildTaskGraph(options), token: undefined }
57
+ }
58
+ const { projects, packageGraph } = options
59
+ const parts: string[] = [
60
+ GRAPH_MEMO_VERSION,
61
+ codeIdentity(),
62
+ memo.lock,
63
+ options.workspaceRoot ?? '',
64
+ JSON.stringify(options.rules ?? null),
65
+ String(packageGraph.edgeSource),
66
+ JSON.stringify(options.requested),
67
+ ]
68
+ for (const m of memo.metas) {
69
+ parts.push(
70
+ m.name,
71
+ m.dir,
72
+ m.configPath ?? '\u0001',
73
+ packageGraph.directDeps(m.name).join('\u0001'),
74
+ )
75
+ }
76
+ for (const name of projects.keys()) parts.push(name)
77
+ const id = `\0graph\0${xxh3hex(JSON.stringify(options.requested))}`
78
+ const token = xxh3hex(parts.join('\0'))
79
+ const row = memo.folds.get(id, token)
80
+ if (row !== undefined) {
81
+ const nodes = rebuilt(row, projects)
82
+ if (nodes !== undefined) return { nodes, token }
83
+ }
84
+ const nodes = buildTaskGraph(options)
85
+ memo.folds.set(id, token, JSON.stringify(rowsOf(nodes)))
86
+ return { nodes, token }
87
+ }
88
+
89
+ function rowsOf(nodes: Map<string, TaskNode>): Row[] {
90
+ const at = new Map<string, number>()
91
+ for (const id of nodes.keys()) at.set(id, at.size)
92
+ const pos = (ids: readonly string[]): number[] => ids.map((d) => at.get(d)!)
93
+ return [...nodes.values()].map((n) => {
94
+ const row: Row = [n.projectName, n.taskName, n.requested ? 1 : 0, pos(n.deps)]
95
+ if (n.addsToOutputsOf !== undefined || n.outputsAddedToBy !== undefined)
96
+ row.push(
97
+ n.addsToOutputsOf === undefined ? null : pos(n.addsToOutputsOf),
98
+ n.outputsAddedToBy ?? null,
99
+ )
100
+ return row
101
+ })
102
+ }
103
+
104
+ function rebuilt(
105
+ row: string,
106
+ projects: BuildGraphOptions['projects'],
107
+ ): Map<string, TaskNode> | undefined {
108
+ let rows: Row[]
109
+ try {
110
+ rows = JSON.parse(row)
111
+ } catch {
112
+ return undefined
113
+ }
114
+ const list: TaskNode[] = []
115
+ for (const [projectName, taskName, requested] of rows) {
116
+ const project = projects.get(projectName)
117
+ const config = declaredTask(project?.config, taskName)
118
+ if (project === undefined || config === undefined) return undefined
119
+ list.push({
120
+ id: `${projectName}#${taskName}`,
121
+ projectName,
122
+ projectDir: project.dir,
123
+ taskName,
124
+ config,
125
+ deps: [],
126
+ requested: requested === 1,
127
+ })
128
+ }
129
+ const ids = (at: readonly number[]): string[] => at.map((i) => list[i]!.id)
130
+ const nodes = new Map<string, TaskNode>()
131
+ for (let i = 0; i < rows.length; i++) {
132
+ const [, , , deps, adds, added] = rows[i]!
133
+ const node = list[i]!
134
+ node.deps = ids(deps)
135
+ if (adds != null) node.addsToOutputsOf = ids(adds)
136
+ if (added != null) node.outputsAddedToBy = added
137
+ nodes.set(node.id, node)
138
+ }
139
+ return nodes
140
+ }
@@ -2,11 +2,13 @@
2
2
  // CLI's `--affected` task selection (`vx show <task> --affected`), so both
3
3
  // select over the graph the hooks leave.
4
4
 
5
+ import path from 'node:path'
5
6
  import type { GraphRules, TaskNode } from '../graph/index.js'
7
+ import { relPosix, UserError } from '../util/index.js'
6
8
  import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
7
9
  import { applyGraphHooks } from './plugin-host.js'
8
10
  import type { VxPlugin } from './plugin.js'
9
- import { isDefaultBuild } from './projects.js'
11
+ import { isDefaultBuild } from '../graph/index.js'
10
12
 
11
13
  export async function applyGraphStage(
12
14
  plugins: readonly VxPlugin[],
@@ -23,6 +25,10 @@ export async function applyGraphStage(
23
25
  // edit is held to what the loader accepts from a user: a misspelled
24
26
  // `exec` field ran as an empty command and failed with no reason.
25
27
  const configPaths = new Map(args.projectMetas.map((m) => [m.name, m.configPath]))
28
+ // A node under a directory no project owns, or another project's, ran,
29
+ // keyed and cleaned its outputs there: past the hard project boundary.
30
+ const dirs = new Map(args.projectMetas.map((m) => [m.name, path.resolve(m.dir)]))
31
+ const shown = (dir: string): string => relPosix(args.workspaceRoot, dir) || '.'
26
32
  await applyGraphHooks(
27
33
  plugins,
28
34
  nodes,
@@ -34,6 +40,17 @@ export async function applyGraphStage(
34
40
  },
35
41
  (plugin) => {
36
42
  for (const n of nodes.values()) {
43
+ const dir = dirs.get(n.projectName)
44
+ if (dir === undefined) {
45
+ throw new UserError(
46
+ `plugin '${plugin.name}' failed in graph: ${n.id}'s project ${n.projectName} is not a project in this workspace`,
47
+ )
48
+ }
49
+ if (path.resolve(n.projectDir) !== dir) {
50
+ throw new UserError(
51
+ `plugin '${plugin.name}' failed in graph: ${n.id}'s projectDir is ${shown(n.projectDir)}, not project ${n.projectName}'s directory ${shown(dir)}`,
52
+ )
53
+ }
37
54
  if (isDefaultBuild(n.config)) continue
38
55
  const own = configPaths.get(n.projectName) ?? null
39
56
  validateProjectConfig(