@vzn/vx 0.0.651 → 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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -63,3 +63,56 @@ export function keyedProjects(
63
63
  return below.get(root.id)!
64
64
  }
65
65
  }
66
+
67
+ /**
68
+ * A run's lookup of the exec tasks whose outputs a sandboxed task may read:
69
+ * those its key folds, through a group to its members, never past an exec
70
+ * task. Their outputs are what their keys answer for, and this key folds
71
+ * those keys, so a read of them can never replay stale (the toolchain
72
+ * rule: a `tools` project builds Node, every dependant runs it). A
73
+ * dependency `inputs.tasks` leaves out is ordered, not read.
74
+ */
75
+ export function readableDeps(
76
+ nodes: ReadonlyMap<string, TaskNode>,
77
+ keyOnly: ReadonlyMap<string, TaskNode> = new Map(),
78
+ ): (node: TaskNode) => readonly TaskNode[] {
79
+ const nodeOf = (id: string): TaskNode => nodes.get(id) ?? keyOnly.get(id)!
80
+ return (root) => {
81
+ const out = new Map<string, TaskNode>()
82
+ const seen = new Set<string>()
83
+ const stack = foldedDeps(root, nodeOf).map((c) => c.node)
84
+ while (stack.length > 0) {
85
+ const dep = stack.pop()!
86
+ if (seen.has(dep.id)) continue
87
+ seen.add(dep.id)
88
+ if (!isGroupTask(dep)) out.set(dep.id, dep)
89
+ else for (const { node } of foldedDeps(dep, nodeOf)) stack.push(node)
90
+ }
91
+ return [...out.values()]
92
+ }
93
+ }
94
+
95
+ /**
96
+ * A run's lookup of the projects nested in a keyed one, by its directory.
97
+ * A sandboxed task granted a linked package is walled from them: that
98
+ * package's key stops at a nested project (sandbox-request.ts). Built on
99
+ * the first ask, from every node the walk can reach.
100
+ */
101
+ export function nestedDirsOf(
102
+ nodes: ReadonlyMap<string, TaskNode>,
103
+ keyOnly: ReadonlyMap<string, TaskNode>,
104
+ nestedDirsByProject: ReadonlyMap<string, readonly string[]>,
105
+ ): (projectDir: string) => readonly string[] {
106
+ let byDir: Map<string, readonly string[]> | undefined
107
+ return (dir) => {
108
+ if (byDir === undefined) {
109
+ byDir = new Map()
110
+ for (const map of [nodes, keyOnly]) {
111
+ for (const n of map.values()) {
112
+ byDir.set(n.projectDir, nestedDirsByProject.get(n.projectName) ?? [])
113
+ }
114
+ }
115
+ }
116
+ return byDir.get(dir) ?? []
117
+ }
118
+ }
@@ -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()
@@ -32,7 +32,7 @@
32
32
  // - Only STABLE-key, cacheable, local-read tasks are classified. A
33
33
  // stable miss stays in the normal (dep-gated) schedule; an unstable
34
34
  // task is never probed here.
35
- // - A task that declares `cache.outputs.workspaceFiles` (the
35
+ // - A task that declares `outputs.workspaceFiles` (the
36
36
  // boundary-ignoring escape hatch) can write where a restore touches,
37
37
  // so every task whose project directory a workspace-output glob's
38
38
  // static prefix REACHES stays out of the restore tier, and so does
