@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
@@ -4,6 +4,7 @@
4
4
  // plugin `project` stage, which can give tasks to a package that never
5
5
  // wrote a `vx.config.ts`.
6
6
 
7
+ import { existsSync } from 'node:fs'
7
8
  import { stat } from 'node:fs/promises'
8
9
  import path from 'node:path'
9
10
  import type { ProjectConfig } from '../config.js'
@@ -14,6 +15,7 @@ import {
14
15
  lockedProjects,
15
16
  NO_LOCK,
16
17
  pathName,
18
+ PROJECT_CONFIG_FILENAMES,
17
19
  readLockfile,
18
20
  validateProjectConfig,
19
21
  type Lockfile,
@@ -22,8 +24,9 @@ import {
22
24
  type ProjectMeta,
23
25
  } from '../workspace/index.js'
24
26
  import type { WorkspaceConfig } from '../config.js'
25
- import { lazyGitEnumeration, type LazyGitEnumeration } from '../cache/index.js'
26
- import { relPosix, UserError } from '../util/index.js'
27
+ import { atPath, SETTINGS } from '../settings.js'
28
+ import { lazyGitEnumeration, type LazyGitEnumeration, parseCachePolicy } from '../cache/index.js'
29
+ import { checkIgnoreSync, isUserError, relPosix, UserError } from '../util/index.js'
27
30
  import {
28
31
  cacheScopeError,
29
32
  isCacheScope,
@@ -34,8 +37,10 @@ import {
34
37
  validateWorkspace,
35
38
  } from '../workspace/index.js'
36
39
  import { DEFAULT_BUILD, parseDependencySpec } from '../graph/index.js'
40
+ import { workspaceFile } from '../handoff.js'
37
41
  import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
38
42
  import type { BranchFacts, VxPlugin } from './plugin.js'
43
+ import { freezeDeep, readOnly } from './read-only.js'
39
44
 
40
45
  /**
41
46
  * The workspace config with the plugin `config` stage applied — the first
@@ -49,12 +54,27 @@ export async function loadWorkspacePlugins(
49
54
  workspaceConfig: WorkspaceConfig | null
50
55
  plugins: readonly VxPlugin[]
51
56
  branch: BranchFacts
57
+ /** Setting path → the last plugin whose `config` hook changed it (`vx info`). */
58
+ settingPlugins: ReadonlyMap<string, string>
52
59
  }> {
53
60
  const loaded = await loadWorkspaceConfig(workspaceRoot)
61
+ const policy = loaded?.cache?.policy
62
+ if (policy !== undefined) {
63
+ // The grammar is the cache module's, which the schema may not import:
64
+ // checked here, where the file is known, so the refusal is framed.
65
+ try {
66
+ parseCachePolicy(policy)
67
+ } catch (err) {
68
+ const file = await workspaceFile(workspaceRoot)
69
+ if (!isUserError(err) || file === null) throw err
70
+ throw new UserError(`${file}: \`cache.policy\` ${err.message}`, 'VX_E_CONFIG')
71
+ }
72
+ }
54
73
  const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
55
74
  const branch: BranchFacts = {}
75
+ const settingPlugins = new Map<string, string>()
56
76
  if (loaded === null || !hasHook(plugins, 'config'))
57
- return { workspaceConfig: loaded, plugins, branch }
77
+ return { workspaceConfig: loaded, plugins, branch, settingPlugins }
58
78
  // Bun keeps one module per specifier, so the file's export is one object
59
79
  // per process: the CLI's selection pass, the run and each `vx watch`
60
80
  // cycle handed the hooks what the last load's hooks had edited
@@ -91,9 +111,17 @@ export async function loadWorkspacePlugins(
91
111
  branch.refs = [...new Set(facts.refs)]
92
112
  }
93
113
  }
114
+ const settingPaths = SETTINGS.filter((s) => s.config !== false).map((s) => s.path)
115
+ const settingValues = new Map(settingPaths.map((p) => [p, atPath(workspaceConfig, p)]))
94
116
  await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) => {
95
117
  const at = `.vx/workspace (after plugin '${plugin.name}')`
96
118
  validateWorkspace(workspaceConfig, at)
119
+ for (const p of settingPaths) {
120
+ const v = atPath(workspaceConfig, p)
121
+ if (v === settingValues.get(p)) continue
122
+ settingValues.set(p, v)
123
+ settingPlugins.set(p, plugin.name)
124
+ }
97
125
  // The run takes `plugins` from the file: an edited list validated and was dropped.
98
126
  const now = workspaceConfig.plugins
99
127
  if (now?.length !== list?.length || now?.some((p, i) => p !== list![i])) {
@@ -102,7 +130,7 @@ export async function loadWorkspacePlugins(
102
130
  )
103
131
  }
104
132
  })
