@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
@@ -2,8 +2,8 @@
2
2
  // workers, which run tasks another machine placed. A run arms the sandbox
3
3
  // runtime before it builds a sandboxed request; a worker is no run, so it
4
4
  // arms here. It starts a request's servers (`ExecuteRequest.servers`) once
5
- // each, before the task, each inside its own sandbox, and stops them when
6
- // it closes.
5
+ // each, before the task, each inside its own sandbox, and stops each when
6
+ // the run says no task needs it, or when it closes.
7
7
 
8
8
  import type { ExecConfig } from '../config.js'
9
9
  import {
@@ -18,6 +18,7 @@ import {
18
18
  type ServerSpec,
19
19
  wrapSandboxedCommand,
20
20
  } from '../exec/index.js'
21
+ import { stopServer } from './persistent.js'
21
22
  import { placeholderSweeper, prepareSandbox, sandboxRequestFor } from './sandbox-request.js'
22
23
  import { forwardedSignal, terminateChildren } from './signals.js'
23
24
 
@@ -28,6 +29,8 @@ export interface LocalFloor {
28
29
  execute(req: ExecuteRequest): Promise<ExecuteResult>
29
30
  /** Arms the sandbox runtime for `sandboxes` from the next sandboxed task on; its servers keep running. */
30
31
  rearm(sandboxes: readonly NonNullable<ExecConfig['sandbox']>[]): Promise<void>
32
+ /** Stops server `taskId` if this started it: no task left needs it. A later task that needs it starts it again. */
33
+ stop(taskId: string): Promise<void>
31
34
  /** Stops the servers this started and the sandbox runtime this armed. */
32
35
  close(): Promise<void>
33
36
  }
@@ -38,10 +41,13 @@ export interface LocalFloor {
38
41
  * unix-socket filter are one per process, set from their union before the
39
42
  * first sandboxed task (`prepareSandbox`). A request that carries a
40
43
  * `signal` but no `liveChildren` has its children stopped here when it
41
- * aborts, the way a run's stop ends its own.
44
+ * aborts, the way a run's stop ends its own. `onServer` hears each
45
+ * server's pid as it spawns, and `undefined` as `stop` ends it: a worker
46
+ * meters what its servers use.
42
47
  */
43
48
  export function localFloor(
44
49
  sandboxes: readonly NonNullable<ExecConfig['sandbox']>[] = [],
50
+ onServer?: (taskId: string, pid: number | undefined) => void,
45
51
  ): LocalFloor {
46
52
  const local = localExecutor()
47
53
  const prepare = (list: typeof sandboxes) =>
@@ -103,6 +109,7 @@ export function localFloor(
103
109
  if (spawn.child === undefined) void gone()
104
110
  else {
105
111
  children.push(spawn.child)
112
+ onServer?.(spec.taskId, spawn.child.pid)
106
113
  void spawn.child.exited.then(gone, gone)
107
114
  }
108
115
  try {
@@ -190,6 +197,17 @@ export function localFloor(
190
197
  if (armer?.armed === true) await resetSandbox()
191
198
  armer = prepare(list)
192
199
  },
200
+ async stop(taskId) {
201
+ const up = servers.get(taskId)
202
+ if (up === undefined) return
203
+ servers.delete(taskId)
204
+ const child = await up.catch(() => undefined)
205
+ if (child === undefined) return
206
+ const i = children.indexOf(child)
207
+ if (i >= 0) children.splice(i, 1)
208
+ onServer?.(taskId, undefined)
209
+ await stopServer(child)
210
+ },
193
211
  async close() {
194
212
  if (children.length > 0) await terminateChildren(() => children)
195
213
  if (armer?.armed === true) await resetSandbox()
@@ -21,7 +21,7 @@
21
21
  // `vx lock` adds them to the memo.
22
22
 
23
23
  import path from 'node:path'
24
- import { stat } from 'node:fs/promises'
24
+ import { readdir, realpath, stat } from 'node:fs/promises'
25
25
  import { type LockfileMemo, readLockfileMemo, writeLockfileMemo } from '../cache/index.js'
26
26
  import { relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
27
27
  import type { TaskNode } from '../graph/index.js'
@@ -88,6 +88,11 @@ export interface LockfileImporter {
88
88
  readonly deps: Readonly<Record<string, string>>
89
89
  /** Its peers that resolve to an importer, each with no installed entry of the same name. */
90
90
  readonly peers: Readonly<Record<string, string>>
91
+ /**
92
+ * The importers it reaches only through installed packages, sorted: a
93
+ * registry package's dependency the install resolved to a workspace.
94
+ */
95
+ readonly transitive?: readonly string[]
91
96
  }
92
97
 
93
98
  /** What a lockfile plugin spreads into `definePlugin` (`fingerprint`, `key`), and `importers` for its own stages. */
@@ -152,13 +157,34 @@ function unreadable(rel: string, err: unknown): UserError {
152
157
  * Every file under `dir` but its `node_modules` and `.git`, as `rel\0hash`
153
158
  * lines in sorted order. Links are followed: a linked file is what a task
154
159
  * reads, and the scan skipped it, so an edit to its target moved no key.
160
+ * The walk never enters `node_modules` or `.git`: a glob of `**` that
161
+ * dropped them after listed every file under them (30k files there: a warm
162
+ * run 307 → 183 ms, min of 10). A linked directory that is one of its own
163
+ * ancestors is not entered again.
155
164
  */
156
165
  async function hashDir(dir: string): Promise<string> {
157
166
  const rels: string[] = []
158
- const scan = { cwd: dir, dot: true, onlyFiles: true, followSymlinks: true }
159
- for await (const rel of new Bun.Glob('**').scan(scan)) {
160
- if (!rel.split('/').some((s) => s === 'node_modules' || s === '.git')) rels.push(rel)
167
+ const walk = async (rel: string, real: string, ancestors: readonly string[]): Promise<void> => {
168
+ const entries = await readdir(path.join(dir, rel), { withFileTypes: true })
169
+ await Promise.all(
170
+ entries.map(async (e) => {
171
+ if (e.name === 'node_modules' || e.name === '.git') return
172
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
173
+ if (e.isFile()) return void rels.push(child)
174
+ if (e.isDirectory()) return walk(child, path.join(real, e.name), [...ancestors, real])
175
+ // A link, or an entry the filesystem gave no type: what it names decides.
176
+ const abs = path.join(dir, child)
177
+ const st = await stat(abs).catch(() => undefined)
178
+ if (st?.isFile()) rels.push(child)
179
+ else if (st?.isDirectory()) {
180
+ const target = await realpath(abs)
181
+ if (target !== real && !ancestors.includes(target))
182
+ await walk(child, target, [...ancestors, real])
183
+ }
184
+ }),
185
+ )
161
186
  }
187
+ await walk('', await realpath(dir), [])
162
188
  rels.sort()
163
189
  const lines = await Promise.all(
164
190
  rels.map(async (rel) => `${rel}\0${xxh3hex(await Bun.file(path.join(dir, rel)).bytes())}`),
@@ -7,7 +7,7 @@
7
7
  // a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
8
8
  // code motion.
9
9
 
10
- import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs'
10
+ import { existsSync, statSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import {
13
13
  type CacheLayer,
@@ -26,7 +26,6 @@ import {
26
26
  relPosix,
27
27
  span,
28
28
  splitNegations,
29
- staticPrefix,
30
29
  taskGlob,
31
30
  } from '../util/index.js'
32
31
  import { type Logger, warnOf } from '../report/index.js'
@@ -137,7 +136,7 @@ export interface SaveMissArgs {
137
136
  * (a same-project downstream task reads both), and the pack + write +
138
137
  * index go to the run's save lane (save-lane.ts).
139
138
  */
140
- deferSave?: ((save: () => Promise<void>) => Promise<boolean>) | undefined
139
+ deferSave?: ((save: () => Promise<void>, taskId: string) => Promise<boolean>) | undefined
141
140
  /** Time the save and size its artifact, for a telemetry sink: one stat. */
142
141
  measure?: boolean | undefined
143
142
  }
@@ -167,6 +166,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
167
166
  projectDir: node.projectDir,
168
167
  outputs: a.outputs,
169
168
  nestedProjectDirs: a.nestedProjectDirs,
169
+ inputs: {
170
+ projectDir: node.projectDir,
171
+ workspaceRoot: a.workspaceRoot,
172
+ declared: node.config.inputs,
173
+ },
170
174
  special: true,
171
175
  })
172
176
  const outputFiles = tree.files
@@ -176,6 +180,11 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
176
180
  : await resolveWorkspaceOutputTree({
177
181
  workspaceRoot: a.workspaceRoot,
178
182
  outputs: a.wsOutputs,
183
+ inputs: {
184
+ projectDir: node.projectDir,
185
+ workspaceRoot: a.workspaceRoot,
186
+ declared: node.config.inputs,
187
+ },
179
188
  special: true,
180
189
  })
181
190
  const wsOutputFiles = wsTree.files
@@ -213,17 +222,12 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
213
222
  // `outputs` is not a write grant; `exec.sandbox.allow.write` is.
214
223
  const sandboxed = node.config.exec?.sandbox !== undefined
215
224
  const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
216
- // A `workspaceFiles` directory linked out of the workspace drops every
217
- // file (M-65); a project one is refused by the resolver (X-88).
218
- const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
219
225
  warnOf(log)(
220
226
  `[vx] ${node.id}: outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
221
227
  `an empty artifact is saved; a later hit restores nothing` +
222
- (linkedOut !== undefined
223
- ? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
224
- : sandboxed && !grantsWrite
225
- ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
226
- : '') +
228
+ (sandboxed && !grantsWrite
229
+ ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
230
+ : '') +
227
231
  bracketLiteralHint([
228
232
  ...a.outputs.map((g) => [node.projectDir, g] as const),
229
233
  ...a.wsOutputs.map((g) => [a.workspaceRoot, g] as const),
@@ -231,6 +235,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
231
235
  )
232
236
  }
233
237
  const facts: SaveFacts = {}
238
+ let kept = false
234
239
  const save = async (): Promise<void> => {
235
240
  const saveStart = a.measure === true ? performance.now() : 0
236
241
  const endSave = span('miss: save')
@@ -262,6 +267,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
262
267
  endSave()
263
268
  const bytes = cache.outputBytes?.(a.hash)
264
269
  if (bytes !== undefined) facts.outputBytes = bytes
270
+ // A `--force` run that found the key stored kept that entry: the
271
+ // footer's `↑` counts saves, and this was none.
272
+ if (cache.kept?.(a.hash) === true) kept = true
265
273
  if (a.measure === true) {
266
274
  facts.saveMs = Math.round(performance.now() - saveStart)
267
275
  // No local artifact (local writes off): no size to say.
@@ -300,11 +308,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
300
308
  // the entry before the outcome.
301
309
  if (a.deferSave !== undefined) {
302
310
  return {
303
- landed: a.deferSave(save).then((stored) => (stored ? { ...facts, saved: true } : facts)),
311
+ landed: a
312
+ .deferSave(save, node.id)
313
+ .then((stored) => (stored && !kept ? { ...facts, saved: true } : facts)),
304
314
  }
305
315
  }
306
316
  await save()
307
- return { landed: Promise.resolve({ ...facts, saved: true }) }
317
+ return { landed: Promise.resolve(kept ? facts : { ...facts, saved: true }) }
308
318
  }
309
319
 
310
320
  /** What `markUnsaved` reads of a miss: where its declared outputs are. */
@@ -342,10 +352,20 @@ async function resolveUnsaved(
342
352
  projectDir: a.node.projectDir,
343
353
  outputs: a.outputs,
344
354
  nestedProjectDirs: a.nestedProjectDirs,
355
+ inputs: {
356
+ projectDir: a.node.projectDir,
357
+ workspaceRoot: a.workspaceRoot,
358
+ declared: a.node.config.inputs,
359
+ },
345
360
  })
346
361
  const wsOutputFiles = await resolveWorkspaceOutputs({
347
362
  workspaceRoot: a.workspaceRoot,
348
363
  outputs: a.wsOutputs,
364
+ inputs: {
365
+ projectDir: a.node.projectDir,
366
+ workspaceRoot: a.workspaceRoot,
367
+ declared: a.node.config.inputs,
368
+ },
349
369
  })
350
370
  endResolve()
351
371
  return { outputFiles, wsOutputFiles }
@@ -399,46 +419,6 @@ function markWritten(
399
419
  }
400
420
  }
401
421
 
402
- /**
403
- * The first `workspaceFiles` directory that is a symlink resolving outside
404
- * the workspace root: every file under it is dropped as outside, and the
405
- * empty-artifact warning blamed the glob (M-65).
406
- */
407
- function outputDirLinkedOut(
408
- baseDir: string,
409
- outputs: readonly string[],
410
- ): { dir: string; target: string; base: string } | undefined {
411
- const realProject = realpathOrNull(baseDir) ?? baseDir
412
- // `dist` names the tree `dist/**`: the same reading the resolver gives.
413
- for (const pattern of asTrees(outputs.filter((o) => !o.startsWith('!')))) {
414
- const literal = staticPrefix(pattern)
415
- .split('/')
416
- .filter((p) => p !== '' && p !== '.')
417
- for (let i = 1; i <= literal.length; i++) {
418
- const dir = literal.slice(0, i).join('/')
419
- const abs = path.join(baseDir, dir)
420
- try {
421
- if (!lstatSync(abs).isSymbolicLink()) continue
422
- } catch {
423
- break
424
- }
425
- const target = realpathOrNull(abs)
426
- if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
427
- return { dir, target, base: 'the workspace' }
428
- }
429
- }
430
- }
431
- return undefined
432
- }
433
-
434
- function realpathOrNull(p: string): string | null {
435
- try {
436
- return realpathSync(p)
437
- } catch {
438
- return null
439
- }
440
- }
441
-
442
422
  /**
443
423
  * The clause a "matched no files" warning gains when a glob holds an
444
424
  * unescaped `[` and, read as a literal path, names something on disk:
@@ -8,6 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { AffectedReason } from './affected-tasks.js'
9
9
  import type { EventBus, Logger } from '../report/index.js'
10
10
  import type { TelemetrySink } from './telemetry.js'
11
+ import type { CommandWindows } from './command-windows.js'
11
12
  import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
12
13
  import type { RunSettings } from '../config.js'
13
14
 
@@ -220,6 +221,11 @@ export interface RunOptions {
220
221
  * is replaced only when the next cycle starts.
221
222
  */
222
223
  holdPersistent?: boolean
224
+ /**
225
+ * The run is a `vx watch` cycle: an input edited while a task ran starts
226
+ * the next cycle, and the line withholding the task's save says so.
227
+ */
228
+ watch?: boolean
223
229
  /**
224
230
  * The servers an earlier `holdPersistent` run handed back. Each one still
225
231
  * up whose task this run would start again, with the same resolved
@@ -232,6 +238,13 @@ export interface RunOptions {
232
238
  * not take.
233
239
  */
234
240
  keep?: HeldPersistent
241
+ /**
242
+ * Where the run records when each command ran (and each restore or deferred fetch)
243
+ * and where it may write (command-windows.ts). The watch loop passes its
244
+ * own, so a write that lands during the cycle and no command of it may
245
+ * have made is the user's edit, whatever the file settles to.
246
+ */
247
+ commandWindows?: CommandWindows
235
248
  /**
236
249
  * Print the per-task table (`--verbosity 1`) just above the footer: the
237
250
  * footer is the run's last word, so the caller cannot print it after.
@@ -3,9 +3,10 @@
3
3
  // and core's renderer is the run's, byte for byte.
4
4
 
5
5
  import { terminalSubscriber, type RunEventSubscriber } from '../report/index.js'
6
- import type { Logger } from '../report/index.js'
6
+ import type { Logger, RunEvent } from '../report/index.js'
7
7
  import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
8
8
  import type { OutputContext, VxPlugin } from './plugin.js'
9
+ import { readOnlyEvents } from './read-only.js'
9
10
 
10
11
  const REQUIRED = ['status', 'taskStdout', 'taskStderr', 'taskComplete'] as const
11
12
  const OPTIONAL = [
@@ -87,24 +88,35 @@ export function guardedOutput(
87
88
  }
88
89
  // An `async` method's rejection reached no one: an unhandled rejection
89
90
  // ended the run with a stack, exit 1, after its tasks had passed. It
90
- // fails the Logger as a throw does; the event it was handed stays its.
91
+ // fails the Logger as a throw does, and core draws the event it was
92
+ // handed, as after a throw: a rejected `taskComplete` lost its task's row.
93
+ let current: RunEvent | undefined
91
94
  const logger = Object.create(claimed.logger) as Logger
92
95
  for (const k of [...REQUIRED, ...OPTIONAL, 'warn'] as const) {
93
96
  const method = claimed.logger[k] as ((...args: unknown[]) => unknown) | undefined
94
97
  if (typeof method !== 'function') continue
95
98
  ;(logger as unknown as Record<string, unknown>)[k] = (...args: unknown[]): void => {
99
+ const event = current
96
100
  const ret = method.apply(claimed.logger, args)
97
- if (ret instanceof Promise) ret.catch(fail)
101
+ if (ret instanceof Promise)
102
+ ret.catch((err: unknown) => {
103
+ fail(err)
104
+ if (event !== undefined) fallback(event)
105
+ })
98
106
  }
99
107
  }
100
108
  const render = terminalSubscriber(logger)
109
+ const viewOf = readOnlyEvents()
101
110
  return (event) => {
102
111
  if (failed) return fallback(event)
112
+ current = event
103
113
  try {
104
- render(event)
114
+ render(viewOf(event))
105
115
  } catch (err) {
106
116
  fail(err)
107
117
  fallback(event)
118
+ } finally {
119
+ current = undefined
108
120
  }
109
121
  }
110
122
  }
@@ -5,7 +5,7 @@
5
5
  // a literal RS doubled. Output that never touched stderr is its own text.
6
6
 
7
7
  import { type BoundedCapture, type CapturedChunk, isDroppedOutputChunk } from '../exec/index.js'
8
- import { MASKED, type SecretMask } from '../util/index.js'
8
+ import { maskStreams, type SecretMask } from '../util/index.js'
9
9
 
10
10
  const RS = '\x1e'
11
11
 
@@ -43,59 +43,6 @@ export function maskedOutputLog(chunks: readonly CapturedChunk[], mask: SecretMa
43
43
  return h + back + chunks[at]!.text + lead + t
44
44
  }
45
45
 
46
- /**
47
- * Each stream's run of adjacent non-empty chunks joined, with `mask`
48
- * applied to each stream's text as a whole: a value is masked where it
49
- * starts and its rest dropped from the runs it reaches. The live stream
50
- * masks chunk by chunk and lets a held piece go after a pause, so a value
51
- * written in two pieces reached the capture in two chunks, and a write to
52
- * the other stream between them made them two runs. A hit replays each
53
- * stream to its own descriptor, so `vx run > log` joined them again.
54
- */
55
- export function maskStreams(
56
- chunks: readonly CapturedChunk[],
57
- mask: SecretMask | undefined,
58
- ): CapturedChunk[] {
59
- if (mask === undefined) return chunks.filter((c) => c.text.length > 0)
60
- const runs: CapturedChunk[] = []
61
- for (const c of chunks) {
62
- if (c.text.length === 0) continue
63
- const last = runs.at(-1)
64
- if (last !== undefined && last.err === c.err) last.text += c.text
65
- else runs.push({ text: c.text, err: c.err })
66
- }
67
- for (const err of [false, true]) {
68
- const own = runs.filter((r) => r.err === err)
69
- const spans = mask.spans(own.map((r) => r.text).join(''))
70
- if (spans.length === 0) continue
71
- let at = 0
72
- let i = 0
73
- for (const r of own) {
74
- const end = at + r.text.length
75
- let text = ''
76
- let from = at
77
- for (; i < spans.length && spans[i]![0] < end; i++) {
78
- const [s, e] = spans[i]!
79
- if (s >= from) text += r.text.slice(from - at, s - at) + MASKED
80
- from = Math.max(from, Math.min(e, end))
81
- // A value that runs past this run is cut from the next one too.
82
- if (e > end) break
83
- }
84
- r.text = text + r.text.slice(from - at)
85
- at = end
86
- }
87
- }
88
- // A run emptied whole leaves its neighbours, the other stream, adjacent.
89
- const kept: CapturedChunk[] = []
90
- for (const r of runs) {
91
- if (r.text.length === 0) continue
92
- const last = kept.at(-1)
93
- if (last !== undefined && last.err === r.err) last.text += r.text
94
- else kept.push(r)
95
- }
96
- return kept
97
- }
98
-
99
46
  /** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
100
47
  export function decodeOutputLog(log: string): CapturedChunk[] {
101
48
  if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
@@ -30,7 +30,7 @@ import {
30
30
  } from './sandbox-request.js'
31
31
  import { shellVerdict } from './shell-verdict.js'
32
32
  import { forwardedSignal, terminateChildren } from './signals.js'
33
- import { computeTaskHash } from './task-hash.js'
33
+ import { computeTaskHash, keyOf } from './task-hash.js'
34
34
  import {
35
35
  forgetUndeclaredWrites,
36
36
  isAborted,
@@ -52,16 +52,18 @@ export function serverKey(args: ExecuteArgs): Promise<string | undefined> | stri
52
52
  if (args.noDependants === true) return undefined
53
53
  return (
54
54
  args.upfrontKey ??
55
- computeTaskHash({
56
- node: args.node,
57
- upstream: args.upstream,
58
- workspaceRoot: args.workspaceRoot,
59
- cache: args.cache,
60
- forwardArgs: args.forwardArgs,
61
- nestedProjectDirs: args.nestedProjectDirs,
62
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
63
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
64
- })
55
+ keyOf(
56
+ computeTaskHash({
57
+ node: args.node,
58
+ upstream: args.upstream,
59
+ workspaceRoot: args.workspaceRoot,
60
+ cache: args.cache,
61
+ forwardArgs: args.forwardArgs,
62
+ nestedProjectDirs: args.nestedProjectDirs,
63
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
64
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
65
+ }),
66
+ )
65
67
  )
66
68
  }
67
69
 
@@ -129,6 +131,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
129
131
  await args.armSandbox?.()
130
132
  // A persistent task never declares `cache` (the loader refuses it), so
131
133
  // it keeps the whole linked grant: no key of its can be stale.
134
+ const deps = await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot)
132
135
  const sb = await sandboxRequestFor(
133
136
  node,
134
137
  step.sandbox,
@@ -136,7 +139,8 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
136
139
  undefined,
137
140
  args.nestedProjectDirs,
138
141
  args.cacheDir,
139
- await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot),
142
+ deps.reads,
143
+ deps.reported,
140
144
  )
141
145
  placeholders = sb.placeholders
142
146
  serverSandbox = sb.sandbox
@@ -181,6 +185,17 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
181
185
  : {}),
182
186
  ...(signalChannel ? { signalChannel } : {}),
183
187
  ...(args.terminal === true ? { terminal: true } : {}),
188
+ // What the pre-ready text left held goes out first, so the run can
189
+ // tell it from what the server writes since ready.
190
+ ...(args.serverReady !== undefined
191
+ ? {
192
+ onReady: () => {
193
+ serverMasked?.end()
194
+ ends.end()
195
+ args.serverReady!(node.id)
196
+ },
197
+ }
198
+ : {}),
184
199
  }
185
200
  if (step.persistent.readyWhen !== undefined) {
186
201
  persistentOpts.readyWhen = step.persistent.readyWhen
@@ -244,7 +259,9 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
244
259
  readyWhen === undefined
245
260
  ? undefined
246
261
  : setTimeout(() => {
247
- if (isAborted(args.stopSignal)) return
262
+ // A server that traps SIGTERM runs out the kill grace after the
263
+ // timeout gave up; the wait is over, and its failure says so.
264
+ if (isAborted(args.stopSignal) || spawn.gaveUp()) return
248
265
  const after = noticeMs < 1000 ? `${noticeMs} ms` : `${noticeMs / 1000} s`
249
266
  const unbounded = effectiveTimeout === undefined ? ', with no exec.timeout' : ''
250
267
  warnOf(log)(
@@ -288,10 +305,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
288
305
  // (an embedder) never saw a bare stderr write at all. What waited on
289
306
  // an open line goes first.
290
307
  ends.end()
291
- log.taskStderr(
292
- node,
293
- ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
294
- )
308
+ log.taskStderr(node, ends.own(`[vx] ${node.id}: not ready: ${message}\n`))
295
309
  // A server's 127, 126 or signal death needs the line a one-shot task's
296
310
  // gets (execute-task.ts): a dev server whose tool sits in a sibling's
297
311
  // bin said only "exit 127".