@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
@@ -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
+ }
@@ -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. */
@@ -74,7 +74,7 @@ export function writeRunFailures(
74
74
  }
75
75
 
76
76
  const plain = (log: string): string =>
77
- Bun.stripANSI(
77
+ stripEscapes(
78
78
  decodeOutputLog(log)
79
79
  .map((c) => c.text)
80
80
  .join(''),
@@ -166,8 +166,9 @@ export interface TaskLog {
166
166
  status: string
167
167
  /**
168
168
  * Where the output came from: the run's kept failure output, the cache
169
- * entry the task saved or hit (masked as it was replayed), or null when
170
- * 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).
171
172
  */
172
173
  source: 'failure' | 'cache' | null
173
174
  /** Plain text; '' when `source` is null. The files it names: `runFailures`. */
@@ -183,7 +184,7 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
183
184
  if (project === '' || task === '') return null
184
185
  const row = db
185
186
  .query(
186
- `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
187
188
  WHERE project = ? AND task = ? AND run_id IS NOT NULL ${runId === undefined ? '' : 'AND run_id = ?'}
188
189
  ORDER BY id DESC LIMIT 1`,
189
190
  )
@@ -191,6 +192,8 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
191
192
  runId: string
192
193
  hash: string
193
194
  status: string
195
+ hit: number | null
196
+ startedAt: number
194
197
  } | null
195
198
  if (row === null) return null
196
199
  const base = { runId: row.runId, taskId, status: row.status }
@@ -213,17 +216,32 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
213
216
  before === undefined || tries === null || tries === undefined
214
217
  ? ''
215
218
  : `${before}${attemptHeading(tries, tries, 'passed')}`
216
- const stored = db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(row.hash) as {
217
- stdout: string
218
- } | null
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
219
237
  if (stored !== null) {
220
238
  // An entry saved before masking, or by another machine, can hold a
221
239
  // secret-named variable's value: masked as a hit's replay masks it.
222
240
  // This process's env only: `vx last` loads no config, so a task's
223
241
  // `env.define` / `env.secret` values are not known here.
224
242
  const output = maskedLine(
225
- Bun.stripANSI(
226
- decodeOutputLog(stored.stdout)
243
+ stripEscapes(
244
+ decodeOutputLog(storedLog(stored.stdout))
227
245
  .map((c) => c.text)
228
246
  .join(''),
229
247
  ),
@@ -231,12 +249,23 @@ export function taskLog(db: Database, taskId: string, runId?: string): TaskLog |
231
249
  return { ...base, source: 'cache', output: prefix + output }
232
250
  }
233
251
  // An entry that printed nothing stores no stdout row: kept, and empty.
234
- if (db.query('SELECT 1 FROM entries WHERE hash = ?').get(row.hash) !== null) {
252
+ if (own) {
235
253
  return { ...base, source: 'cache', output: prefix }
236
254
  }
237
255
  }
238
- // A pass with no entry that took a retry still kept its failed attempts.
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.
239
258
  const kept = row.status === 'failed' ? undefined : keptFailures(db, row.runId).get(taskId)
240
- if (kept !== undefined) return { ...base, source: 'failure', output: kept.output }
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
+ }
241
270
  return { ...base, source: null, output: '' }
242
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