105
- return { workspaceConfig, plugins, branch }
133
+ return { workspaceConfig, plugins, branch, settingPlugins }
106
134
  }
107
135
 
108
136
  const isRef = (r: unknown): boolean =>
@@ -128,6 +156,17 @@ export function lockOfDiscovery(
128
156
  return discoveredLock.get(projects)
129
157
  }
130
158
 
159
+ /** The nested workspace roots (absolute) the lock a discovery read records, by the array it returned. */
160
+ const discoveredWalls = new WeakMap<readonly ProjectMeta[], readonly string[]>()
161
+
162
+ /**
163
+ * Where every project's outputs stop besides its nested projects: the
164
+ * nested workspaces `vx lock` found. None for an array no discovery made.
165
+ */
166
+ export function nestedWorkspacesOf(projects: readonly ProjectMeta[]): readonly string[] {
167
+ return discoveredWalls.get(projects) ?? []
168
+ }
169
+
131
170
  /**
132
171
  * The package graph a selection pass built over a discovery, by that
133
172
  * discovery's array, when it folded no task edge in: the graph a run that
@@ -143,6 +182,31 @@ export function keepDiscoveryGraph(projects: readonly ProjectMeta[], graph: Pack
143
182
  discoveredGraph.set(projects, graph)
144
183
  }
145
184
 
185
+ /** The plugin whose `discover` entry made each project the lock does not hold. */
186
+ const addedBy = new WeakMap<object, string>()
187
+
188
+ /**
189
+ * Why a project a `discover` plugin added holds no config while its
190
+ * directory has one git ignores, or undefined: `vx lock` reads only the
191
+ * configs git lists, so "run `vx lock`" could never fix it. `config` is
192
+ * the file on disk, looked for when not given. One git spawn, asked only
193
+ * of a directory holding a config.
194
+ */
195
+ export function ignoredConfigRefusal(
196
+ root: string,
197
+ meta: { readonly name: string; readonly dir: string },
198
+ config?: string,
199
+ ): string | undefined {
200
+ const by = addedBy.get(meta)
201
+ if (by === undefined) return undefined
202
+ const file =
203
+ config ?? PROJECT_CONFIG_FILENAMES.map((n) => path.join(meta.dir, n)).find((f) => existsSync(f))
204
+ if (file === undefined) return undefined
205
+ const rel = relPosix(root, file)
206
+ if (checkIgnoreSync(root, [rel])?.has(rel) !== true) return undefined
207
+ return `plugin '${by}' adds project ${meta.name}, but git ignores ${rel}, so \`vx lock\` cannot read it — unignore it or delete it`
208
+ }
209
+
146
210
  /**
147
211
  * The workspace's projects: the directories the lock records (a run never
148
212
  * walks for configs), then the plugin `discover` stage, which may add
@@ -158,7 +222,13 @@ export async function discoverProjects(
158
222
  /** The run's enumeration, which `DiscoverContext.worktreeChanges` reads (and starts). */
159
223
  git: LazyGitEnumeration = lazyGitEnumeration(workspaceRoot),
