@vzn/vx-dte 0.0.0 → 0.0.649

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/src/trace.ts ADDED
@@ -0,0 +1,288 @@
1
+ // A distributed run in time, for `vx run --profile` (2026-10-23: "a
2
+ // Chrome profile with all workers and tasks they executed in time"): Trace
3
+ // Event Format events, one process per machine.
4
+ // A worker's first row is its life (warm-up, idle, leave); each task gets
5
+ // the first row free over its time there. The orchestrator's rows hold
6
+ // what it asked and when, the tasks it held waiting for room, and its
7
+ // restores. Times are this machine's, so a worker's are when it heard.
8
+
9
+ /** One slice: a span of a row, in `performance.now()` ms. */
10
+ interface Slice {
11
+ readonly track: string
12
+ readonly name: string
13
+ readonly cat: string
14
+ readonly from: number
15
+ readonly to: number
16
+ readonly args?: Record<string, unknown>
17
+ /** Slices inside it, on its row. */
18
+ readonly parts?: readonly { name: string; from: number; to: number }[]
19
+ /** Its first row, not one of its own: a worker's life. */
20
+ readonly life?: true
21
+ }
22
+
23
+ interface Mark {
24
+ readonly track: string
25
+ readonly name: string
26
+ readonly at: number
27
+ readonly args?: Record<string, unknown>
28
+ }
29
+
30
+ /** A task's cores on its worker over its time: the counters. */
31
+ interface Use {
32
+ readonly track: string
33
+ readonly from: number
34
+ readonly to: number
35
+ readonly booked: number
36
+ readonly used: number
37
+ }
38
+
39
+ type Event = Record<string, unknown>
40
+
41
+ export const ORCHESTRATOR = 'orchestrator'
42
+
43
+ export class Trace {
44
+ private readonly slices: Slice[] = []
45
+ private readonly marks: Mark[] = []
46
+ private readonly uses: Use[] = []
47
+ /** Each worker's machine: cores busy, as it reported, task or not. */
48
+ private readonly loads: {
49
+ readonly track: string
50
+ readonly at: number
51
+ readonly cpu: number
52
+ readonly tasks: number | undefined
53
+ }[] = []
54
+ /** Each worker's time online: idle is what its tasks leave of it. */
55
+ private readonly online = new Map<string, { from: number; to?: number }>()
56
+ /** `performance.now()` here when `Date.now()` was `wall`: a worker's own clock to ours. */
57
+ private readonly wall = Date.now() - performance.now()
58
+
59
+ slice(s: Slice): void {
60
+ if (s.to >= s.from) this.slices.push(s)
61
+ }
62
+
63
+ mark(track: string, name: string, args?: Record<string, unknown>, at = performance.now()): void {
64
+ this.marks.push({ track, name, at, ...(args === undefined ? {} : { args }) })
65
+ }
66
+
67
+ use(u: Use): void {
68
+ if (u.to > u.from) this.uses.push(u)
69
+ }
70
+
71
+ /**
72
+ * Worker `track`'s machine had `cpu` cores busy at `at`, its tasks' or
73
+ * not, and its running tasks' process trees `tasks` of them: a Mac
74
+ * whose tasks used 0.65 cores read 2.3 busy and queued a 1.67-core
75
+ * build 65 s (main b141dd5, 2026-10-24).
76
+ */
77
+ load(track: string, cpu: number, at = performance.now(), tasks?: number): void {
78
+ const last = this.loads.findLast((l) => l.track === track)
79
+ if (last?.cpu !== cpu || last.tasks !== tasks) this.loads.push({ track, at, cpu, tasks })
80
+ }
81
+
82
+ /**
83
+ * Worker `track` joined at `at`, asked for at `askedAt` when this run
84
+ * started it; `booted` is the wall-clock time its `vx` started, which
85
+ * splits its warm-up into its machine's start (a CI queue, a checkout,
86
+ * an install) and vx's own boot.
87
+ */
88
+ joined(track: string, at: number, askedAt?: number, booted?: number): void {
89
+ this.online.set(track, { from: at })
90
+ if (askedAt === undefined) return
91
+ const boot = booted === undefined ? undefined : booted - this.wall
92
+ const split = boot !== undefined && boot > askedAt && boot < at
93
+ this.slice({
94
+ track,
95
+ name: 'warm-up',
96
+ cat: 'worker',
97
+ from: askedAt,
98
+ to: at,
99
+ life: true,
100
+ ...(split
101
+ ? {
102
+ parts: [
103
+ { name: 'machine start', from: askedAt, to: boot },
104
+ { name: 'vx boot + join', from: boot, to: at },
105
+ ],
106
+ }
107
+ : {}),
108
+ })
109
+ }
110
+
111
+ /** Worker `track` left (`how`: left, lost, stopped) at `at`. */
112
+ left(track: string, how: string, at = performance.now()): void {
113
+ const o = this.online.get(track)
114
+ if (o === undefined || o.to !== undefined) return
115
+ o.to = at
116
+ this.mark(track, how, undefined, at)
117
+ }
118
+
119
+ /** A worker asked for that never joined, from its ask to `at`. */
120
+ unstarted(track: string, askedAt: number, why: string, at = performance.now()): void {
121
+ this.slice({
122
+ track,
123
+ name: 'warm-up',
124
+ cat: 'worker',
125
+ from: askedAt,
126
+ to: at,
127
+ life: true,
128
+ args: { failed: why },
129
+ })
130
+ }
131
+
132
+ /** The run's events, ended at `end`: `ts` in microseconds of `performance.now()` (`TaskExecutor.profile`). */
133
+ events(end: number): Event[] {
134
+ const us = (ms: number): number => Math.round(ms * 1000)
135
+ const tracks = [
136
+ ORCHESTRATOR,
137
+ ...new Set(
138
+ // Every source of a row: a worker with no slice yet still has its idle and counters.
139
+ [
140
+ ...this.slices.map((s) => s.track),
141
+ ...this.marks.map((m) => m.track),
142
+ ...this.online.keys(),
143
+ ...this.uses.map((u) => u.track),
144
+ ]
145
+ .filter((t) => t !== ORCHESTRATOR)
146
+ .sort((a, b) => a.localeCompare(b, 'en', { numeric: true })),
147
+ ),
148
+ ]
149
+ const pid = new Map(tracks.map((t, i) => [t, i + 1]))
150
+ const events: Event[] = []
151
+ for (const [t, p] of pid) {
152
+ events.push({ ph: 'M', name: 'process_name', pid: p, tid: 0, args: { name: t } })
153
+ events.push({ ph: 'M', name: 'process_sort_index', pid: p, tid: 0, args: { sort_index: p } })
154
+ events.push({
155
+ ph: 'M',
156
+ name: 'thread_name',
157
+ pid: p,
158
+ tid: 0,
159
+ args: { name: t === ORCHESTRATOR ? 'run' : 'worker' },
160
+ })
161
+ }
162
+ // Idle: a worker's time online that no task of its covers.
163
+ const idle: Slice[] = []
164
+ for (const [track, o] of this.online) {
165
+ const busy = this.slices
166
+ .filter((s) => s.track === track && s.life !== true)
167
+ .map((s) => [s.from, s.to] as const)
168
+ .sort((a, b) => a[0] - b[0])
169
+ let at = o.from
170
+ const until = o.to ?? end
171
+ for (const [from, to] of busy) {
172
+ if (from > at)
173
+ idle.push({
174
+ track,
175
+ name: 'idle',
176
+ cat: 'idle',
177
+ from: at,
178
+ to: Math.min(from, until),
179
+ life: true,
180
+ })
181
+ at = Math.max(at, to)
182
+ }
183
+ if (until > at)
184
+ idle.push({ track, name: 'idle', cat: 'idle', from: at, to: until, life: true })
185
+ }
186
+ // Rows by first free, one set per kind (a worker's tasks, the run's
187
+ // waits, its restores): a slice takes the lowest of its kind whose last ended.
188
+ const rows = new Map<string, { tid: number; end: number }[]>()
189
+ const tids = new Map<string, number>()
190
+ for (const s of [...this.slices, ...idle].sort((a, b) => a.from - b.from)) {
191
+ const p = pid.get(s.track)!
192
+ let tid = 0
193
+ if (s.life !== true) {
194
+ const kind = `${s.track}\0${s.cat}`
195
+ const free = rows.get(kind) ?? []
196
+ rows.set(kind, free)
197
+ let row = free.find((r) => r.end <= s.from)
198
+ if (row === undefined) {
199
+ tid = (tids.get(s.track) ?? 0) + 1
200
+ tids.set(s.track, tid)
201
+ row = { tid, end: 0 }
202
+ free.push(row)
203
+ events.push({
204
+ ph: 'M',
205
+ name: 'thread_name',
206
+ pid: p,
207
+ tid,
208
+ args: { name: `${s.cat} ${free.length}` },
209
+ })
210
+ }
211
+ row.end = s.to
212
+ tid = row.tid
213
+ }
214
+ events.push({
215
+ ph: 'X',
216
+ name: s.name,
217
+ cat: s.cat,
218
+ pid: p,
219
+ tid,
220
+ ts: us(s.from),
221
+ dur: Math.max(0, us(s.to) - us(s.from)),
222
+ ...(s.args === undefined ? {} : { args: s.args }),
223
+ })
224
+ for (const part of s.parts ?? [])
225
+ if (part.to > part.from)
226
+ events.push({
227
+ ph: 'X',
228
+ name: part.name,
229
+ cat: s.cat,
230
+ pid: p,
231
+ tid,
232
+ ts: us(part.from),
233
+ dur: us(part.to) - us(part.from),
234
+ })
235
+ }
236
+ for (const m of this.marks)
237
+ events.push({
238
+ ph: 'i',
239
+ s: 'p',
240
+ name: m.name,
241
+ cat: 'mark',
242
+ pid: pid.get(m.track)!,
243
+ tid: 0,
244
+ ts: us(m.at),
245
+ ...(m.args === undefined ? {} : { args: m.args }),
246
+ })
247
+ // Each worker's cores: booked by the run and used by its tasks, as they change.
248
+ for (const track of new Set(this.uses.map((u) => u.track))) {
249
+ const steps = this.uses
250
+ .filter((u) => u.track === track)
251
+ .flatMap((u) => [
252
+ [u.from, u.booked, u.used],
253
+ [u.to, -u.booked, -u.used],
254
+ ])
255
+ .sort((a, b) => a[0]! - b[0]!)
256
+ let booked = 0
257
+ let used = 0
258
+ for (const [at, b, u] of steps) {
259
+ booked += b!
260
+ used += u!
261
+ events.push({
262
+ ph: 'C',
263
+ name: 'cores',
264
+ pid: pid.get(track)!,
265
+ tid: 0,
266
+ ts: us(at!),
267
+ args: { booked: round(booked), used: round(used) },
268
+ })
269
+ }
270
+ }
271
+ for (const l of this.loads) {
272
+ const id = pid.get(l.track)
273
+ if (id !== undefined)
274
+ events.push({
275
+ ph: 'C',
276
+ name: 'machine',
277
+ pid: id,
278
+ tid: 0,
279
+ ts: us(l.at),
280
+ args: { busy: round(l.cpu), ...(l.tasks === undefined ? {} : { tasks: round(l.tasks) }) },
281
+ })
282
+ }
283
+ return events
284
+ }
285
+ }
286
+
287
+ /** Two decimals, and no `-0` or float dust from adding and taking away. */
288
+ const round = (n: number): number => Math.max(0, Math.round(n * 100) / 100)
package/src/tree.ts ADDED
@@ -0,0 +1,72 @@
1
+ // What a worker must have checked out: the tracked files as the run sees
2
+ // them. Workers compare trees, not commits, so a run on uncommitted changes
3
+ // still matches the worktrees `local()` makes from them.
4
+
5
+ import { copyFile, mkdtemp, rm } from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import path from 'node:path'
8
+
9
+ /**
10
+ * A commit of the tracked files as they are now (`HEAD`'s when none changed),
11
+ * and its tree; both empty when git cannot say.
12
+ */
13
+ export async function snapshot(root: string): Promise<{ commit: string; tree: string }> {
14
+ const none = { commit: '', tree: '' }
15
+ const head = (await git(['rev-parse', '--verify', '-q', 'HEAD^{commit}'], root))?.trim()
16
+ if (head === undefined || head === '') return none
17
+ // The working tree's tracked files staged into a copy of the index, which
18
+ // leaves the index and the working tree as they are. `git stash create`
19
+ // did the same, but refused an intent-to-add entry (`git add -N`), and
20
+ // HEAD stood in for the changes (2026-10-28).
21
+ const index = (
22
+ await git(['rev-parse', '--path-format=absolute', '--git-path', 'index'], root)
23
+ )?.trim()
24
+ if (index === undefined) return none
25
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'vx-dte-index-'))
26
+ try {
27
+ const env = { GIT_INDEX_FILE: path.join(dir, 'index') }
28
+ try {
29
+ await copyFile(index, env.GIT_INDEX_FILE)
30
+ } catch {
31
+ // quiet: no index yet, so HEAD's tree is its tracked set.
32
+ if ((await git(['read-tree', head], root, env)) === undefined) return none
33
+ }
34
+ if ((await git(['add', '-u', '--', ':/'], root, env)) === undefined) return none
35
+ const tree = (await git(['write-tree'], root, env))?.trim()
36
+ if (tree === undefined || tree === '') return none
37
+ const was = (await git(['rev-parse', `${head}^{tree}`], root))?.trim()
38
+ if (tree === was) return { commit: head, tree }
39
+ // The commit is nobody's, so it needs no one's identity: a CI runner
40
+ // with none made `git stash create` fail (2026-10-28).
41
+ const commit = (
42
+ await git(['commit-tree', tree, '-p', head, '-m', 'vx dte'], root, IDENTITY)
43
+ )?.trim()
44
+ return commit === undefined || commit === '' ? none : { commit, tree }
45
+ } finally {
46
+ await rm(dir, { recursive: true, force: true })
47
+ }
48
+ }
49
+
50
+ const IDENTITY = {
51
+ GIT_AUTHOR_NAME: 'vx',
52
+ GIT_AUTHOR_EMAIL: 'vx@localhost',
53
+ GIT_COMMITTER_NAME: 'vx',
54
+ GIT_COMMITTER_EMAIL: 'vx@localhost',
55
+ }
56
+
57
+ /** git's stdout, or undefined when it failed. */
58
+ async function git(
59
+ args: string[],
60
+ cwd: string,
61
+ env?: Record<string, string>,
62
+ ): Promise<string | undefined> {
63
+ const p = Bun.spawn(['git', ...args], {
64
+ cwd,
65
+ stdin: 'ignore',
66
+ stdout: 'pipe',
67
+ stderr: 'ignore',
68
+ ...(env === undefined ? {} : { env: { ...process.env, ...env } }),
69
+ })
70
+ const [out, code] = await Promise.all([new Response(p.stdout).text(), p.exited])
71
+ return code === 0 ? out : undefined
72
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,135 @@
1
+ // What one machine did in a distributed run, from its tasks' results: the
2
+ // final summary's per-worker block.
3
+
4
+ interface Span {
5
+ readonly start: number
6
+ readonly end: number
7
+ readonly cores: number
8
+ readonly memory: number
9
+ }
10
+
11
+ export interface UsageStats {
12
+ readonly onlineMs: number
13
+ /** Time with at least one task running. */
14
+ readonly busyMs: number
15
+ /** Cores and bytes in use at once, at the most and on average over its time online. */
16
+ readonly cpuPeak: number
17
+ readonly cpuAvg: number
18
+ readonly memoryPeak: number
19
+ readonly memoryAvg: number
20
+ /** Core-time its tasks used over the core-time it had. */
21
+ readonly utilization: number
22
+ readonly cpuMs: number
23
+ /** Cores the run held for its tasks, on average over its time online: what packing gave it. */
24
+ readonly bookedAvg: number
25
+ /**
26
+ * Cores busy on its machine, any process's, on average since its worker
27
+ * started, as its last frame said: what `cpuAvg` leaves out (a Mac's
28
+ * indexer, a task's orphans). Undefined when it said none.
29
+ */
30
+ readonly machineAvg?: number | undefined
31
+ }
32
+
33
+ export class Usage {
34
+ done = 0
35
+ failed = 0
36
+ /** Killed by the run's stop, as core counts a task `aborted`. */
37
+ aborted = 0
38
+ /** Messages and bytes the run sent it, and those it sent the run. */
39
+ messagesIn = 0
40
+ bytesIn = 0
41
+ messagesOut = 0
42
+ bytesOut = 0
43
+ /** When it left, if before the run ended. */
44
+ to: number | undefined
45
+ private readonly spans: Span[] = []
46
+ private booked = 0
47
+ private bookedMs = 0
48
+ private bookedAt = 0
49
+ /** Its machine's busy cores on average, as its last frame said. */
50
+ machineAvg: number | undefined
51
+
52
+ constructor(readonly from: number = performance.now()) {}
53
+
54
+ /**
55
+ * A task it finished, as it ends: on it `from` its send (else its
56
+ * command's duration), its CPU time spread over that. Its command alone
57
+ * left the restore of its dependencies and its save idle: a worker
58
+ * busy all along read 10 s idle (2026-10-24).
59
+ */
60
+ record(
61
+ r: {
62
+ readonly exitCode: number
63
+ readonly signal?: string | number | undefined
64
+ readonly timedOut?: boolean | undefined
65
+ readonly durationMs: number
66
+ readonly cpuMs?: number | undefined
67
+ readonly peakRssBytes?: number | undefined
68
+ },
69
+ from?: number,
70
+ ): void {
71
+ if (r.exitCode === 0) this.done++
72
+ else if ((r.signal === 'SIGINT' || r.signal === 'SIGTERM') && r.timedOut !== true)
73
+ this.aborted++
74
+ else this.failed++
75
+ const end = performance.now()
76
+ const ms = Math.max(1, from === undefined ? r.durationMs : end - from)
77
+ this.spans.push({
78
+ start: end - ms,
79
+ end,
80
+ cores: (r.cpuMs ?? 0) / ms,
81
+ memory: r.peakRssBytes ?? 0,
82
+ })
83
+ }
84
+
85
+ /** The run now holds `cores` more of it for its tasks (fewer when negative). */
86
+ book(cores: number, at: number = performance.now()): void {
87
+ this.bookedMs += this.booked * (at - this.bookedAt)
88
+ this.bookedAt = at
89
+ this.booked += cores
90
+ }
91
+
92
+ stats(cores: number, until: number): UsageStats {
93
+ // Never before it joined: a time from before would read it online 1 ms.
94
+ const end = Math.max(this.from, this.to ?? until)
95
+ const onlineMs = Math.max(1, end - this.from)
96
+ // Ends before starts at one instant: back to back is not at once.
97
+ const events = this.spans
98
+ .flatMap((s) => [[s.start, 1, s] as const, [s.end, -1, s] as const])
99
+ .sort((a, b) => a[0] - b[0] || a[1] - b[1])
100
+ let running = 0
101
+ let cpu = 0
102
+ let memory = 0
103
+ let cpuPeak = 0
104
+ let memoryPeak = 0
105
+ let busyMs = 0
106
+ let last = 0
107
+ for (const [at, d, s] of events) {
108
+ if (running > 0) busyMs += at - last
109
+ last = at
110
+ running += d
111
+ cpu += d * s.cores
112
+ memory += d * s.memory
113
+ cpuPeak = Math.max(cpuPeak, cpu)
114
+ memoryPeak = Math.max(memoryPeak, memory)
115
+ }
116
+ let cpuMs = 0
117
+ let memoryMs = 0
118
+ for (const s of this.spans) {
119
+ cpuMs += s.cores * (s.end - s.start)
120
+ memoryMs += s.memory * (s.end - s.start)
121
+ }
122
+ return {
123
+ onlineMs,
124
+ busyMs,
125
+ cpuPeak,
126
+ cpuAvg: cpuMs / onlineMs,
127
+ memoryPeak,
128
+ memoryAvg: memoryMs / onlineMs,
129
+ utilization: cpuMs / (cores * onlineMs),
130
+ cpuMs,
131
+ bookedAvg: (this.bookedMs + this.booked * Math.max(0, end - this.bookedAt)) / onlineMs,
132
+ machineAvg: this.machineAvg,
133
+ }
134
+ }
135
+ }
@@ -0,0 +1,85 @@
1
+ // Each size's warm-up across runs, through the workspace's cache (M,
2
+ // 2026-10-23: "planner needs to use warmup from history"): a CI run writes
3
+ // the least time a worker of each size took to join under its commit, over
4
+ // the sizes it read; a run reads its newest first-parent ancestor's. Before
5
+ // any worker of a size joins, the plan waits that long for one, not 20 s:
6
+ // main fd5c72e's small workers joined in 15 s, its mac in 27 s. The blob is
7
+ // untrusted: it only sizes a plan, and a bad one reads as none.
8
+
9
+ import type { PluginCache } from '@vzn/vx'
10
+
11
+ /** Commits walked back from HEAD for a blob. */
12
+ const WALK = 6
13
+ const MAX_BYTES = 64 * 1024
14
+ const MAX_SIZES = 256
15
+ const MAX_MS = 3_600_000
16
+
17
+ const keyOf = (sha: string): string => `warm-ups\0${sha}`
18
+
19
+ /** `{ version: 1, sizes: { name: ms } }`'s warm-ups, each ms finite in [0, 1 h]; null for anything else. */
20
+ export function parseWarmUps(bytes: Uint8Array): Map<string, number> | null {
21
+ if (bytes.byteLength > MAX_BYTES) return null
22
+ let parsed: unknown
23
+ try {
24
+ parsed = JSON.parse(new TextDecoder().decode(bytes))
25
+ // quiet: not JSON reads as no blob
26
+ } catch {
27
+ return null
28
+ }
29
+ if (typeof parsed !== 'object' || parsed === null) return null
30
+ const { version, sizes } = parsed as { version?: unknown; sizes?: unknown }
31
+ if (version !== 1 || typeof sizes !== 'object' || sizes === null || Array.isArray(sizes))
32
+ return null
33
+ const entries = Object.entries(sizes)
34
+ if (entries.length > MAX_SIZES) return null
35
+ const out = new Map<string, number>()
36
+ for (const [name, ms] of entries)
37
+ if (typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 && ms <= MAX_MS) out.set(name, ms)
38
+ return out
39
+ }
40
+
41
+ /** HEAD and its first-parent ancestors, newest first; none outside git. */
42
+ async function commits(root: string): Promise<string[]> {
43
+ try {
44
+ const proc = Bun.spawn({
45
+ cmd: ['git', 'rev-list', '--first-parent', `--max-count=${WALK}`, 'HEAD'],
46
+ cwd: root,
47
+ stdout: 'pipe',
48
+ stderr: 'ignore',
49
+ })
50
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
51
+ return exit === 0 ? out.split('\n').filter((l) => l !== '') : []
52
+ // quiet: a local git read; no commits reads no blob
53
+ } catch {
54
+ return []
55
+ }
56
+ }
57
+
58
+ /** The newest warm-ups an ancestor's run wrote, the base branch's on a scoped run. */
59
+ export async function readWarmUps(cache: PluginCache, root: string): Promise<Map<string, number>> {
60
+ const keys = (await commits(root)).map(keyOf)
61
+ if (keys.length === 0) return new Map()
62
+ const held = await cache.hasMany(keys, false)
63
+ for (const key of keys) {
64
+ if (held !== null && !held.has(key)) continue
65
+ const bytes = await cache.get(key, false)
66
+ const got = bytes === null ? null : parseWarmUps(bytes)
67
+ if (got !== null) return got
68
+ }
69
+ return new Map()
70
+ }
71
+
72
+ /** `learned` with this run's own over it, under HEAD: once per commit, as every entry is. */
73
+ export async function writeWarmUps(
74
+ cache: PluginCache,
75
+ root: string,
76
+ learned: ReadonlyMap<string, number>,
77
+ run: ReadonlyMap<string, number>,
78
+ ): Promise<void> {
79
+ if (run.size === 0) return
80
+ const [head] = await commits(root)
81
+ if (head === undefined) return
82
+ const sizes: Record<string, number> = {}
83
+ for (const [name, ms] of [...learned, ...run]) sizes[name] = Math.round(ms)
84
+ await cache.put(keyOf(head), new TextEncoder().encode(JSON.stringify({ version: 1, sizes })))
85
+ }