@@ -261,7 +261,7 @@ export function restoreTierExclusions(
261
261
  const prefixes: string[] = []
262
262
  let everything = false
263
263
  for (const node of nodes.values()) {
264
- for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
264
+ for (const raw of node.config.outputs?.workspaceFiles ?? []) {
265
265
  // A `!` entry needs no skip: its prefix (`!gen`) names no directory,
266
266
  // so it excludes nothing (A-44; the `continue` that stood here was
267
267
  // dropped in item 640).
@@ -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'
@@ -109,7 +108,7 @@ export interface SaveMissArgs {
109
108
  workspaceRoot: string
110
109
  nestedProjectDirs: string[]
111
110
  gitFilesCache?: GitFilesCache | undefined
112
- /** Declared `cache.outputs.files` / `.workspaceFiles`. */
111
+ /** Declared `outputs.files` / `.workspaceFiles`. */
113
112
  outputs: readonly string[]
114
113
  wsOutputs: readonly string[]
115
114
  /**
@@ -119,7 +118,7 @@ export interface SaveMissArgs {
119
118
  * would also take the upstream's files the task adds beside.
120
119
  */
121
120
  ownOutputFiles?: string[] | undefined
122
- /** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
121
+ /** `ownOutputFiles` for `outputs.workspaceFiles` (A-43). */
123
122
  ownWsOutputFiles?: string[] | undefined
124
123
  /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
125
124
  captured: readonly TaskInputComponent[]
@@ -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
  }
@@ -162,19 +161,48 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
162
161
  // An additive task owns only the files it wrote (item 588), no directory.
163
162
  const tree =
164
163
  a.ownOutputFiles !== undefined
165
- ? { files: a.ownOutputFiles, emptyDirs: [] }
164
+ ? { files: a.ownOutputFiles, emptyDirs: [], special: [] }
166
165
  : await resolveOutputTree({
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
+ },
174
+ special: true,
170
175
  })
171
176
  const outputFiles = tree.files
172
177
  const wsTree =
173
178
  a.ownWsOutputFiles !== undefined
174
- ? { files: a.ownWsOutputFiles, emptyDirs: [] }
175
- : await resolveWorkspaceOutputTree({ workspaceRoot: a.workspaceRoot, outputs: a.wsOutputs })
179
+ ? { files: a.ownWsOutputFiles, emptyDirs: [], special: [] }
180
+ : await resolveWorkspaceOutputTree({
181
+ workspaceRoot: a.workspaceRoot,
182
+ outputs: a.wsOutputs,
183
+ inputs: {
184
+ projectDir: node.projectDir,
185
+ workspaceRoot: a.workspaceRoot,
186
+ declared: node.config.inputs,
187
+ },
188
+ special: true,
189
+ })
176
190
  const wsOutputFiles = wsTree.files
177
191
  endResolve()
192
+ // A FIFO, socket or device holds no bytes to store (opening a FIFO waits
193
+ // for a writer, nx#35228), so it stays out of the entry; said, because a
194
+ // hit restores the tree without it.
195
+ const special = [
196
+ ...tree.special.map((p) => relPosix(node.projectDir, p)),
197
+ ...wsTree.special.map((p) => relPosix(a.workspaceRoot, p)),
198
+ ]
199
+ if (special.length > 0) {
200
+ warnOf(log)(
201
+ `[vx] ${node.id}: ${special.join(', ')} ${special.length === 1 ? 'is' : 'are'} not a regular file or symlink ` +
202
+ `(a FIFO, socket or device): vx does not cache ${special.length === 1 ? 'it' : 'them'}, so a hit restores the outputs without ${special.length === 1 ? 'it' : 'them'} ` +
203
+ `— emit a file there, or take ${special.length === 1 ? 'it' : 'them'} back with a '!' entry`,
204
+ )
205
+ }
178
206
  // The mirror of the input warning: declared outputs that resolve to
179
207
  // nothing save an empty artifact, and the next hit "restores" it —
180
208
  // the build that ran nowhere looks like a build that ran. `files: []`
@@ -191,20 +219,15 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
191
219
  // (project dir === workspace root) the write SUCCEEDS into that
192
220
  // scratch and the task exits 0, so this warning is the only thing that
193
221
  // says the build produced nothing (item 444). A declared
194
- // `cache.outputs` is not a write grant; `exec.sandbox.allow.write` is.
222
+ // `outputs` is not a write grant; `exec.sandbox.allow.write` is.
195
223
  const sandboxed = node.config.exec?.sandbox !== undefined
196
224
  const grantsWrite = (node.config.exec?.sandbox?.allow?.write?.length ?? 0) > 0
197
- // A `workspaceFiles` directory linked out of the workspace drops every
198
- // file (M-65); a project one is refused by the resolver (X-88).
199
- const linkedOut = outputDirLinkedOut(a.workspaceRoot, a.wsOutputs)
200
225
  warnOf(log)(
201
- `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
226
+ `[vx] ${node.id}: outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
202
227
  `an empty artifact is saved; a later hit restores nothing` +
203
- (linkedOut !== undefined
204
- ? ` — ${linkedOut.dir} is a symlink to ${linkedOut.target}, outside ${linkedOut.base}, and vx keeps only outputs inside it: make ${linkedOut.dir} a directory`
205
- : sandboxed && !grantsWrite
206
- ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
207
- : '') +
228
+ (sandboxed && !grantsWrite
229
+ ? ` — the task is sandboxed and declares no exec.sandbox.allow.write, so its writes never reached disk`
230
+ : '') +
208
231
  bracketLiteralHint([
209
232
  ...a.outputs.map((g) => [node.projectDir, g] as const),
210
233
  ...a.wsOutputs.map((g) => [a.workspaceRoot, g] as const),
@@ -212,6 +235,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
212
235
  )
213
236
  }
214
237
  const facts: SaveFacts = {}
238
+ let kept = false
215
239
  const save = async (): Promise<void> => {
216
240
  const saveStart = a.measure === true ? performance.now() : 0
217
241
  const endSave = span('miss: save')
@@ -243,6 +267,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
243
267
  endSave()
244
268
  const bytes = cache.outputBytes?.(a.hash)
245
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
246
273
  if (a.measure === true) {
247
274
  facts.saveMs = Math.round(performance.now() - saveStart)
248
275
  // No local artifact (local writes off): no size to say.
@@ -281,11 +308,13 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
281
308
  // the entry before the outcome.
282
309
  if (a.deferSave !== undefined) {
283
310
  return {
284
- 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)),
285
314
  }
286
315
  }
287
316
  await save()
288
- return { landed: Promise.resolve({ ...facts, saved: true }) }
317
+ return { landed: Promise.resolve(kept ? facts : { ...facts, saved: true }) }
289
318
  }
290
319
 
291
320
  /** What `markUnsaved` reads of a miss: where its declared outputs are. */
@@ -323,10 +352,20 @@ async function resolveUnsaved(
323
352
  projectDir: a.node.projectDir,
324
353
  outputs: a.outputs,
325
354
  nestedProjectDirs: a.nestedProjectDirs,
355
+ inputs: {
356
+ projectDir: a.node.projectDir,
357
+ workspaceRoot: a.workspaceRoot,
358
+ declared: a.node.config.inputs,
359
+ },
326
360
  })
327
361
  const wsOutputFiles = await resolveWorkspaceOutputs({
328
362
  workspaceRoot: a.workspaceRoot,
329
363
  outputs: a.wsOutputs,
364
+ inputs: {
365
+ projectDir: a.node.projectDir,
366
+ workspaceRoot: a.workspaceRoot,
367
+ declared: a.node.config.inputs,
368
+ },
330
369
  })
331
370
  endResolve()
332
371
  return { outputFiles, wsOutputFiles }
@@ -347,7 +386,7 @@ function sizeOf(base: string, files: readonly string[]): number {
347
386
  * these paths. On a 1000-package cold run this removes ~one synchronous
348
387
  * `git ls-files` spawn per project (the single largest cold-run cost —
349
388
  * 22% of CPU in profiling). Contract: outputs must be declared — an
350
- * executed task that writes files outside `cache.outputs.files` which a
389
+ * executed task that writes files outside `outputs.files` which a
351
390
  * same-project downstream task reads is undeclared behavior (the restore
352
391
  * path already assumes it).
353
392
  */
@@ -380,46 +419,6 @@ function markWritten(
380
419
  }
381
420
  }
382
421
 
383
- /**
384
- * The first `workspaceFiles` directory that is a symlink resolving outside
385
- * the workspace root: every file under it is dropped as outside, and the
386
- * empty-artifact warning blamed the glob (M-65).
387
- */
388
- function outputDirLinkedOut(
389
- baseDir: string,
390
- outputs: readonly string[],
391
- ): { dir: string; target: string; base: string } | undefined {
392
- const realProject = realpathOrNull(baseDir) ?? baseDir
393
- // `dist` names the tree `dist/**`: the same reading the resolver gives.
394
- for (const pattern of asTrees(outputs.filter((o) => !o.startsWith('!')))) {
395
- const literal = staticPrefix(pattern)
396
- .split('/')
397
- .filter((p) => p !== '' && p !== '.')
398
- for (let i = 1; i <= literal.length; i++) {
399
- const dir = literal.slice(0, i).join('/')
400
- const abs = path.join(baseDir, dir)
401
- try {
402
- if (!lstatSync(abs).isSymbolicLink()) continue
403
- } catch {
404
- break
405
- }
406
- const target = realpathOrNull(abs)
407
- if (target !== null && target !== realProject && !target.startsWith(realProject + path.sep)) {
408
- return { dir, target, base: 'the workspace' }
409
- }
410
- }
411
- }
412
- return undefined
413
- }
414
-
415
- function realpathOrNull(p: string): string | null {
416
- try {
417
- return realpathSync(p)
418
- } catch {
419
- return null
420
- }
421
- }
422
-
423
422
  /**
424
423
  * The clause a "matched no files" warning gains when a glob holds an
425
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 }]