160
224
  ): Promise<ProjectMeta[]> {
161
- const projects = lockedProjects(workspaceRoot, (await lock()).projects)
225
+ const read = await lock()
226
+ const projects = lockedProjects(workspaceRoot, read.projects)
227
+ if (read.nestedWorkspaces !== undefined)
228
+ discoveredWalls.set(
229
+ projects,
230
+ read.nestedWorkspaces.map((rel) => path.join(workspaceRoot, rel)),
231
+ )
162
232
  // Registered whatever the hooks: an `--affected` selection reads its
163
233
  // untracked files from this walk, and the run reuses it (I-26).
164
234
  discoveredGit.set(projects, git)
@@ -176,7 +246,10 @@ export async function discoverProjects(
176
246
  { workspaceRoot, cacheDir, warn, worktreeChanges },
177
247
  async (entry, plugin) => {
178
248
  const found = await discoveredProject(workspaceRoot, byDir, entry, plugin.name)
179
- if (found.added) byDir.set(found.meta.dir, found.meta)
249
+ if (found.added) {
250
+ byDir.set(found.meta.dir, found.meta)
251
+ addedBy.set(found.meta, plugin.name)
252
+ }
180
253
  return found
181
254
  },
182
255
  )
@@ -259,6 +332,10 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
259
332
  // behind `^build`. Left out, its edit replayed the dependant's stale
260
333
  // output as a hit (X-9).
261
334
  const projectStage = hasHook(plugins, 'project')
335
+ // A hook that set one project's `dir` to another's moved it there: the
336
+ // graph's directory guard read the moved entry, and its outputs clean
337
+ // emptied the other project's.
338
+ if (projectStage) freezeDeep(args.projectMetas)
262
339
  const configured = args.projectMetas.filter(
263
340
  (m) => projectStage || (typeof m.configPath === 'string' && m.configPath.length > 0),
264
341
  )
@@ -372,9 +449,10 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
372
449
  }
373
450
  const frozen: ProjectConfig =
374
451
  meta.configPath === null ? { tasks: {} } : (loaded[next++] as ProjectConfig)
375
- // The lock shares a task two projects declare alike: an edit in place
376
- // works on this project's own copy.
377
- const config = projectStage ? structuredClone(frozen) : frozen
452
+ // The lock shares a task, or a part of one, every place declares alike,
453
+ // in this project too: an edit in place works on a copy that shares
454
+ // nothing. `structuredClone` keeps the sharing; the lock is JSON.
455
+ const config: ProjectConfig = projectStage ? JSON.parse(JSON.stringify(frozen)) : frozen
378
456
  if (projectStage) {
379
457
  // The `project` stage edits the validated object in place; core
380
458
  // re-validates after EACH plugin so a plugin can only produce what
@@ -391,7 +469,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
391
469
  warn: args.warn,
392
470
  name: meta.name,
393
471
  dir: meta.dir,
394
- projects: args.projectMetas,
472
+ // The view `discover` was handed: nx() keys its graph on its identity.
473
+ projects: readOnly(args.projectMetas),
395
474
  lock: args.lock === true,
396
475
  },
