@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
package/src/cli/lock.ts CHANGED
@@ -3,21 +3,29 @@
3
3
  // `vx lock --check` — audit the lock against a fresh evaluation in the
4
4
  // current environment. Design: docs/design/config-lock-2026-06.md.
5
5
 
6
+ import { readdirSync } from 'node:fs'
6
7
  import { open } from 'node:fs/promises'
7
8
  import path from 'node:path'
8
9
  import type { ProjectConfig } from '../config.js'
9
10
  import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
10
- import { buildTaskGraph, expandRequested, isDefaultBuild } from '../graph/index.js'
11
+ import {
12
+ buildTaskGraph,
13
+ expandRequested,
14
+ isDefaultBuild,
15
+ refuseFiltersThroughGroups,
16
+ } from '../graph/index.js'
11
17
  import {
12
18
  applyGraphStage,
13
19
  claimedDigests,
14
20
  discoverProjects,
15
21
  fingerprintClaims,
16
22
  hasHook,
23
+ ignoredConfigRefusal,
17
24
  loadProjects,
18
25
  } from '../orchestrator/index.js'
19
26
  import { resolveSetting, runRules } from '../settings.js'
20
27
  import {
28
+ configErrorFrame,
21
29
  DOT_VX_GITATTRIBUTES,
22
30
  fsRefusalHint,
23
31
  isFsRefusal,
@@ -26,10 +34,12 @@ import {
26
34
  maskedLine,
27
35
  relPosix,
28
36
  type RefusalParts,
29
- type UserError,
37
+ UserError,
30
38
  secretMask,
39
+ secretNamed,
31
40
  type SecretMask,
32
41
  refuseSymlink,
42
+ writeAllOrNone,
33
43
  writeNoFollow,
34
44
  } from '../util/index.js'
35
45
  import * as ui from '../ui/index.js'
@@ -39,6 +49,7 @@ import {
39
49
  findProjectConfigs,
40
50
  findWorkspaceRoot,
41
51
  REVIEW_FILE,
52
+ PROJECT_CONFIG_FILENAMES,
42
53
  loadProjectConfigs,
43
54
  LOCKFILE_NAME,
44
55
  LOCKFILE_VERSION,
@@ -48,11 +59,11 @@ import {
48
59
  checkBracketLiterals,
49
60
  checkDependencyRules,
50
61
  computeNestedProjectDirs,
51
- writeLockfile,
62
+ lockfileWrite,
52
63
  DEPENDENCIES_LOCK_NAME,
53
64
  dependenciesLock,
54
65
  parseDependenciesLock,
55
- writeDependenciesLock,
66
+ dependenciesLockWrite,
56
67
  type DependenciesLock,
57
68
  type Lockfile,
58
69
  type ProjectMeta,
@@ -86,6 +97,29 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
86
97
 
87
98
  type ConfiguredMeta = ProjectMeta & { configPath: string }
88
99
 
100
+ /**
101
+ * A `dependsOn` the graph refused (`a#dep: dependsOn a#nope …`), said as
102
+ * a schema refusal of that field, so it prints at the config's line with
103
+ * its frame as every other config error does. Undefined when the task's
104
+ * `dependsOn` is not in its file (a plugin's task, the default build).
105
+ */
106
+ function placedDependsOn(
107
+ err: UserError,
108
+ all: readonly ProjectMeta[],
109
+ root: string,
110
+ ): string | undefined {
111
+ if (err.code !== 'VX_E_DEPENDS_ON') return undefined
112
+ const m = /^(.+)#([^#\s]+): dependsOn (.+)$/s.exec(err.message)
113
+ if (m === null) return undefined
114
+ const configPath = all.find((p) => p.name === m[1])?.configPath
115
+ if (configPath === undefined || configPath === null) return undefined
116
+ // `does not accept …` reads on from the field; a named dep takes `names`.
117
+ // The frame turns a trailing ` — did you mean X?` into its fix line.
118
+ const said = /^(?:does not|patterns are)\b/.test(m[3]!) ? m[3] : `names ${m[3]}`
119
+ const placed = `${configPath}: tasks.${m[2]}.dependsOn ${said!.replace(/\. Did you mean (.+)\?$/s, ' — did you mean $1?')}`
120
+ return configErrorFrame(placed, root) === undefined ? undefined : placed
121
+ }
122
+
89
123
  export async function lockCmd(args: readonly string[]): Promise<number> {
90
124
  const parsed = parseLockArgs(args)
91
125
  if (parsed.error) return refuse('lock', args, parsed.error, 'VX_E_USAGE')
@@ -156,11 +190,19 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
156
190
  try {
157
191
  const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
158
192
  out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
159
- const { configs: found, strayReviews, shadowed } = await findProjectConfigs(root)
193
+ const {
194
+ configs: found,
195
+ strayReviews,
196
+ shadowed,
197
+ nestedWorkspaces,
198
+ } = await findProjectConfigs(root)
160
199
  out.strayReviews = strayReviews
161
200
  out.shadowed = shadowed
162
201
  // The one load that evaluates: every reader reads the lock this writes.
163
- const configs = await loadProjectConfigs(found.map((f) => f.configPath))
202
+ const configs = await loadProjectConfigs(
203
+ found.map((f) => f.configPath),
204
+ root,
205
+ )
164
206
  // In the lock's form: a field left `undefined` is a key the stored JSON
165
207
  // never has. Entries, not assignment: a directory named `__proto__` is
166
208
  // a key of its own (D-152).
@@ -173,10 +215,16 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
173
215
  },
174
216
  ]),
175
217
  )
218
+ if (nestedWorkspaces.length > 0)
219
+ out.fresh.nestedWorkspaces = nestedWorkspaces.map((dir) => lockKey(root, dir))
176
220
  // The stages edit what they are handed; the lock keeps what the configs say.
177
221
  const view = structuredClone(out.fresh)
178
222
  const lock = async (): Promise<Lockfile> => view
179
223
  out.all = await discoverProjects(root, lock, plugins, cacheDir, warn)
224
+ for (const m of out.all) {
225
+ const why = m.configPath === null ? ignoredConfigRefusal(root, m) : undefined
226
+ if (why !== undefined) throw new UserError(why, 'VX_E_LOCK')
227
+ }
180
228
  const { projects } = await loadProjects({
181
229
  workspaceRoot: root,
182
230
  cacheDir,
@@ -216,11 +264,19 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
216
264
  workspaceRoot: root,
217
265
  rules,
218
266
  nestedProjects: new Map(
219
- [...computeNestedProjectDirs(out.all)].map(([name, dirs]) => [
267
+ [...computeNestedProjectDirs(out.all, nestedWorkspaces)].map(([name, dirs]) => [
220
268
  name,
221
- dirs.map((dir) => ({ name: nameOf.get(dir)!, dir })),
269
+ dirs.map((dir) => {
270
+ const project = nameOf.get(dir)
271
+ return project === undefined
272
+ ? { name: relPosix(root, dir), dir, workspace: true }
273
+ : { name: project, dir }
274
+ }),
222
275
  ]),
223
276
  ),
277
+ projectConfigs: out.all.flatMap((m) =>
278
+ m.configPath === null ? [] : [{ name: m.name, configPath: m.configPath }],
279
+ ),
224
280
  })
225
281
  if (hasHook(plugins, 'graph'))
226
282
  await applyGraphStage(plugins, nodes, {
@@ -230,11 +286,14 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
230
286
  warn,
231
287
  rules,
232
288
  })
289
+ // A run refuses it after its `graph` stage; the lock, which holds the
290
+ // graph every run trusts, refuses it too.
291
+ refuseFiltersThroughGroups(nodes)
233
292
  } catch (err) {
234
293
  if (isUserError(err))
235
294
  return {
236
295
  ...out,
237
- broken: err.message,
296
+ broken: placedDependsOn(err, out.all, root) ?? err.message,
238
297
  ...(err.parts ? { brokenParts: err.parts } : {}),
239
298
  ...(err instanceof ConfigsRefused ? { alsoBroken: err.all.slice(1) } : {}),
240
299
  }
@@ -424,11 +483,11 @@ async function writeLock(
424
483
  }
425
484
  // The copies first: one that cannot be written leaves the lock as it was,
426
485
  // not a new lock beside stale copies and a stack.
427
- for (const r of reviews) refuseSymlink(r.file)
486
+ for (const r of reviews) refuseSymlink(r.file, root)
428
487
  const blocked = (
429
488
  await Promise.all(
430
489
  reviews.map((r) =>
431
- writeNoFollow(r.file, r.text).then(
490
+ writeNoFollow(r.file, r.text, root).then(
432
491
  () => null,
433
492
  (err: unknown): { file: string; err: NodeJS.ErrnoException } => {
434
493
  if (typeof (err as NodeJS.ErrnoException).code !== 'string' || isOutOfFds(err))
@@ -451,8 +510,13 @@ async function writeLock(
451
510
  })
452
511
  return 1
453
512
  }
454
- await writeLockfile(root, check.fresh)
455
- if (check.dependencies !== null) await writeDependenciesLock(root, check.dependencies)
513
+ await writeAllOrNone(
514
+ [
515
+ lockfileWrite(root, check.fresh),
516
+ ...(check.dependencies === null ? [] : [dependenciesLockWrite(root, check.dependencies)]),
517
+ ],
518
+ root,
519
+ )
456
520
  await writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES)
457
521
  const n = metas.length
458
522
  say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
@@ -464,6 +528,27 @@ async function writeLock(
464
528
  return 0
465
529
  }
466
530
 
531
+ /** The first places (three at most) two evaluated configs differ: `path: locked → now`. */
532
+ function configDiff(locked: unknown, now: unknown, at = '', out: string[] = []): string[] {
533
+ if (out.length >= 3 || Bun.deepEquals(locked, now, true)) return out
534
+ const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object'
535
+ if (isObj(locked) && isObj(now) && Array.isArray(locked) === Array.isArray(now)) {
536
+ for (const k of new Set([...Object.keys(locked), ...Object.keys(now)])) {
537
+ const next = Array.isArray(now) ? `${at}[${k}]` : at === '' ? k : `${at}.${k}`
538
+ configDiff(locked[k], now[k], next, out)
539
+ }
540
+ return out
541
+ }
542
+ out.push(`${at}: ${diffValue(locked)} → ${diffValue(now)}`)
543
+ return out
544
+ }
545
+
546
+ function diffValue(v: unknown): string {
547
+ if (v === undefined) return '(none)'
548
+ const s = JSON.stringify(v)
549
+ return s.length > 60 ? `${s.slice(0, 59)}…` : s
550
+ }
551
+
467
552
  /**
468
553
  * Review copies with no config beside them, sorted. One left beside a
469
554
  * config since deleted shows reviewers tasks nothing runs, and keys the
@@ -483,21 +568,45 @@ async function strayCopies(check: GraphCheck): Promise<string[]> {
483
568
  return [...new Set([...kept.filter((f) => f !== null), ...check.strayReviews])].sort()
484
569
  }
485
570
 
486
- const strayLine = (root: string, file: string): string =>
487
- `${relPosix(root, file)} has no vx.config beside it — delete it`
571
+ /**
572
+ * A copy's config renamed to a name vx does not read (`.json`, `.tsx`)
573
+ * is still there: "delete it" was the wrong advice. Looked for only here,
574
+ * once a copy is already stray.
575
+ */
576
+ function strayLine(root: string, file: string): string {
577
+ const copy = `${relPosix(root, file)} has no vx.config beside it`
578
+ let names: string[] = []
579
+ try {
580
+ names = readdirSync(path.dirname(file))
581
+ } catch {
582
+ // gone or unreadable: nothing beside it to name
583
+ }
584
+ const unread = names
585
+ .filter((n) => n.startsWith('vx.config.') && !PROJECT_CONFIG_FILENAMES.includes(n))
586
+ .sort()[0]
587
+ if (unread === undefined) return `${copy} — delete it`
588
+ const exts = PROJECT_CONFIG_FILENAMES.map((n) => n.slice('vx.config.'.length)).join(', ')
589
+ return `${copy}: ${unread} is not a config vx reads (${exts})`
590
+ }
488
591
 
489
592
  /**
490
593
  * Each place an evaluated config holds the value of a secret in this
491
594
  * environment, by the rule output masking uses (a secret-named variable,
492
- * or one a task lists in `exec.env.secret`). The lock is committed, so
493
- * such a value would be published, and masking it would freeze a `***`
494
- * that every run runs (L-42).
595
+ * one a task lists in `exec.env.secret`, or a define of such a name whose
596
+ * value this environment holds). The lock is committed, so such a value
597
+ * would be published, and masking it would freeze a `***` that every run
598
+ * runs (L-42).
495
599
  */
496
600
  function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string; name: string }> {
497
601
  const named = new Set<string>()
602
+ const defines: Array<[name: string, value: string]> = []
498
603
  for (const config of Object.values(projects)) {
499
- for (const task of Object.values(config.tasks ?? {}))
500
- for (const n of task?.exec?.env?.secret ?? []) named.add(n)
604
+ for (const task of Object.values(config.tasks ?? {})) {
605
+ const secret = task?.exec?.env?.secret ?? []
606
+ for (const n of secret) named.add(n)
607
+ for (const [n, v] of Object.entries(task?.exec?.env?.define ?? {}))
608
+ if (secretNamed(n) || secret.includes(n)) defines.push([n, v])
609
+ }
501
610
  }
502
611
  // Each variable's own mask: what the output masks of it, a URL's
503
612
  // password and a key's lines too, not only the whole value.
@@ -507,12 +616,26 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
507
616
  value === undefined ? null : secretMask([{ [name]: value }], named.has(name) ? [name] : [])
508
617
  if (mask !== null) secrets.push([mask, name])
509
618
  }
619
+ // The output masks a secret define's value by the define's name, so
620
+ // `API_TOKEN: process.env.MY_CREDS` printed masked and locked in clear.
621
+ // Only a value this environment holds counts: a literal define is the
622
+ // config's own committed text, and npm's `'${NPM_TOKEN}'` is no secret.
623
+ for (const [n, v] of defines) {
624
+ const mask = secretMask([{ [n]: v }], [n])
625
+ if (mask === null) continue
626
+ for (const [name, value] of Object.entries(process.env))
627
+ if (value !== undefined && mask.spans(value).length > 0) secrets.push([mask, name])
628
+ }
510
629
  if (secrets.length === 0) return []
511
630
  const found: Array<{ at: string; name: string }> = []
512
631
  const walk = (project: string, at: string, v: unknown): void => {
513
632
  if (typeof v === 'string') {
514
- for (const [mask, name] of secrets)
515
- if (mask.spans(v).length > 0) found.push({ at: `${project}: ${at} holds $${name}`, name })
633
+ for (const [mask, name] of secrets) {
634
+ const place = `${project}: ${at} holds $${name}`
635
+ // A define's mask and its variable's own both find one value.
636
+ if (mask.spans(v).length > 0 && !found.some((f) => f.at === place))
637
+ found.push({ at: place, name })
638
+ }
516
639
  } else if (Array.isArray(v)) {
517
640
  v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
518
641
  } else if (v !== null && typeof v === 'object') {
@@ -533,6 +656,29 @@ function warnShadowed(root: string, check: GraphCheck, verb: string): void {
533
656
  )
534
657
  }
535
658
 
659
+ /**
660
+ * `read` with each file's record taken from `fresh` where only the files
661
+ * its digests read moved (same bytes, same extra files, an extra's hash
662
+ * changed): a run digests anew on such an edit (`lockfileClaim`'s
663
+ * `lockedOnce`), and cli.md says it needs no `vx lock`, yet `--check`
664
+ * failed every edit in a `file:` directory or a patch. With every hash
665
+ * unchanged a run keys from the record, so its digests are compared.
666
+ */
667
+ function liveExtras(read: DependenciesLock, fresh: DependenciesLock): DependenciesLock {
668
+ const files = { ...read.files }
669
+ for (const [name, now] of Object.entries(fresh.files)) {
670
+ const was = files[name]
671
+ if (
672
+ was !== undefined &&
673
+ was.hash === now.hash &&
674
+ Bun.deepEquals(Object.keys(was.extras ?? {}), Object.keys(now.extras ?? {})) &&
675
+ !Bun.deepEquals(was.extras ?? {}, now.extras ?? {})
676
+ )
677
+ files[name] = now
678
+ }
679
+ return { ...read, files }
680
+ }
681
+
536
682
  /**
537
683
  * Audit the lock: find and re-evaluate every config in the CURRENT
538
684
  * environment and deep-compare it with the locked object. That sees an
@@ -586,12 +732,26 @@ async function checkLock(root: string, json: boolean): Promise<number> {
586
732
  ? `${rel} is not the ${locked.file} the lock holds for ${dir === '.' ? 'the workspace root project' : dir} — run 'vx lock'`
587
733
  : !Bun.deepEquals(fresh.config, locked.config, true)
588
734
  ? `${rel} evaluates differently from the lock here — its config, a file ` +
589
- `it imports, or the environment it reads changed; run 'vx lock'`
735
+ `it imports, or the environment it reads changed; run 'vx lock'` +
736
+ // What changed: a config that reads the environment shows no file diff.
737
+ configDiff(locked.config, fresh.config)
738
+ .map((d) => `\n ${maskedLine(d)}`)
739
+ .join('')
590
740
  : null
591
741
  if (problem === null) continue
592
742
  failures.push(problem)
593
743
  drifted.add(dir)
594
744
  }
745
+ const lockedWalls = lock.nestedWorkspaces ?? []
746
+ const walls = check.fresh.nestedWorkspaces ?? []
747
+ for (const dir of walls)
748
+ if (!lockedWalls.includes(dir))
749
+ failures.push(`${dir} is a nested workspace the lock does not hold — run 'vx lock'`)
750
+ for (const dir of lockedWalls)
751
+ if (!walls.includes(dir))
752
+ failures.push(
753
+ `the lock holds ${dir} as a nested workspace, which it is not now — run 'vx lock'`,
754
+ )
595
755
  if (broken !== null && check.loaded) failures.push(broken)
596
756
  // Each config that does not load, as `vx lock` names them: a CI fixing
597
757
  // one at a time re-ran the gate once per broken config.
@@ -645,7 +805,10 @@ async function checkLock(root: string, json: boolean): Promise<number> {
645
805
  .catch(() => null)
646
806
  // Compared as JSON: a formatter may lay the file out its own way.
647
807
  const read = parseDependenciesLock(text)
648
- if (read === null || !Bun.deepEquals(read, check.dependencies, true))
808
+ if (
809
+ read === null ||
810
+ !Bun.deepEquals(liveExtras(read, check.dependencies), check.dependencies, true)
811
+ )
649
812
  failures.push(
650
813
  `${DEPENDENCIES_LOCK_NAME} ${text === null ? 'is missing' : 'is not what the dependencies resolve to here'} — run 'vx lock'`,
651
814
  )
@@ -3,7 +3,7 @@
3
3
  import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
4
4
  import { computeReverseDepCount } from '../graph/index.js'
5
5
  import { planList, type PlannedRow, skippedReason } from '../report/index.js'
6
- import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
6
+ import { formatBytes, formatElapsed, maskedCommand, printable } from '../util/index.js'
7
7
  import * as ui from '../ui/index.js'
8
8
  import type { ColorSupport } from '../ui/index.js'
9
9
 
@@ -12,11 +12,10 @@ import type { ColorSupport } from '../ui/index.js'
12
12
  * list's grid (what the run would do with it, its usual time, where it
13
13
  * would run), then the counts and the predicted time.
14
14
  *
15
- * ─ Plan 3 ─────────────────────────────────────────────────────
16
- * time usual status cache cpu mem outputs blocks worker task
17
- * ⇢ cached local local @vzn/vx#lint
18
- * ⇣ cached remote 12MB local @vzn/vx#test
19
- * ⏺︎ ~1.2s run miss local @vzn/vx#build
15
+ * Plan 3 status cache usual time cpu mem outputs blocks worker task
16
+ * cached local — — — — — — local @vzn/vx#lint
17
+ * cached remote — — — — 12MB — local @vzn/vx#test
18
+ * run miss ~1.2s — — — — — local @vzn/vx#build
20
19
  *
21
20
  * tasks 3 planned · 2 cache hits (1 local, 1 remote, 12MB) · 1 would run
22
21
  * predicted ~1.20s wall · ~1.20s total execution
@@ -46,12 +45,12 @@ export function planReport(plan: RunPlan): ui.Block {
46
45
  if (t.cacheStatus === 'skipped') notes.push(skippedReason(t))
47
46
  const desc = shownDescription(t)
48
47
  if (desc !== undefined && desc !== '') notes.push(desc)
49
- if (t.affected !== undefined) notes.push(`affected: ${describeReason(t.affected)}`)
48
+ if (t.affected !== undefined) notes.push(`affected: ${printable(describeReason(t.affected))}`)
50
49
  const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
51
50
  rows.push({
52
51
  node: t.node,
53
52
  ...(skipped ? NOOP : LOOK[t.cacheStatus]),
54
- ...(executes && t.p50Ms !== undefined ? { usualMs: t.p50Ms } : {}),
53
+ ...(executes ? { usualMs: t.p50Ms ?? null } : {}),
55
54
  ...(t.cacheStatus === 'hit-remote' && t.remoteBytes !== undefined
56
55
  ? { outputBytes: t.remoteBytes }
57
56
  : {}),
@@ -114,7 +113,7 @@ export function planReport(plan: RunPlan): ui.Block {
114
113
  const parts = [`~${formatElapsed(p.wallMs)} wall`]
115
114
  parts.push(`~${formatElapsed(p.workMs)} total execution`)
116
115
  if (p.unknownCount > 0) {
117
- parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} without history (+?)`)
116
+ parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} never timed (+?)`)
118
117
  }
119
118
  facts.push(['predicted', parts.join(' · ')])
120
119
  }
@@ -137,37 +136,34 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
137
136
  return ui.render([planReport(plan)], c)
138
137
  }
139
138
 
140
- type Look = Pick<PlannedRow, 'glyph' | 'status' | 'tone' | 'cache' | 'cacheTone'>
139
+ type Look = Pick<PlannedRow, 'status' | 'tone' | 'cache' | 'cacheTone'>
141
140
 
142
- /** Each outcome on the run's grid: its glyph and words, as the run would print them. */
141
+ /** Each outcome on the run's grid: its words, as the run would print them. */
143
142
  const LOOK: Record<CacheStatus, Look> = {
144
143
  'hit-local': {
145
- glyph: '\u21e2',
146
144
  status: 'cached',
147
145
  tone: 'ok',
148
146
  cache: 'local',
149
147
  cacheTone: 'local',
150
148
  },
151
149
  'hit-remote': {
152
- glyph: '\u21e3',
153
150
  status: 'cached',
154
151
  tone: 'ok',
155
152
  cache: 'remote',
156
153
  cacheTone: 'remote',
157
154
  },
158
- miss: { glyph: '\u23fa\ufe0e', status: 'run', tone: 'info', cache: 'miss', cacheTone: 'muted' },
155
+ miss: { status: 'run', tone: 'info', cache: 'miss', cacheTone: 'muted' },
159
156
  'no-cache': {
160
- glyph: '\u23fa\ufe0e',
161
157
  status: 'run',
162
158
  tone: 'info',
163
159
  cache: 'no-cache',
164
160
  cacheTone: 'muted',
165
161
  },
166
- group: { glyph: '\u25cb', status: 'group', tone: 'muted', cache: '', cacheTone: '' },
167
- 'not-started': { glyph: '\u25cc', status: 'not run', tone: 'muted', cache: '', cacheTone: '' },
168
- skipped: { glyph: '\u2298', status: 'skipped', tone: 'warn', cache: '', cacheTone: '' },
162
+ group: { status: 'group', tone: 'muted', cache: '', cacheTone: '' },
163
+ 'not-started': { status: 'not run', tone: 'muted', cache: '', cacheTone: '' },
164
+ skipped: { status: 'skipped', tone: 'warn', cache: '', cacheTone: '' },
169
165
  }
170
- const NOOP: Look = { glyph: '\u00b7', status: 'noop', tone: 'muted', cache: '', cacheTone: '' }
166
+ const NOOP: Look = { status: 'noop', tone: 'muted', cache: '', cacheTone: '' }
171
167
 
172
168
  /** One `--affected --dry` reason line: the cause, then the edge chain that carried it. */
173
169
  function describeReason(r: AffectedReason): string {
@@ -181,13 +177,20 @@ function describeReason(r: AffectedReason): string {
181
177
  : r.kind === 'input'
182
178
  ? `${r.file} changed (an input)`
183
179
  : r.kind === 'workspace'
184
- ? `${r.file} changed (workspace-wide)`
180
+ ? `${r.file} changed (${r.wide === true ? 'workspace-wide' : 'every task in its project'})`
185
181
  : r.file !== undefined
186
182
  ? `${r.file} changed (in its project)`
187
183
  : 'its project changed as a whole'
188
184
  return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
189
185
  }
190
186
 
187
+ function wireReason(r: AffectedReason): AffectedReason {
188
+ if (r.wide === undefined) return r
189
+ const wire = { ...r }
190
+ delete wire.wide
191
+ return wire
192
+ }
193
+
191
194
  /** A TS config may build the description from `process.env` (L-11). */
192
195
  function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
193
196
  const { description, exec } = t.node.config
@@ -232,7 +235,7 @@ export function formatPlanJson(
232
235
  ...(t.executor !== undefined ? { executor: t.executor } : {}),
233
236
  ...(t.download !== undefined ? { download: t.download } : {}),
234
237
  ...(description !== undefined ? { description } : {}),
235
- ...(t.affected !== undefined ? { affected: t.affected } : {}),
238
+ ...(t.affected !== undefined ? { affected: wireReason(t.affected) } : {}),
236
239
  ...(t.otherPlatform !== undefined ? { otherPlatform: t.otherPlatform } : {}),
237
240
  ...(t.blockedBy !== undefined ? { blockedBy: t.blockedBy } : {}),
238
241
  }
@@ -337,7 +337,12 @@ export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]>
337
337
  if (seen.has(verb)) continue
338
338
  seen.add(verb)
339
339
  lines.push(
340
- ...helpLines(` vx ${verb.padEnd(17)} `, `${command.description} (${plugin.name})`),
340
+ ...helpLines(
341
+ // Two spaces at least: one ran a long verb into its description,
342
+ // and the help painter reads a description after two.
343
+ ` vx ${verb.length < 17 ? `${verb.padEnd(17)} ` : `${verb} `}`,
344
+ `${command.description} (${plugin.name})`,
345
+ ),
341
346
  )
342
347
  }
343
348
  }
@@ -16,7 +16,7 @@ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: s
16
16
  "test": "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = {\n workspaceRoot: '/ws',\n cacheDir: '/ws/.vx/cache',\n warn() {},\n concurrency: 1,\n vx: ['vx'],\n cache: {\n get: async () => null,\n put: async () => {},\n has: async () => null,\n hasMany: async () => null,\n getRun: async () => null,\n },\n artifacts: {\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n },\n artifactsFor: () => ({\n save: async () => {},\n restore: async () => null,\n output: async () => null,\n share: async () => false,\n resend: async () => false,\n }),\n}\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(process.stdout, 'write').mockImplementation(() => true)\n const err = spyOn(process.stderr, 'write').mockImplementation(() => true)\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache\\n']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n // On CI the line is coloured: the words are what this asserts.\n expect(err.mock.calls.map(([l]) => Bun.stripANSI(String(l)))).toEqual([\n 'vx cache-dir: takes no arguments, got x\\n',\n ])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n"
17
17
  },
18
18
  "executor": {
19
- "plugin": "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n // A stop before the spawn is work not to start: an abort fires no\n // listener added after it, so the command would run unstopped.\n if (req.signal?.aborted === true) {\n const exitCode = req.signal.reason === 'SIGINT' ? 130 : 143\n return { exitCode, durationMs: 0, stdout: '', stderr: '', violations: [], where: 'shell' }\n }\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const kill = (signal: NodeJS.Signals): void => {\n try {\n process.kill(-child.pid, signal)\n } catch {\n // already gone\n }\n }\n // Core abandons an executor 2 s after the abort: a group that ignores\n // SIGTERM is killed before then, or it would run on unreached.\n let killTimer: ReturnType<typeof setTimeout> | undefined\n const stop = (): void => {\n kill('SIGTERM')\n killTimer = setTimeout(() => kill('SIGKILL'), 1_000)\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const readers: { cancel(): Promise<void> }[] = []\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const reader = stream.getReader()\n readers.push(reader)\n const decoder = new TextDecoder()\n let all = ''\n for (let r = await reader.read(); !r.done; r = await reader.read()) {\n const text = decoder.decode(r.value, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const drained = Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n ])\n const exitCode = await child.exited\n // A pipe ends only when every writer closes it: a process the command\n // left running (`server &`) would hold the task open. What it writes\n // after a short grace is not the task's.\n const cut = setTimeout(() => readers.forEach((r) => void r.cancel()), 250)\n const [stdout, stderr] = await drained\n clearTimeout(cut)\n req.signal?.removeEventListener('abort', stop)\n clearTimeout(killTimer)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
19
+ "plugin": "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, withForwardArgs, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n // No `sandbox: true`: it ignores `req.sandbox`, so vx runs a task that\n // declares `exec.sandbox` itself, inside one.\n async execute(req) {\n const started = Date.now()\n // A stop before the spawn is work not to start: an abort fires no\n // listener added after it, so the command would run unstopped.\n if (req.signal?.aborted === true) {\n const exitCode = req.signal.reason === 'SIGINT' ? 130 : 143\n return { exitCode, durationMs: 0, stdout: '', stderr: '', violations: [], where: 'shell' }\n }\n // vx's own `sh`: a bare name is looked up on `req.env`'s PATH, which\n // leads with the project's node_modules/.bin, where a dependency's\n // `sh` would run every command.\n const sh = Bun.which('sh') ?? '/bin/sh'\n // The args after `--`, quoted and placed as the local executor places\n // them (before a trailing comment, never on a heredoc's terminator).\n const command = withForwardArgs(req.command, req.forwardArgs)\n const child = Bun.spawn([sh, '-c', command], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const kill = (signal: NodeJS.Signals): void => {\n try {\n process.kill(-child.pid, signal)\n } catch {\n // already gone\n }\n }\n // Core abandons an executor 2 s after the abort: a group that ignores\n // SIGTERM is killed before then, or it would run on unreached.\n let killTimer: ReturnType<typeof setTimeout> | undefined\n const stop = (): void => {\n kill('SIGTERM')\n killTimer = setTimeout(() => kill('SIGKILL'), 1_000)\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const readers: { cancel(): Promise<void> }[] = []\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const reader = stream.getReader()\n readers.push(reader)\n const decoder = new TextDecoder()\n let all = ''\n for (let r = await reader.read(); !r.done; r = await reader.read()) {\n const text = decoder.decode(r.value, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const drained = Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n ])\n const exitCode = await child.exited\n // A pipe ends only when every writer closes it: a process the command\n // left running (`server &`) would hold the task open. What it writes\n // after a short grace is not the task's.\n const cut = setTimeout(() => readers.forEach((r) => void r.cancel()), 250)\n const [stdout, stderr] = await drained\n clearTimeout(cut)\n req.signal?.removeEventListener('abort', stop)\n clearTimeout(killTimer)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
20
20
  "test": "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\nimport { shellExecutor } from './executor.ts'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n // Core reads no package.json: the config names the project.\n await writeFile(\n path.join(dir, 'vx.config.mjs'),\n config.replace('export default {', `export default { name: ${JSON.stringify(name)},`),\n )\n return dir\n}\n\n/**\n * A `.vx/workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file\n * beside this test, then `vx lock`: a run reads project configs only from the lock.\n */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await Bun.write(\n path.join(root, '.vx/workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n const vx = path.join(path.dirname(new URL(import.meta.resolve('@vzn/vx')).pathname), 'bin.ts')\n const lock = Bun.spawnSync({ cmd: [process.execPath, vx, 'lock'], cwd: root })\n expect([lock.exitCode, lock.stderr.toString()]).toEqual([0, ''])\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: '300ms' } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n\nit(\"runs the command through this machine's sh, not a project's node_modules/.bin sh\", async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { hello: { exec: { command: 'echo real' } } } }`,\n )\n await mkdir(path.join(dir, 'node_modules', '.bin'), { recursive: true })\n await writeFile(path.join(dir, 'node_modules', '.bin', 'sh'), '#!/bin/sh\\necho planted\\n', {\n mode: 0o755,\n })\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['hello'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], 'real\\n'])\n}, 20_000)\n\nit('puts the args after -- on the command as the local executor does', async () => {\n await pkg(\n 'a',\n `export default { tasks: { say: { exec: { command: \"printf '<%s>' # each arg\" } } } }`,\n )\n await workspace('shellExecutor()')\n const out: string[] = []\n const r = await run({\n cwd: root,\n tasks: ['say'],\n forwardArgs: [\"it's\", 'a b'],\n log: { ...log(), taskStdout: (_n, chunk) => void out.push(chunk) },\n handleSignals: false,\n })\n expect([r.outcomes.map((o) => o.status), out.join('')]).toEqual([['success'], \"<it's><a b>\"])\n}, 20_000)\n\n// An abort fires no listener added after it: a request stopped before\n// `execute` ran its command anyway, in a group nothing would stop.\nit('starts nothing for a request already aborted', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const stop = new AbortController()\n stop.abort('SIGTERM')\n const r = await executor.execute({\n command: 'sleep 0.3; echo ran > ran.txt',\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n signal: stop.signal,\n } as never)\n await Bun.sleep(600)\n expect([r.exitCode, await Bun.file(path.join(root, 'ran.txt')).exists()]).toEqual([143, false])\n})\n\n// Core abandons an executor 2 s after its abort: a group that ignored\n// SIGTERM ran on, unreached, after the run had ended.\nit('kills a group that ignores SIGTERM after a grace', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const stop = new AbortController()\n const started = Date.now()\n setTimeout(() => stop.abort('SIGTERM'), 200)\n const r = await executor.execute({\n command: \"trap '' TERM; sleep 30\",\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n signal: stop.signal,\n } as never)\n expect([r.exitCode, Date.now() - started < 10_000]).toEqual([137, true])\n}, 40_000)\n\n// A pipe ends only when every writer closes it: a process the command\n// left running (`server &`) held the task open until it exited.\nit('ends when the shell exits, though a process it left holds the pipes', async () => {\n const executor = (await shellExecutor().executor!({} as never))!\n const started = Date.now()\n const r = await executor.execute({\n command: 'sleep 5 & echo up',\n cwd: root,\n env: { PATH: process.env['PATH'] ?? '' },\n forwardArgs: [],\n onStdout() {},\n onStderr() {},\n } as never)\n expect([r.exitCode, r.stdout, Date.now() - started < 3_000]).toEqual([0, 'up\\n', true])\n}, 20_000)\n"
21
21
  },
22
22
  "graph": {