@vzn/vx 0.0.649 → 0.0.651
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.
- package/README.md +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +225 -51
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/fold-memo.ts +8 -1
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +12 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +120 -0
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-hits.ts +9 -1
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/run-notes.ts +123 -0
- package/src/cache/schema.ts +54 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +224 -223
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +10 -3
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +126 -92
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +12 -2
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +39 -2
- package/src/history/index.ts +3 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +12 -3
- package/src/orchestrator/doctor.ts +3 -3
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/hit-restore.ts +3 -0
- package/src/orchestrator/index.ts +3 -1
- package/src/orchestrator/live-usage.ts +98 -0
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +176 -102
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +49 -4
- package/src/orchestrator/prepare.ts +18 -2
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +35 -114
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +89 -26
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/forecast.ts +12 -33
- package/src/report/framed-output.ts +388 -161
- package/src/report/index.ts +8 -2
- package/src/report/logger.ts +100 -36
- package/src/report/status-line.ts +81 -365
- package/src/report/summary.ts +104 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/hidden-prompt.ts +8 -2
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/secrets.ts +3 -0
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/orchestrator/run-steps.ts +0 -93
- package/src/util/colors.ts +0 -130
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// .vx/dependencies.lock.json — what the workspace's dependencies resolve
|
|
2
|
+
// to, written by `vx lock` beside the config lock and committed.
|
|
3
|
+
//
|
|
4
|
+
// Each project's edges (the `discover` stage's `deps` and `reach`), by
|
|
5
|
+
// directory, and each claimed file's per-project digests
|
|
6
|
+
// (`FingerprintClaim.digests`) with the hash of the bytes they came from
|
|
7
|
+
// and of each other file the digests read. A run keys from these digests
|
|
8
|
+
// and never parses the file: one whose bytes moved since refuses
|
|
9
|
+
// (orchestrator/locked-digests.ts). `--affected` reads the base commit's
|
|
10
|
+
// copy with git: a deleted project's dependants are in its edges, and a
|
|
11
|
+
// changed lockfile's base digests are there, so the base side is never
|
|
12
|
+
// parsed. `vx lock --check` holds it to a fresh derivation.
|
|
13
|
+
|
|
14
|
+
import { rename, rm } from 'node:fs/promises'
|
|
15
|
+
import path from 'node:path'
|
|
16
|
+
import type { ProjectMeta } from './workspace.js'
|
|
17
|
+
import { lockKey } from './lockfile.js'
|
|
18
|
+
import { type LoadReads, readOnce } from './load-reads.js'
|
|
19
|
+
|
|
20
|
+
export const DEPENDENCIES_LOCK_NAME = '.vx/dependencies.lock.json'
|
|
21
|
+
const DEPENDENCIES_LOCK_VERSION = 3
|
|
22
|
+
|
|
23
|
+
// Flat: each directory once, everything else an index into `projects`, so
|
|
24
|
+
// a parse builds few strings and a lookup is an array read.
|
|
25
|
+
export interface DependenciesLock {
|
|
26
|
+
version: number
|
|
27
|
+
/** Project directories (root-relative POSIX, `.` for the root), sorted. */
|
|
28
|
+
projects: string[]
|
|
29
|
+
/** Per project, the indexes of the projects it builds after. */
|
|
30
|
+
deps: number[][]
|
|
31
|
+
/** Per project, the indexes of the projects it reaches for selection only. */
|
|
32
|
+
reach: number[][]
|
|
33
|
+
/** Claimed file → its bytes' xxh3, each project's digest by index ('' when none), and each other file read → its hash. */
|
|
34
|
+
files: Record<string, LockedFile>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface LockedFile {
|
|
38
|
+
hash: string
|
|
39
|
+
digests: string[]
|
|
40
|
+
/** Root-relative files besides this one whose bytes the digests read → their hash (`hashExtra`). */
|
|
41
|
+
extras?: Record<string, string>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The lock for `metas`, with `files` keyed by project name as the claims answer. */
|
|
45
|
+
export function dependenciesLock(
|
|
46
|
+
root: string,
|
|
47
|
+
metas: readonly ProjectMeta[],
|
|
48
|
+
files: ReadonlyMap<
|
|
49
|
+
string,
|
|
50
|
+
{
|
|
51
|
+
hash: string
|
|
52
|
+
projects: Readonly<Record<string, string>>
|
|
53
|
+
extras?: Readonly<Record<string, string>>
|
|
54
|
+
}
|
|
55
|
+
>,
|
|
56
|
+
): DependenciesLock {
|
|
57
|
+
const dirOf = new Map(metas.map((m) => [m.name, lockKey(root, m.dir)]))
|
|
58
|
+
const projects = [...new Set(dirOf.values())].sort(cmp)
|
|
59
|
+
const at = new Map(projects.map((d, i) => [d, i]))
|
|
60
|
+
const byDir = new Map(metas.map((m) => [dirOf.get(m.name)!, m]))
|
|
61
|
+
const indexes = (names: readonly string[] | undefined): number[] =>
|
|
62
|
+
[...new Set((names ?? []).flatMap((n) => at.get(dirOf.get(n) ?? '') ?? []))].sort(
|
|
63
|
+
(a, b) => a - b,
|
|
64
|
+
)
|
|
65
|
+
const out: DependenciesLock['files'] = {}
|
|
66
|
+
for (const file of [...files.keys()].sort(cmp)) {
|
|
67
|
+
const { hash, projects: byName, extras } = files.get(file)!
|
|
68
|
+
const digests = projects.map(() => '')
|
|
69
|
+
for (const [name, digest] of Object.entries(byName)) {
|
|
70
|
+
const i = at.get(dirOf.get(name) ?? '')
|
|
71
|
+
if (i !== undefined) digests[i] = digest
|
|
72
|
+
}
|
|
73
|
+
const rels = Object.keys(extras ?? {}).sort(cmp)
|
|
74
|
+
out[file] =
|
|
75
|
+
rels.length === 0
|
|
76
|
+
? { hash, digests }
|
|
77
|
+
: { hash, digests, extras: Object.fromEntries(rels.map((r) => [r, extras![r]!])) }
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
version: DEPENDENCIES_LOCK_VERSION,
|
|
81
|
+
projects,
|
|
82
|
+
deps: projects.map((d) => indexes(byDir.get(d)!.deps)),
|
|
83
|
+
reach: projects.map((d) => indexes(byDir.get(d)!.reach)),
|
|
84
|
+
files: out,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Minified, as the config lock: only vx reads it, and minified JSON parses faster. */
|
|
89
|
+
function dependenciesLockText(lock: DependenciesLock): string {
|
|
90
|
+
return `${JSON.stringify(lock)}\n`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Written beside its name and renamed over it, as `writeLockfile`: a reader never sees half a file. */
|
|
94
|
+
export async function writeDependenciesLock(root: string, lock: DependenciesLock): Promise<void> {
|
|
95
|
+
const file = path.join(root, DEPENDENCIES_LOCK_NAME)
|
|
96
|
+
const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
|
|
97
|
+
try {
|
|
98
|
+
await Bun.write(tmp, dependenciesLockText(lock))
|
|
99
|
+
await rename(tmp, file)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
102
|
+
const e = err as NodeJS.ErrnoException
|
|
103
|
+
if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
|
|
104
|
+
if (e.path === tmp) e.path = file
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The lock's bytes at `root`, read once per `reads`; null when there is none. */
|
|
110
|
+
export function readDependenciesLock(root: string, reads?: LoadReads): Promise<Uint8Array | null> {
|
|
111
|
+
return readOnce(reads, path.join(root, DEPENDENCIES_LOCK_NAME))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The lock in `bytes`; null when absent, another version, or not this shape. */
|
|
115
|
+
export function parseDependenciesLock(bytes: Uint8Array | null): DependenciesLock | null {
|
|
116
|
+
if (bytes === null) return null
|
|
117
|
+
try {
|
|
118
|
+
const lock = JSON.parse(new TextDecoder().decode(bytes)) as Partial<DependenciesLock>
|
|
119
|
+
return lock.version === DEPENDENCIES_LOCK_VERSION && wellFormed(lock)
|
|
120
|
+
? (lock as DependenciesLock)
|
|
121
|
+
: null
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A committed file a merge or a hand edit can break inside, so every
|
|
128
|
+
// entry is checked: one bad entry read on would throw a raw TypeError at
|
|
129
|
+
// its first lookup instead of the "invalid, run `vx lock`" refusal.
|
|
130
|
+
function wellFormed(lock: Partial<DependenciesLock>): boolean {
|
|
131
|
+
const { projects, deps, reach, files } = lock
|
|
132
|
+
if (!Array.isArray(projects) || !projects.every((d) => typeof d === 'string')) return false
|
|
133
|
+
const n = projects.length
|
|
134
|
+
const edges = (lists: unknown): boolean =>
|
|
135
|
+
Array.isArray(lists) &&
|
|
136
|
+
lists.length === n &&
|
|
137
|
+
lists.every((l) => Array.isArray(l) && l.every((i) => Number.isInteger(i) && i >= 0 && i < n))
|
|
138
|
+
if (!edges(deps) || !edges(reach) || !isRecord(files)) return false
|
|
139
|
+
return Object.values(files).every(
|
|
140
|
+
(rec: unknown) =>
|
|
141
|
+
isRecord(rec) &&
|
|
142
|
+
typeof rec['hash'] === 'string' &&
|
|
143
|
+
Array.isArray(rec['digests']) &&
|
|
144
|
+
rec['digests'].length === n &&
|
|
145
|
+
rec['digests'].every((d) => typeof d === 'string') &&
|
|
146
|
+
(rec['extras'] === undefined ||
|
|
147
|
+
(isRecord(rec['extras']) &&
|
|
148
|
+
Object.values(rec['extras']).every((h) => typeof h === 'string'))),
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isRecord(v: unknown): v is Record<string, unknown> {
|
|
153
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The project directories whose edges reach `dir`. */
|
|
157
|
+
export function dependantsOf(lock: DependenciesLock, dir: string): string[] {
|
|
158
|
+
const i = lock.projects.indexOf(dir)
|
|
159
|
+
if (i < 0) return []
|
|
160
|
+
return lock.projects.filter((_, j) => lock.deps[j]!.includes(i) || lock.reach[j]!.includes(i))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `file`'s recorded hashes and each project directory's digest (undefined when none). */
|
|
164
|
+
export function recordedDigests(
|
|
165
|
+
lock: DependenciesLock,
|
|
166
|
+
file: string,
|
|
167
|
+
):
|
|
168
|
+
| {
|
|
169
|
+
hash: string
|
|
170
|
+
extras: Readonly<Record<string, string>>
|
|
171
|
+
has: (dir: string) => boolean
|
|
172
|
+
of: (dir: string) => string | undefined
|
|
173
|
+
}
|
|
174
|
+
| undefined {
|
|
175
|
+
const rec = Object.hasOwn(lock.files, file) ? lock.files[file] : undefined
|
|
176
|
+
if (rec === undefined) return undefined
|
|
177
|
+
const at = new Map(lock.projects.map((d, i) => [d, i]))
|
|
178
|
+
return {
|
|
179
|
+
hash: rec.hash,
|
|
180
|
+
extras: rec.extras ?? {},
|
|
181
|
+
has: (dir) => at.has(dir),
|
|
182
|
+
of: (dir) => {
|
|
183
|
+
const i = at.get(dir)
|
|
184
|
+
return i === undefined || rec.digests[i] === '' ? undefined : rec.digests[i]
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cmp(a: string, b: string): number {
|
|
190
|
+
return a < b ? -1 : a > b ? 1 : 0
|
|
191
|
+
}
|
package/src/workspace/filter.ts
CHANGED
|
@@ -66,6 +66,8 @@ export interface ParsedFilter {
|
|
|
66
66
|
* `./apps/*` when it names no project, as Nx's `--projects` reads it.
|
|
67
67
|
*/
|
|
68
68
|
dirFallback?: ParsedFilter
|
|
69
|
+
/** `<pattern>{<dir>}`: the `{<dir>}` path form the name matches are narrowed to. */
|
|
70
|
+
within?: ParsedFilter
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export function parseFilter(
|
|
@@ -175,6 +177,27 @@ export function parseFilter(
|
|
|
175
177
|
}
|
|
176
178
|
}
|
|
177
179
|
|
|
180
|
+
// `<pattern>{<dir>}`: the packages the name selects in the directory, as
|
|
181
|
+
// pnpm and Turbo read it (turborepo#887, `pattern{foo}[master]`). Read as
|
|
182
|
+
// one name glob, it matched nothing.
|
|
183
|
+
const named = /^([^{}]+)\{([^{}]*)\}$/.exec(s)
|
|
184
|
+
if (named !== null && !named[1]!.startsWith('./') && named[1] !== '.') {
|
|
185
|
+
if (named[2]!.trim() === '') throw new UserError(`filter "${raw}" names no project`)
|
|
186
|
+
return {
|
|
187
|
+
raw,
|
|
188
|
+
negate,
|
|
189
|
+
withDeps,
|
|
190
|
+
withDependents,
|
|
191
|
+
onlyDeps,
|
|
192
|
+
onlyDependents,
|
|
193
|
+
isPath: false,
|
|
194
|
+
matcher: named[1]!,
|
|
195
|
+
within: parseFilter(`{${named[2]}}`, workspaceRoot, raw),
|
|
196
|
+
...(gitSince !== undefined ? { gitSince } : {}),
|
|
197
|
+
...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
178
201
|
let isPath = false
|
|
179
202
|
let matcher = s
|
|
180
203
|
let pathGlob: Bun.Glob | undefined
|
|
@@ -266,6 +289,11 @@ function matchSelector(
|
|
|
266
289
|
projects: ProjectMeta[],
|
|
267
290
|
tags: ProjectTags | undefined,
|
|
268
291
|
): string[] {
|
|
292
|
+
if (filter.within !== undefined) {
|
|
293
|
+
const { within, ...name } = filter
|
|
294
|
+
const inDir = new Set(matchSelector(within, projects, tags))
|
|
295
|
+
return matchSelector(name, projects, tags).filter((n) => inDir.has(n))
|
|
296
|
+
}
|
|
269
297
|
const out: string[] = []
|
|
270
298
|
if (filter.tag === true) {
|
|
271
299
|
const re = compileNameGlob(filter.matcher)
|
package/src/workspace/index.ts
CHANGED
|
@@ -32,6 +32,15 @@ export {
|
|
|
32
32
|
type Lockfile,
|
|
33
33
|
type LockedProject,
|
|
34
34
|
} from './lockfile.js'
|
|
35
|
+
export {
|
|
36
|
+
DEPENDENCIES_LOCK_NAME,
|
|
37
|
+
dependenciesLock,
|
|
38
|
+
parseDependenciesLock,
|
|
39
|
+
readDependenciesLock,
|
|
40
|
+
recordedDigests,
|
|
41
|
+
writeDependenciesLock,
|
|
42
|
+
type DependenciesLock,
|
|
43
|
+
} from './dependencies-lock.js'
|
|
35
44
|
export {
|
|
36
45
|
ConfigsRefused,
|
|
37
46
|
loadProjectConfig,
|
|
@@ -21,7 +21,7 @@ import { validateProjectConfig } from './config-schema.js'
|
|
|
21
21
|
import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
|
|
22
22
|
|
|
23
23
|
export const LOCKFILE_NAME = '.vx/projects.lock.json'
|
|
24
|
-
export const LOCKFILE_VERSION =
|
|
24
|
+
export const LOCKFILE_VERSION = 5
|
|
25
25
|
|
|
26
26
|
/** One project as the lock holds it. */
|
|
27
27
|
export interface LockedProject {
|
|
@@ -55,14 +55,19 @@ export const LOCK_INVALID = `${LOCKFILE_NAME} is invalid, run \`vx lock\``
|
|
|
55
55
|
* raw TypeError; asked only on that crash's path, so a run pays nothing.
|
|
56
56
|
*/
|
|
57
57
|
export function lockEntryBroken(root: string): boolean {
|
|
58
|
-
let
|
|
58
|
+
let lock: Pooled
|
|
59
59
|
try {
|
|
60
|
-
|
|
61
|
-
.projects
|
|
60
|
+
lock = JSON.parse(readFileSync(lockfilePath(root), 'utf8')) as Pooled
|
|
62
61
|
} catch {
|
|
63
62
|
return false
|
|
64
63
|
}
|
|
65
|
-
if (typeof projects !== 'object' || projects === null) return false
|
|
64
|
+
if (typeof lock?.projects !== 'object' || lock.projects === null) return false
|
|
65
|
+
try {
|
|
66
|
+
pooledIn(lock)
|
|
67
|
+
} catch {
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
const projects = lock.projects
|
|
66
71
|
for (const [dir, entry] of Object.entries(projects)) {
|
|
67
72
|
try {
|
|
68
73
|
validateProjectConfig((entry as LockedProject).config, `${LOCKFILE_NAME}: "${dir}"`, null)
|
|
@@ -158,10 +163,157 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
158
163
|
)
|
|
159
164
|
}
|
|
160
165
|
}
|
|
166
|
+
pooledIn(parsed as Pooled)
|
|
161
167
|
digests.set(parsed as Lockfile, xxh3hex(text))
|
|
162
168
|
return parsed as Lockfile
|
|
163
169
|
}
|
|
164
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The project entries of a lock's bytes with its pool put back, or null
|
|
173
|
+
* for bytes that do not parse as a lock: an entry compares across two
|
|
174
|
+
* locks only once both are read the same way.
|
|
175
|
+
*/
|
|
176
|
+
export function lockEntries(bytes: Uint8Array | null): Record<string, unknown> | null {
|
|
177
|
+
if (bytes === null) return null
|
|
178
|
+
try {
|
|
179
|
+
const lock = JSON.parse(new TextDecoder().decode(bytes)) as Pooled
|
|
180
|
+
if (typeof lock?.projects !== 'object' || lock.projects === null) return null
|
|
181
|
+
pooledIn(lock)
|
|
182
|
+
return lock.projects
|
|
183
|
+
} catch {
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* A task or task part that two places in the lock hold alike is written once,
|
|
190
|
+
* in `pool`, and each place holds its index. Each slot is one the schema
|
|
191
|
+
* types as an object, so a number there can only be an index. Big
|
|
192
|
+
* workspaces repeat the same tasks: 2,000 projects of five tasks read in
|
|
193
|
+
* 0.71 ms against 2.62 ms, a fifth of the bytes. A run reads them shared,
|
|
194
|
+
* once; a stage that edits a config in place copies it first.
|
|
195
|
+
*/
|
|
196
|
+
const POOLS = ['env', 'sandbox', 'exec', 'inputs', 'cache', 'task'] as const
|
|
197
|
+
type PoolName = (typeof POOLS)[number]
|
|
198
|
+
type Part = Exclude<PoolName, 'task'>
|
|
199
|
+
type Pools = Partial<Record<PoolName, unknown[]>>
|
|
200
|
+
type Pooled = { pool?: Pools; projects: Record<string, LockedProject> }
|
|
201
|
+
type Obj = Record<string, unknown>
|
|
202
|
+
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
|
|
203
|
+
const TASK_PARTS = ['exec', 'inputs', 'cache'] as const
|
|
204
|
+
const EXEC_PARTS = ['env', 'sandbox'] as const
|
|
205
|
+
|
|
206
|
+
/** `lock.projects` with every index replaced by the value it names, in place. */
|
|
207
|
+
function pooledIn(lock: Pooled): void {
|
|
208
|
+
const pool = lock.pool
|
|
209
|
+
delete lock.pool
|
|
210
|
+
if (pool === undefined) return
|
|
211
|
+
if (!isObj(pool) || Object.values(pool).some((list) => !Array.isArray(list))) {
|
|
212
|
+
throw new UserError(
|
|
213
|
+
`${LOCKFILE_NAME}: \`pool\` must be an object of lists — re-run \`vx lock\``,
|
|
214
|
+
'VX_E_LOCK',
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
const at = (name: PoolName, i: number): unknown => {
|
|
218
|
+
const hit = pool[name]?.[i]
|
|
219
|
+
if (hit === undefined) {
|
|
220
|
+
throw new UserError(
|
|
221
|
+
`${LOCKFILE_NAME}: no ${name} ${i} in its pool — re-run \`vx lock\``,
|
|
222
|
+
'VX_E_LOCK',
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
return hit
|
|
226
|
+
}
|
|
227
|
+
const parts = (o: unknown, names: readonly Part[]): void => {
|
|
228
|
+
if (!isObj(o)) return
|
|
229
|
+
for (const n of names) if (typeof o[n] === 'number') o[n] = at(n, o[n])
|
|
230
|
+
}
|
|
231
|
+
const task = (t: unknown): void => {
|
|
232
|
+
parts(t, TASK_PARTS)
|
|
233
|
+
if (isObj(t)) parts(t['exec'], EXEC_PARTS)
|
|
234
|
+
}
|
|
235
|
+
// Inner pools first: a pooled task's exec may itself be pooled.
|
|
236
|
+
for (const e of pool.exec ?? []) parts(e, EXEC_PARTS)
|
|
237
|
+
for (const t of pool.task ?? []) task(t)
|
|
238
|
+
for (const entry of Object.values(lock.projects)) {
|
|
239
|
+
const tasks = (entry as { config?: { tasks?: unknown } } | null)?.config?.tasks
|
|
240
|
+
if (!isObj(tasks)) continue
|
|
241
|
+
for (const name of Object.keys(tasks)) {
|
|
242
|
+
const t = tasks[name]
|
|
243
|
+
if (typeof t === 'number') tasks[name] = at('task', t)
|
|
244
|
+
else task(t)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `projects` as written: each value two slots hold alike, once in `pool`. */
|
|
250
|
+
function pooledOut(projects: Record<string, LockedProject>): Pooled {
|
|
251
|
+
const seen = new Map<string, number>()
|
|
252
|
+
const keyOf = (name: PoolName, v: Obj): string => `${name}\0${JSON.stringify(v)}`
|
|
253
|
+
const count = (name: PoolName, v: unknown): void => {
|
|
254
|
+
if (!isObj(v)) return
|
|
255
|
+
const k = keyOf(name, v)
|
|
256
|
+
seen.set(k, (seen.get(k) ?? 0) + 1)
|
|
257
|
+
}
|
|
258
|
+
const tasksOf = (p: LockedProject): Obj | undefined =>
|
|
259
|
+
isObj(p.config.tasks) ? p.config.tasks : undefined
|
|
260
|
+
for (const p of Object.values(projects)) {
|
|
261
|
+
for (const t of Object.values(tasksOf(p) ?? {})) {
|
|
262
|
+
if (!isObj(t)) continue
|
|
263
|
+
count('task', t)
|
|
264
|
+
for (const n of TASK_PARTS) count(n, t[n])
|
|
265
|
+
if (isObj(t['exec'])) for (const n of EXEC_PARTS) count(n, t['exec'][n])
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const pool: Pools = {}
|
|
269
|
+
const index = new Map<string, number>()
|
|
270
|
+
const out = (name: PoolName, v: unknown, inner: (v: Obj) => Obj): unknown => {
|
|
271
|
+
if (!isObj(v)) return v
|
|
272
|
+
const k = keyOf(name, v)
|
|
273
|
+
if ((seen.get(k) ?? 0) < 2) return inner(v)
|
|
274
|
+
let i = index.get(k)
|
|
275
|
+
if (i === undefined) {
|
|
276
|
+
const list = (pool[name] ??= [])
|
|
277
|
+
index.set(k, (i = list.length))
|
|
278
|
+
list.push(inner(v))
|
|
279
|
+
}
|
|
280
|
+
return i
|
|
281
|
+
}
|
|
282
|
+
const same = (v: Obj): Obj => v
|
|
283
|
+
const withParts = (o: Obj, names: readonly Part[]): Obj => {
|
|
284
|
+
const r = { ...o }
|
|
285
|
+
for (const n of names) if (n in o) r[n] = out(n, o[n], n === 'exec' ? exec : same)
|
|
286
|
+
return r
|
|
287
|
+
}
|
|
288
|
+
const exec = (e: Obj): Obj => withParts(e, EXEC_PARTS)
|
|
289
|
+
const task = (t: Obj): Obj => withParts(t, TASK_PARTS)
|
|
290
|
+
// Entries, not assignment: a directory named `__proto__` set the prototype (D-152).
|
|
291
|
+
const written = Object.fromEntries(
|
|
292
|
+
Object.entries(projects).map(([dir, p]) => {
|
|
293
|
+
const tasks = tasksOf(p)
|
|
294
|
+
return [
|
|
295
|
+
dir,
|
|
296
|
+
tasks === undefined
|
|
297
|
+
? p
|
|
298
|
+
: {
|
|
299
|
+
...p,
|
|
300
|
+
config: {
|
|
301
|
+
...p.config,
|
|
302
|
+
tasks: Object.fromEntries(
|
|
303
|
+
Object.entries(tasks).map(([name, t]) => [name, out('task', t, task)]),
|
|
304
|
+
),
|
|
305
|
+
} as ProjectConfig,
|
|
306
|
+
},
|
|
307
|
+
]
|
|
308
|
+
}),
|
|
309
|
+
)
|
|
310
|
+
const ordered: Pools = {}
|
|
311
|
+
for (const n of POOLS) if (pool[n] !== undefined) ordered[n] = pool[n]
|
|
312
|
+
return POOLS.some((n) => pool[n] !== undefined)
|
|
313
|
+
? { pool: ordered, projects: written }
|
|
314
|
+
: { projects: written }
|
|
315
|
+
}
|
|
316
|
+
|
|
165
317
|
/**
|
|
166
318
|
* `vx lock` evaluates and validates every config, then writes a digest of
|
|
167
319
|
* the bytes it wrote, under vx's version, as the last field: the lock's
|
|
@@ -194,7 +346,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
|
|
|
194
346
|
// Minified: the file only vx reads, and it loads faster than pretty
|
|
195
347
|
// JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
|
|
196
348
|
// beside each config is the copy for people.
|
|
197
|
-
const
|
|
349
|
+
const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
|
|
350
|
+
const body = JSON.stringify({ version: lock.version, ...pooledOut(sorted) })
|
|
198
351
|
await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
|
|
199
352
|
await rename(tmp, file)
|
|
200
353
|
} catch (err) {
|
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
refuseSymlink,
|
|
17
17
|
relPosix,
|
|
18
18
|
UserError,
|
|
19
|
-
ui,
|
|
20
19
|
writeNoFollow,
|
|
21
20
|
} from '../util/index.js'
|
|
21
|
+
import * as ui from '../ui/index.js'
|
|
22
22
|
import { VERSION } from '../version.js'
|
|
23
23
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
24
24
|
|
|
@@ -451,7 +451,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
451
451
|
...plan.notes,
|
|
452
452
|
]
|
|
453
453
|
if (args.json === true) {
|
|
454
|
-
|
|
454
|
+
ui.data(
|
|
455
|
+
'out',
|
|
455
456
|
`${JSON.stringify({
|
|
456
457
|
dry,
|
|
457
458
|
source,
|
|
@@ -472,7 +473,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
|
|
|
472
473
|
|
|
473
474
|
// The lock's own words become a row; a refusal prints above the report.
|
|
474
475
|
const lock = dry || args.lock === undefined ? undefined : lockAnswer(await args.lock(root))
|
|
475
|
-
if (lock !== undefined && lock.code !== 0)
|
|
476
|
+
if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
|
|
476
477
|
|
|
477
478
|
const rows: ui.FieldRow[] = [['from', source]]
|
|
478
479
|
if (dry) rows.push(['dry run', 'nothing written'])
|
|
@@ -159,6 +159,9 @@ export async function resolveStoreRoot(
|
|
|
159
159
|
let id = repoIds.get(root)
|
|
160
160
|
if (id === undefined) repoIds.set(root, (id = repoIdOf(root)))
|
|
161
161
|
const resolved = await id
|
|
162
|
+
// No identity yet (no commit, no remote) is asked again: the first
|
|
163
|
+
// commit gives one, and `vx watch` runs on in the same process.
|
|
164
|
+
if (resolved === null) repoIds.delete(root)
|
|
162
165
|
return resolved === null ? null : path.join(home, '.vx', resolved, 'cache')
|
|
163
166
|
}
|
|
164
167
|
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// What a run said that its rows do not hold, for `vx last`: the steps it
|
|
2
|
-
// did not wait on (`vx:prefetch`, M 2026-10-16), which never show in a
|
|
3
|
-
// run's output, and its warnings, which scroll past on a terminal and are
|
|
4
|
-
// gone (an agent reading the run afterwards saw only the footer's count,
|
|
5
|
-
// 2026-10-28). One file per run that had any, `<cacheDir>/steps/<runId>.json`
|
|
6
|
-
// and `<cacheDir>/warnings/<runId>.json`, the newest 50 of each kept, as
|
|
7
|
-
// run-failures.ts keeps failed output: files, not cache.db, so the index
|
|
8
|
-
// layout stays as it was. A run with none writes nothing.
|
|
9
|
-
|
|
10
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
|
-
import path from 'node:path'
|
|
12
|
-
import { isFsRefusal } from '../util/index.js'
|
|
13
|
-
import { trimKept } from './run-failures.js'
|
|
14
|
-
|
|
15
|
-
/** Runs whose steps (or warnings) are kept; run ids are UUIDv7, so names sort by age. */
|
|
16
|
-
const KEPT = 50
|
|
17
|
-
|
|
18
|
-
/** One of vx's own steps that ran beside the tasks. */
|
|
19
|
-
export interface RunStep {
|
|
20
|
-
id: string
|
|
21
|
-
durationMs: number
|
|
22
|
-
ok: boolean
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
type Kept = 'steps' | 'warnings'
|
|
26
|
-
|
|
27
|
-
function keep(
|
|
28
|
-
cacheDir: string,
|
|
29
|
-
kind: Kept,
|
|
30
|
-
runId: string,
|
|
31
|
-
value: readonly unknown[],
|
|
32
|
-
warn: (message: string) => void,
|
|
33
|
-
): void {
|
|
34
|
-
if (value.length === 0) return
|
|
35
|
-
try {
|
|
36
|
-
const dir = path.join(cacheDir, kind)
|
|
37
|
-
mkdirSync(dir, { recursive: true })
|
|
38
|
-
const file = path.join(dir, `${runId}.json`)
|
|
39
|
-
const tmp = `${file}.${process.pid}.tmp`
|
|
40
|
-
writeFileSync(tmp, JSON.stringify(value))
|
|
41
|
-
renameSync(tmp, file)
|
|
42
|
-
trimKept(dir, KEPT)
|
|
43
|
-
} catch (err) {
|
|
44
|
-
if (!isFsRefusal(err)) throw err
|
|
45
|
-
warn(
|
|
46
|
-
`[vx] ${kind === 'steps' ? 'background steps' : 'warnings'} not kept: ${err.message} — the verdict above stands`,
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function kept<T>(cacheDir: string, kind: Kept, runId: string): T[] {
|
|
52
|
-
try {
|
|
53
|
-
return JSON.parse(readFileSync(path.join(cacheDir, kind, `${runId}.json`), 'utf8')) as T[]
|
|
54
|
-
} catch {
|
|
55
|
-
return []
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Keep `steps` for `runId`; a refused write costs only them. */
|
|
60
|
-
export function writeRunSteps(
|
|
61
|
-
cacheDir: string,
|
|
62
|
-
runId: string,
|
|
63
|
-
steps: readonly RunStep[],
|
|
64
|
-
warn: (message: string) => void,
|
|
65
|
-
): void {
|
|
66
|
-
keep(cacheDir, 'steps', runId, steps, warn)
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** The background steps `runId` kept; none when it had none or they are gone. */
|
|
70
|
-
export function runSteps(cacheDir: string, runId: string): RunStep[] {
|
|
71
|
-
return kept<RunStep>(cacheDir, 'steps', runId)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Keep the run's warnings as plain text, colour stripped; a refused write costs only them. */
|
|
75
|
-
export function writeRunWarnings(
|
|
76
|
-
cacheDir: string,
|
|
77
|
-
runId: string,
|
|
78
|
-
warnings: readonly string[],
|
|
79
|
-
warn: (message: string) => void,
|
|
80
|
-
): void {
|
|
81
|
-
keep(
|
|
82
|
-
cacheDir,
|
|
83
|
-
'warnings',
|
|
84
|
-
runId,
|
|
85
|
-
warnings.map((w) => Bun.stripANSI(w)),
|
|
86
|
-
warn,
|
|
87
|
-
)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** The warnings `runId` said; none when it said none or they are gone. */
|
|
91
|
-
export function runWarnings(cacheDir: string, runId: string): string[] {
|
|
92
|
-
return kept<string>(cacheDir, 'warnings', runId)
|
|
93
|
-
}
|