397
476
  (plugin) => {
@@ -0,0 +1,115 @@
1
+ // What a plugin is handed to observe is read-only. A setup handler that set
2
+ // a failed outcome's status to `success` made the run pass and its
3
+ // dependants run; a sink that cleared a record's `failedAttempts` cleared
4
+ // the outcome's own, and the run's failure log crashed on it. A write
5
+ // throws, inside the plugin's own call, where core already reports a throw
6
+ // naming the plugin.
7
+
8
+ import type { RunEvent } from '../report/index.js'
9
+
10
+ /**
11
+ * Freezes `value` and everything it holds, in place: for an object core
12
+ * built for plugins and no longer writes (a telemetry record, the run
13
+ * report, the project list). An object already frozen is taken as done, so
14
+ * each is walked once.
15
+ */
16
+ export function freezeDeep<T>(value: T): T {
17
+ if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
18
+ // A typed array with elements cannot be frozen; none is in a record.
19
+ if (ArrayBuffer.isView(value)) return value
20
+ Object.freeze(value)
21
+ for (const key of Object.keys(value)) freezeDeep((value as Record<string, unknown>)[key])
22
+ return value
23
+ }
24
+
25
+ /** The write a read-only view refuses: a TypeError, as a frozen object's is, saying why. */
26
+ function refused(key: string | symbol): TypeError {
27
+ return new TypeError(
28
+ `'${String(key)}' is read-only: vx hands a plugin what it observes, not what the run decides`,
29
+ )
30
+ }
31
+
32
+ /**
33
+ * The run's events as a plugin sees them: a read-only view of each, made at
34
+ * the first read of each object and kept for the run, so every plugin reads
35
+ * the same view and a node keeps one identity from its start to its end.
36
+ * A view, not a copy: core still writes an outcome after its task ends
37
+ * (`serverExit`), and a frozen deep copy of each node and outcome was ~5 us
38
+ * of every task a hook heard (10,000 tasks: 45 ms).
39
+ */
40
+ export function readOnlyEvents(): (event: RunEvent) => RunEvent {
41
+ return readOnlyViews()
42
+ }
43
+
44
+ /**
45
+ * What a stage hands a plugin that core goes on using live: a task node in
46
+ * `key`, `schedule` and `admit` (core writes its key parts, platform and
47
+ * deps after, so it is not frozen), the project list in `discover` (core
48
+ * names and grows it after each plugin). A key hook that moved a node's
49
+ * `projectDir` ran it in another project; an admit hook that rewrote its
50
+ * command ran it under the declared one's key. One view per object for the
51
+ * process, so a plugin that keys on identity (nx() on the project list,
52
+ * from `discover` to `project`) sees one object across stages.
53
+ */
54
+ export const readOnly: <T>(value: T) => T = readOnlyViews()
55
+
56
+ /** `nodes` as a `schedule` hook reads it: each node a read-only view, the map itself unwritable. */
57
+ export function readOnlyNodes<V>(nodes: ReadonlyMap<string, V>): ReadonlyMap<string, V> {
58
+ const out = new Map<string, V>()
59
+ for (const [id, node] of nodes) out.set(id, readOnly(node))
60
+ for (const key of ['set', 'delete', 'clear'] as const)
61
+ Object.defineProperty(out, key, {
62
+ value: () => {
63
+ throw refused(key)
64
+ },
65
+ })
66
+ return out
67
+ }
68
+
69
+ /**
70
+ * Read-only views of plain objects and arrays, each made at its first read
71
+ * and kept. Only plain objects and arrays are wrapped: a Map's methods need
72
+ * the Map itself, and a frozen object is read-only already.
73
+ */
74
+ function readOnlyViews(): <T>(value: T) => T {
75
+ const views = new WeakMap<object, object>()
76
+ const handler: ProxyHandler<object> = {
77
+ // A frozen target's own values are its own: a proxy must answer them
78
+ // unchanged, and they are read-only already.
79
+ get: (target, key) => {
80
+ const value: unknown = Reflect.get(target, key)
81
+ return Object.isFrozen(target) ? value : view(value)
82
+ },
83
+ // Each refused whatever the plugin's mode: a frozen object's write is
84
+ // silent outside strict code.
85
+ set: (_target, key) => {
86
+ throw refused(key)
87
+ },
88
+ defineProperty: (_target, key) => {
89
+ throw refused(key)
90
+ },
91
+ deleteProperty: (_target, key) => {
92
+ throw refused(key)
93
+ },
94
+ setPrototypeOf: () => {
95
+ throw refused('prototype')
96
+ },
97
+ preventExtensions: () => {
98
+ throw refused('extensions')
99
+ },
100
+ }
101
+ function view<T>(value: T): T {
102
+ if (typeof value !== 'object' || value === null) return value
103
+ // Before the frozen check: the project list is viewed in `discover`
104
+ // and frozen before `project`, and keeps its one view.
105
+ const seen = views.get(value)
106
+ if (seen !== undefined) return seen as T
107
+ if (Object.isFrozen(value)) return value
108
+ const proto: unknown = Object.getPrototypeOf(value)
109
+ if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) return value
110
+ const made = new Proxy(value, handler)
111
+ views.set(value, made)
112
+ return made as T
113
+ }
114
+ return view
115
+ }
@@ -231,7 +231,7 @@ function fetchPool(
231
231
  * lookup, the other one that can reach a remote.
232
232
  */
233
233
  export function getContext(node: TaskNode, command: string) {
234
- const outputs = node.config.cache?.outputs
234
+ const outputs = node.config.outputs
235
235
  let masked: string | undefined
236
236
  return {
237
237
  taskId: node.id,
@@ -455,13 +455,17 @@ function workTreePath(root: string): string | undefined {
455
455
  * Normalize a git remote URL so every checkout of the same repository
456
456
  * derives the SAME workspace id: `git@github.com:o/r.git`,
457
457
  * `ssh://git@github.com/o/r`, and `https://github.com/o/r.git` all
458
- * reduce to `github.com/o/r`.
458
+ * reduce to `github.com/o/r`. No credential survives: the userinfo ends
459
+ * at the last `@` before the path, as a URL parser splits it (a password
460
+ * may hold `@`), and a query or fragment (`?private_token=`) is dropped.
459
461
  */
460
462
  export function normalizeRemoteUrl(raw: string): string {
461
463
  let s = raw.trim().toLowerCase()
462
464
  const hadProtocol = /^[a-z+]+:\/\//.test(s)
463
465
  s = s.replace(/^[a-z+]+:\/\//, '') // protocol
464
- s = s.replace(/^[^@/]+@/, '') // user[:pass]@
466
+ s = s.replace(/[?#].*$/s, '') // ?query, #fragment
467
+ // user[:pass]@; scp shorthand has no password, and its path follows a `:`.
468
+ s = s.replace(hadProtocol ? /^[^/]*@/ : /^[^@/]+@/, '')
465
469
  // A `:port` only appears in a protocol-form URL (`ssh://host:2222/path`); the
466
470
  // scp shorthand `host:path` has no port. STRIP it (the old code reinserted it
467
471
  // as a path segment, so `ssh://host:2222/o/r` and `https://host/o/r` — the
@@ -3,11 +3,11 @@
3
3
  // cache.db's `failures` (cache/run-notes.ts), the newest 50 failed runs kept.
4
4
 
5
5
  import type { Database } from 'bun:sqlite'
6
- import { indexDb, type Cache, keepRunNotes, keptFailures } from '../cache/index.js'
6
+ import { indexDb, type Cache, keepRunNotes, keptFailures, storedLog } from '../cache/index.js'
7
7
  import { isGroupTask, type TaskOutcome } from '../graph/index.js'
8
8
  import { decodeOutputLog } from './output-log.js'
9
9
  import { PASS_STATUSES } from '../history/index.js'
10
- import { maskedLine, splitTaskId } from '../util/index.js'
10
+ import { maskedLine, splitTaskId, stripEscapes } from '../util/index.js'
11
11
  import { fileLocations, fileMemo, type OutputLocation } from '../ui/index.js'
12
12
 
13
13
  /** A failed task of a run and what it said. */
@@ -46,17 +46,16 @@ export function writeRunFailures(
46
46
  const failures = []
47
47
  for (const o of outcomes) {
48
48
  // A task failed by a moved input may have printed nothing: the input
49
- // is still why, and an agent reads it here.
50
- if ((o.failedOutput === undefined && o.inputMoved === undefined) || isGroupTask(o.node))
49
+ // is still why, and an agent reads it here. A pass that took a retry
50
+ // keeps its failed attempts.
51
+ if (
52
+ (o.failedOutput === undefined &&
53
+ o.inputMoved === undefined &&
54
+ o.attemptOutputs === undefined) ||
55
+ isGroupTask(o.node)
56
+ )
51
57
  continue
52
- const output =
53
- o.failedOutput === undefined
54
- ? ''
55
- : Bun.stripANSI(
56
- decodeOutputLog(o.failedOutput)
57
- .map((c) => c.text)
58
- .join(''),
59
- )
58
+ const output = attemptsText(o)
60
59
  failures.push({
61
60
  taskId: o.node.id,
62
61
  ...(o.inputMoved !== undefined ? { inputMoved: o.inputMoved } : {}),
@@ -74,6 +73,40 @@ export function writeRunFailures(
74
73
  }
75
74
  }
76
75
 
76
+ const plain = (log: string): string =>
77
+ stripEscapes(
78
+ decodeOutputLog(log)
79
+ .map((c) => c.text)
80
+ .join(''),
81
+ )
82
+
83
+ /** How attempt `n` of `of` ended, the line above its output. */
84
+ const attemptHeading = (n: number, of: number, how: string): string =>
85
+ `── attempt ${n} of ${of} · ${how} ──\n`
86
+
87
+ const ended = (a: { exitCode: number; timedOut?: true }): string =>
88
+ a.timedOut === true ? 'timed out' : `exit ${a.exitCode}`
89
+
90
+ /**
91
+ * What a failure row keeps of `o`: its output, after each failed attempt's
92
+ * under a heading when it retried. A pass that retried keeps only the
93
+ * failed attempts: its own output is its cache entry's.
94
+ */
95
+ function attemptsText(o: TaskOutcome): string {
96
+ const last = o.failedOutput === undefined ? '' : plain(o.failedOutput)
97
+ const before = o.attemptOutputs ?? []
98
+ if (before.length === 0) return last
99
+ const of = before.length + 1
100
+ let text = ''
101
+ before.forEach((log, i) => {
102
+ const out = plain(log)
103
+ text += attemptHeading(i + 1, of, ended(o.failedAttempts![i]!)) + out
104
+ if (out !== '' && !out.endsWith('\n')) text += '\n'
105
+ })
106
+ if (o.status === 'failed') text += attemptHeading(of, of, ended(o)) + last
107
+ return text
108
+ }
109
+
77
110
  /**
78
111
  * The failed tasks of `runId`, or of the latest failed run when omitted;
79
112
  * null when there is no such run. A failed task with no kept row (a run
@@ -133,8 +166,9 @@ export interface TaskLog {
133
166
  status: string
134
167
  /**
135
168
  * Where the output came from: the run's kept failure output, the cache
136
- * entry the task saved or hit (masked as it was replayed), or null when
137
- * vx kept none (an uncached task that passed, a skip).
169
+ * entry the task saved or hit while it still holds that output (masked as
170
+ * it was replayed), or null when vx kept none (an uncached task that
171
+ * passed, a skip, an entry another run wrote since).
138
172
  */
139
173
  source: 'failure' | 'cache' | null
140
174
  /** Plain text; '' when `source` is null. The files it names: `runFailures`. */
@@ -150,7 +184,7 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
150
184
  if (project === '' || task === '') return null
151
185
  const row = db
152
186
  .query(
153
- `SELECT run_id AS runId, hash, status FROM runs
187
+ `SELECT run_id AS runId, hash, status, cache_hit AS hit, started_at AS startedAt FROM runs
154
188
  WHERE project = ? AND task = ? AND run_id IS NOT NULL ${runId === undefined ? '' : 'AND run_id = ?'}
155
189
  ORDER BY id DESC LIMIT 1`,
156
190
  )
@@ -158,6 +192,8 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
158
192
  runId: string
159
193
  hash: string
160
194
  status: string
195
+ hit: number | null
196
+ startedAt: number
161
197
  } | null
162
198
  if (row === null) return null
163
199
  const base = { runId: row.runId, taskId, status: row.status }
@@ -167,27 +203,69 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
167
203
  const kept = keptFailures(db, row.runId).get(taskId)
168
204
  if (kept !== undefined) return { ...base, source: 'failure', output: kept.output }
169
205
  } else if (row.hash !== '') {
170
- const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
171
- stdout: string
172
- } | null
206
+ // A pass that took a retry: its failed attempts, then its own output.
207
+ const before = keptFailures(db, row.runId).get(taskId)?.output
208
+ const tries = (
209
+ db
210
+ .query(
211
+ 'SELECT attempts FROM runs WHERE run_id = ? AND project = ? AND task = ? ORDER BY id DESC LIMIT 1',
212
+ )
213
+ .get(row.runId, project, task) as { attempts: number | null } | null
214
+ )?.attempts
215
+ const prefix =
216
+ before === undefined || tries === null || tries === undefined
217
+ ? ''
218
+ : `${before}${attemptHeading(tries, tries, 'passed')}`
219
+ // The entry is the key's, not the run's: a `--force` run kept the one it
220
+ // found, and a key evicted and saved again, or saved by two checkouts
221
+ // of one store at once, holds another run's output. The entry names
222
+ // the run that wrote it and when, so the log is this run's only when
223
+ // this run wrote it, or this run hit it and nothing wrote it since;
224
+ // otherwise the log reads as not kept. Keeping every run's log would
225
+ // hold a copy per run of what the entry already holds once.
226
+ const entry = db
227
+ .query('SELECT run_id AS writer, created_at AS writtenAt FROM entries WHERE hash = ?')
228
+ .get(row.hash) as { writer: string | null; writtenAt: number } | null
229
+ const own =
230
+ entry !== null &&
231
+ (entry.writer === row.runId || (row.hit === 1 && entry.writtenAt < row.startedAt))
232
+ const stored = own
233
+ ? (db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
234
+ stdout: Uint8Array
235
+ } | null)
236
+ : null
173
237
  if (stored !== null) {
174
238
  // An entry saved before masking, or by another machine, can hold a
175
239
  // secret-named variable's value: masked as a hit's replay masks it.
176
240
  // This process's env only: `vx last` loads no config, so a task's
177
241
  // `env.define` / `env.secret` values are not known here.
178
242
  const output = maskedLine(
179
- Bun.stripANSI(
180
- decodeOutputLog(stored.stdout)
243
+ stripEscapes(
244
+ decodeOutputLog(storedLog(stored.stdout))
181
245
  .map((c) => c.text)
182
246
  .join(''),
183
247
  ),
184
248
  )
185
- return { ...base, source: 'cache', output }
249
+ return { ...base, source: 'cache', output: prefix + output }
186
250
  }
187
251
  // An entry that printed nothing stores no stdout row: kept, and empty.
188
- if (db.query('SELECT 1 FROM entries WHERE hash = ?').get(row.hash) !== null) {
189
- return { ...base, source: 'cache', output: '' }
252
+ if (own) {
253
+ return { ...base, source: 'cache', output: prefix }
190
254
  }
191
255
  }
256
+ // A pass with no entry that took a retry still kept its failed attempts,
257
+ // then says the last passed: without it the log read as a failure.
258
+ const kept = row.status === 'failed' ? undefined : keptFailures(db, row.runId).get(taskId)
259
+ if (kept !== undefined) {
260
+ const tries = (
261
+ db
262
+ .query(
263
+ 'SELECT attempts FROM runs WHERE run_id = ? AND project = ? AND task = ? ORDER BY id DESC LIMIT 1',
264
+ )
265
+ .get(row.runId, project, task) as { attempts: number | null } | null
266
+ )?.attempts
267
+ const passed = typeof tries === 'number' ? attemptHeading(tries, tries, 'passed') : ''
268
+ return { ...base, source: 'failure', output: kept.output + passed }
269
+ }
192
270
  return { ...base, source: null, output: '' }
193
271
  }
@@ -8,15 +8,26 @@ import { UserError } from '../util/index.js'
8
8
  * The recorded run `raw` names: itself when recorded, else the one run
9
9
  * whose id starts with it. Null when none does; a prefix several runs share
10
10
  * is refused with those runs, since picking one would replay the wrong run.
11
+ * A run a stop ended keeps task rows but no header (item 854), and `vx why`
12
+ * and `vx last --log` print its id, so its rows name it too.
11
13
  */
12
14
  export function resolveRunId(db: Database, raw: string, verb: string): string | null {
13
15
  // Run ids are lower-case UUIDs; one typed or pasted in upper case is the same id.
14
16
  const id = raw.toLowerCase()
15
- const exact = db.query('SELECT 1 FROM invocations WHERE run_id = ?').get(id)
17
+ const exact = db
18
+ .query(
19
+ 'SELECT 1 FROM invocations WHERE run_id = ?1 UNION ALL SELECT 1 FROM run_ids WHERE run_id = ?1 LIMIT 1',
20
+ )
21
+ .get(id)
16
22
  if (exact !== null) return id
23
+ // A range on `run_ids` (every run with rows), so its index answers:
24
+ // `substr` read every row.
25
+ // `~` sorts after every character of an id.
17
26
  const rows = db
18
27
  .query(
19
- 'SELECT run_id FROM invocations WHERE substr(run_id, 1, ?) = ? ORDER BY run_id DESC LIMIT 6',
28
+ `SELECT run_id FROM invocations WHERE substr(run_id, 1, ?1) = ?2
29
+ UNION SELECT run_id FROM run_ids WHERE run_id >= ?2 AND run_id < ?2 || '~'
30
+ ORDER BY run_id DESC LIMIT 6`,
20
31
  )
21
32
  .all(id.length, id) as Array<{ run_id: string }>
22
33
  if (rows.length === 0) return null