@vzn/vx 0.0.71 → 0.0.73
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/package.json +5 -5
- package/src/cache/cache.ts +38 -7
- package/src/cache/file-hashes.ts +29 -14
- package/src/cache/layer.ts +10 -0
- package/src/cache/output-index.ts +11 -0
- package/src/cli/upgrade.ts +20 -3
- package/src/graph/scheduler.ts +17 -8
- package/src/orchestrator/run.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.73",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.73",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.73",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.73",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.73"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -62,6 +62,7 @@ import {
|
|
|
62
62
|
type DeclaredOutputs,
|
|
63
63
|
type IngestMeta,
|
|
64
64
|
type InvocationRecord,
|
|
65
|
+
isIndexFull,
|
|
65
66
|
type OutputDirRow,
|
|
66
67
|
type OutputFileRow,
|
|
67
68
|
type PruneOptions,
|
|
@@ -1483,7 +1484,12 @@ export class Cache implements CacheLayer {
|
|
|
1483
1484
|
// node's writeFile, not Bun.write: the latter copies the buffer first
|
|
1484
1485
|
// (measured on 150 MiB: +151 MiB and 33 ms against +0 and 21 ms).
|
|
1485
1486
|
const endWrite = span('save: write temp')
|
|
1486
|
-
|
|
1487
|
+
// A write that fails part-way (a full disk) leaves the temp behind,
|
|
1488
|
+
// and a fresh one is an orphan only an hour on (A-14).
|
|
1489
|
+
await writeFile(tmpPath, compressed).catch(async (err: unknown) => {
|
|
1490
|
+
await unlink(tmpPath).catch(() => undefined)
|
|
1491
|
+
throw err
|
|
1492
|
+
})
|
|
1487
1493
|
endWrite()
|
|
1488
1494
|
} else {
|
|
1489
1495
|
// `save` or `ingest` already streamed the artifact into its temp.
|
|
@@ -1625,6 +1631,16 @@ export class Cache implements CacheLayer {
|
|
|
1625
1631
|
const hashes = [...this.touched]
|
|
1626
1632
|
this.touched.clear()
|
|
1627
1633
|
const now = Date.now()
|
|
1634
|
+
// LRU bookkeeping: on a full disk the bumps are dropped, never the prune
|
|
1635
|
+
// or the stats that asked for them (A-14).
|
|
1636
|
+
try {
|
|
1637
|
+
this.writeAccessed(hashes, now)
|
|
1638
|
+
} catch (err) {
|
|
1639
|
+
if (!isIndexFull(err)) throw err
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
private writeAccessed(hashes: readonly string[], now: number): void {
|
|
1628
1644
|
// Chunked: SQLite's bound-parameter ceiling is 32k on modern
|
|
1629
1645
|
// builds, but 900 keeps us safe on any build at negligible cost.
|
|
1630
1646
|
for (let i = 0; i < hashes.length; i += 900) {
|
|
@@ -1824,15 +1840,25 @@ export class Cache implements CacheLayer {
|
|
|
1824
1840
|
if (victims.size > 0 || stale.length > 0) {
|
|
1825
1841
|
const hashes = [...victims]
|
|
1826
1842
|
const rows = [...hashes, ...stale]
|
|
1827
|
-
|
|
1843
|
+
// Artifacts first: a row whose artifact is gone reads as a miss, so the
|
|
1844
|
+
// order is safe, and on a full disk the delete's own journal needs the
|
|
1845
|
+
// space the unlinks free. Rows-first failed there before any file went,
|
|
1846
|
+
// and the one verb meant to free a full disk freed nothing (A-14).
|
|
1847
|
+
await Promise.all(hashes.map((h) => rm(this.tarPath(h), { force: true })))
|
|
1848
|
+
const deleteRows = this.db.transaction(() => {
|
|
1828
1849
|
for (let i = 0; i < rows.length; i += 900) {
|
|
1829
1850
|
const chunk = rows.slice(i, i + 900)
|
|
1830
1851
|
this.db
|
|
1831
1852
|
.prepare(`DELETE FROM entries WHERE hash IN (${chunk.map(() => '?').join(',')})`)
|
|
1832
1853
|
.run(...(chunk as readonly SQLQueryBindings[]))
|
|
1833
1854
|
}
|
|
1834
|
-
})
|
|
1835
|
-
|
|
1855
|
+
})
|
|
1856
|
+
try {
|
|
1857
|
+
deleteRows()
|
|
1858
|
+
} catch (err) {
|
|
1859
|
+
if (!isIndexFull(err)) throw err
|
|
1860
|
+
deleteRows()
|
|
1861
|
+
}
|
|
1836
1862
|
}
|
|
1837
1863
|
|
|
1838
1864
|
const orphans = await this.reapOrphans()
|
|
@@ -1853,9 +1879,14 @@ export class Cache implements CacheLayer {
|
|
|
1853
1879
|
private async reapOrphans(
|
|
1854
1880
|
now: number = Date.now(),
|
|
1855
1881
|
): Promise<{ orphans: number; orphanBytes: number }> {
|
|
1856
|
-
|
|
1857
|
-
.
|
|
1858
|
-
|
|
1882
|
+
try {
|
|
1883
|
+
this.db
|
|
1884
|
+
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
1885
|
+
.run(SWEPT_AT, String(now))
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
// The sweep's clock: on a full disk the sweep runs again next time.
|
|
1888
|
+
if (!isIndexFull(err)) throw err
|
|
1889
|
+
}
|
|
1859
1890
|
let orphans = 0
|
|
1860
1891
|
let orphanBytes = 0
|
|
1861
1892
|
await Promise.all(
|
package/src/cache/file-hashes.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type { Database } from 'bun:sqlite'
|
|
|
10
10
|
import { lstatSync, readlinkSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import { fileIdentity, repoFacts } from './git-inputs.js'
|
|
13
|
-
import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
|
|
13
|
+
import { FILE_HASH_RACY_MS, isIndexFull, racyWindowMs } from './layer.js'
|
|
14
14
|
|
|
15
15
|
const FILE_HASHES_SWEPT_AT = 'file_hashes_swept_at'
|
|
16
16
|
const DAY_MS = 24 * 60 * 60 * 1000
|
|
@@ -163,7 +163,12 @@ export class FileHashStore {
|
|
|
163
163
|
// than memoised; the warm path never meets it (keys are derived long
|
|
164
164
|
// after the files were written).
|
|
165
165
|
if (this.write && Date.now() - ctimeMs >= racyWindowMs(st.ctimeMs, FILE_HASH_RACY_MS)) {
|
|
166
|
-
|
|
166
|
+
// A memo: on a full disk the digest stands and the row is skipped.
|
|
167
|
+
try {
|
|
168
|
+
this.upsertFileHash.run(filePath, mtimeMs, size, ctimeMs, ino, ch, Date.now())
|
|
169
|
+
} catch (err) {
|
|
170
|
+
if (!isIndexFull(err)) throw err
|
|
171
|
+
}
|
|
167
172
|
}
|
|
168
173
|
return fileIdentity(mode, ch)
|
|
169
174
|
}
|
|
@@ -261,20 +266,30 @@ export class FileHashStore {
|
|
|
261
266
|
misses.map((p) => this.hashFileFromDisk(p).catch(() => undefined)),
|
|
262
267
|
)
|
|
263
268
|
const now = Date.now()
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
269
|
+
const rows: Array<[string, Stat, string]> = []
|
|
270
|
+
for (let i = 0; i < misses.length; i++) {
|
|
271
|
+
const digest = digests[i]
|
|
272
|
+
if (digest === undefined) continue
|
|
273
|
+
const p = misses[i]!
|
|
274
|
+
const st = stats.get(p)!
|
|
275
|
+
out.set(p, fileIdentity(st.mode, digest))
|
|
276
|
+
// The same racy-clean rule as `hashFile`: a stat taken within the
|
|
277
|
+
// window of the file's last change is not memoised.
|
|
278
|
+
if (this.write && now - st.ctimeMs >= racyWindowMs(st.ctimeRaw, FILE_HASH_RACY_MS)) {
|
|
279
|
+
rows.push([p, st, digest])
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Every digest is in `out` before the memo is written: on a full disk the
|
|
283
|
+
// rows are skipped and the answer stands.
|
|
284
|
+
try {
|
|
285
|
+
this.db.transaction(() => {
|
|
286
|
+
for (const [p, st, digest] of rows) {
|
|
274
287
|
this.upsertFileHash.run(p, st.mtimeMs, st.size, st.ctimeMs, st.ino, digest, now)
|
|
275
288
|
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
289
|
+
})()
|
|
290
|
+
} catch (err) {
|
|
291
|
+
if (!isIndexFull(err)) throw err
|
|
292
|
+
}
|
|
278
293
|
return out
|
|
279
294
|
}
|
|
280
295
|
|
package/src/cache/layer.ts
CHANGED
|
@@ -438,6 +438,16 @@ export interface IngestMeta {
|
|
|
438
438
|
outputs?: DeclaredOutputs
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
/**
|
|
442
|
+
* SQLite's answer when the disk under the index is full. It is a code on
|
|
443
|
+
* the `SQLiteError` (`SQLITE_FULL`), never an `ENOSPC` errno, so
|
|
444
|
+
* `isDiskFull` does not see it: a memo write, a snapshot flush or a prune's
|
|
445
|
+
* delete on a full disk reached the user as a stack (A-14).
|
|
446
|
+
*/
|
|
447
|
+
export function isIndexFull(err: unknown): boolean {
|
|
448
|
+
return (err as { code?: unknown } | null)?.code === 'SQLITE_FULL'
|
|
449
|
+
}
|
|
450
|
+
|
|
441
451
|
/**
|
|
442
452
|
* The supplied artifact bytes don't decompress/parse as a vx artifact.
|
|
443
453
|
* Thrown by `save`/`ingest` BEFORE anything reaches the final cache
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
OUTPUT_DIRS_CAP,
|
|
17
17
|
OUTPUT_DIRS_RACY_MS,
|
|
18
18
|
type OutputDirRow,
|
|
19
|
+
isIndexFull,
|
|
19
20
|
racyWindowMs,
|
|
20
21
|
type OutputFileRow,
|
|
21
22
|
WORKSPACE_OUTPUT_PREFIX,
|
|
@@ -265,6 +266,16 @@ export class OutputIndex {
|
|
|
265
266
|
|
|
266
267
|
/** Land every pending stamp and snapshot in one transaction each; a null snapshot clears its rows. */
|
|
267
268
|
flushOutputDirs(): void {
|
|
269
|
+
// A memo either way: on a full disk the pending rows are dropped (the
|
|
270
|
+
// next hit walks, or restores), never a failed run (A-14).
|
|
271
|
+
try {
|
|
272
|
+
this.flushPending()
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (!isIndexFull(err)) throw err
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private flushPending(): void {
|
|
268
279
|
if (this.pendingStamps.size > 0) {
|
|
269
280
|
const stamps = [...this.pendingStamps]
|
|
270
281
|
this.pendingStamps.clear()
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -304,10 +304,27 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
|
|
|
304
304
|
// rather than this process guessing, and one that cannot is rolled back.
|
|
305
305
|
let installed = ''
|
|
306
306
|
await replaceBinary(dest, asset.url, asset.sha256, (bin) => {
|
|
307
|
-
|
|
308
|
-
installed
|
|
309
|
-
return proc.exitCode === 0 && installed.startsWith('vx ')
|
|
307
|
+
installed = startedVersion(bin) ?? ''
|
|
308
|
+
return installed !== ''
|
|
310
309
|
})
|
|
311
310
|
process.stdout.write(`vx upgrade: installed ${installed}\n`)
|
|
312
311
|
return 0
|
|
313
312
|
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* `bin --version` when it answers as vx within `timeoutMs`, else null. The
|
|
316
|
+
* answer decides whether a replaced binary is rolled back, so it is
|
|
317
|
+
* bounded: a binary that hung on start held `vx upgrade` forever, new
|
|
318
|
+
* binary in place, old one never restored.
|
|
319
|
+
*/
|
|
320
|
+
export function startedVersion(bin: string, timeoutMs = 10_000): string | null {
|
|
321
|
+
const proc = Bun.spawnSync({
|
|
322
|
+
cmd: [bin, '--version'],
|
|
323
|
+
stdin: 'ignore',
|
|
324
|
+
stdout: 'pipe',
|
|
325
|
+
stderr: 'pipe',
|
|
326
|
+
timeout: timeoutMs,
|
|
327
|
+
})
|
|
328
|
+
const out = new TextDecoder().decode(proc.stdout).trim()
|
|
329
|
+
return proc.exitCode === 0 && out.startsWith('vx ') ? out : null
|
|
330
|
+
}
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -178,6 +178,12 @@ export interface ScheduleOptions {
|
|
|
178
178
|
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
179
179
|
onStart?: (node: TaskNode) => void
|
|
180
180
|
onFinish?: (outcome: TaskOutcome) => void
|
|
181
|
+
/**
|
|
182
|
+
* Where the line naming a rejected `execute` goes, before its failed
|
|
183
|
+
* outcome lands. Absent = stderr. The run gives it the task's own
|
|
184
|
+
* stderr, so its frame and the failure recap carry the reason.
|
|
185
|
+
*/
|
|
186
|
+
onError?: (node: TaskNode, line: string) => void
|
|
181
187
|
/**
|
|
182
188
|
* What an outcome still owes before its dependents may start — a cache
|
|
183
189
|
* save that runs off the execution slot (the orchestrator's save lane):
|
|
@@ -722,19 +728,15 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
722
728
|
})
|
|
723
729
|
leave()
|
|
724
730
|
untrack()
|
|
725
|
-
|
|
726
|
-
// Surface the error live; the outcome itself doesn't
|
|
727
|
-
// carry captured stderr (that's the logger's job). A
|
|
728
|
-
// UserError is a config/input failure (e.g. a failed
|
|
731
|
+
// A UserError is a config/input failure (e.g. a failed
|
|
729
732
|
// `cache.inputs.runtime` command), not a vx bug — report it
|
|
730
733
|
// plainly, never as an "internal error".
|
|
734
|
+
let line: string
|
|
731
735
|
if (isUserError(err) || isFsRefusal(err)) {
|
|
732
736
|
const text = isUserError(err) ? message : `${message} — ${fsRefusalHint(err)}`
|
|
733
737
|
const first = refusedBy.get(text)
|
|
734
738
|
if (first === undefined) refusedBy.set(text, id)
|
|
735
|
-
|
|
736
|
-
`[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`,
|
|
737
|
-
)
|
|
739
|
+
line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
|
|
738
740
|
} else {
|
|
739
741
|
const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
|
|
740
742
|
// A wrapped error's cause is the fact the reader needs (a
|
|
@@ -746,8 +748,15 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
746
748
|
cause === undefined
|
|
747
749
|
? ''
|
|
748
750
|
: ` (cause: ${(cause as NodeJS.ErrnoException).code ?? cause.name}: ${cause.message})`
|
|
749
|
-
|
|
751
|
+
line = `[vx] internal error in ${id}: ${named}${message}${because}\n`
|
|
750
752
|
}
|
|
753
|
+
try {
|
|
754
|
+
if (options.onError === undefined) process.stderr.write(line)
|
|
755
|
+
else options.onError(node, line)
|
|
756
|
+
} catch {
|
|
757
|
+
process.stderr.write(line)
|
|
758
|
+
}
|
|
759
|
+
finishOne(id, outcome)
|
|
751
760
|
tick()
|
|
752
761
|
},
|
|
753
762
|
)
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -806,6 +806,7 @@ async function runOnBus(
|
|
|
806
806
|
log.taskComplete(o.node, o)
|
|
807
807
|
narrowDemand(o.node.id)
|
|
808
808
|
},
|
|
809
|
+
onError: (node, line) => log.taskStderr(node, line),
|
|
809
810
|
execute: (node, upstream) => executeWithDedup(node, keyUpstream(node, upstream)),
|
|
810
811
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
811
812
|
// baseline as the tie-break. Empty map → baseline only.
|