@vzn/vx 0.0.630 → 0.0.632
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 +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +1 -1
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +65 -0
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/workspace/workspace.ts +1 -0
package/src/cache/archive.ts
CHANGED
|
@@ -50,7 +50,15 @@ import { lstat, mkdir, readlink, realpath, rmdir, stat, unlink, writeFile } from
|
|
|
50
50
|
import path from 'node:path'
|
|
51
51
|
import { UserError } from '../util/index.js'
|
|
52
52
|
import { WORKSPACE_OUTPUT_PREFIX } from './layer.js'
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
TarFormatError,
|
|
55
|
+
type TarInput,
|
|
56
|
+
tarEntries,
|
|
57
|
+
tarEntriesSync,
|
|
58
|
+
tarPack,
|
|
59
|
+
tarPackInto,
|
|
60
|
+
tarSize,
|
|
61
|
+
} from './tar-stream.js'
|
|
54
62
|
import { ON_THREAD_MAX } from './zstd.js'
|
|
55
63
|
|
|
56
64
|
/** Archive entry name carrying the per-output mode/mtime sidecar. */
|
|
@@ -59,6 +67,10 @@ const META_ENTRY = '.vx-meta.json'
|
|
|
59
67
|
/** Archive entry name of the always-present stdout record. */
|
|
60
68
|
const STDOUT_ENTRY = 'stdout'
|
|
61
69
|
|
|
70
|
+
// One decoder, its defaults (a leading BOM dropped), for both scanners.
|
|
71
|
+
// `ignoreBOM`: a leading U+FEFF in a task's stdout is the task's.
|
|
72
|
+
const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
73
|
+
|
|
62
74
|
/**
|
|
63
75
|
* The last entry: a CRC-32 over every entry before it, each its name, a
|
|
64
76
|
* NUL, then its body, as 8 lowercase hex digits (v36, L-19). Nothing else
|
|
@@ -107,19 +119,30 @@ class SumCheck {
|
|
|
107
119
|
name: string,
|
|
108
120
|
body: AsyncIterable<Uint8Array>,
|
|
109
121
|
): Promise<AsyncIterable<Uint8Array> | null> {
|
|
122
|
+
if (!this.enter(name)) return this.sum.through(body)
|
|
123
|
+
this.verify(await textOf(body))
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The next regular entry: true when it is the sum entry, whose text the
|
|
128
|
+
* caller hands to `verify`; otherwise the caller hands its bytes to `add`.
|
|
129
|
+
*/
|
|
130
|
+
enter(name: string): boolean {
|
|
110
131
|
if (this.seen) throw new TarFormatError(`entry ${name} follows the artifact's checksum`)
|
|
111
|
-
if (name === SUM_ENTRY)
|
|
112
|
-
const got = await textOf(body)
|
|
113
|
-
if (got !== this.sum.hex()) {
|
|
114
|
-
throw new TarFormatError(
|
|
115
|
-
`artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
|
|
116
|
-
)
|
|
117
|
-
}
|
|
118
|
-
this.seen = true
|
|
119
|
-
return null
|
|
120
|
-
}
|
|
132
|
+
if (name === SUM_ENTRY) return true
|
|
121
133
|
this.sum.entry(name)
|
|
122
|
-
return
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
add(bytes: Uint8Array): void {
|
|
137
|
+
this.sum.add(bytes)
|
|
138
|
+
}
|
|
139
|
+
verify(got: string): void {
|
|
140
|
+
if (got !== this.sum.hex()) {
|
|
141
|
+
throw new TarFormatError(
|
|
142
|
+
`artifact content does not match its checksum (${got}, read ${this.sum.hex()})`,
|
|
143
|
+
)
|
|
144
|
+
}
|
|
145
|
+
this.seen = true
|
|
123
146
|
}
|
|
124
147
|
done(): void {
|
|
125
148
|
if (!this.seen) throw new TarFormatError('artifact carries no checksum')
|
|
@@ -373,16 +396,12 @@ async function* summed(inputs: readonly TarInput[]): AsyncGenerator<TarInput> {
|
|
|
373
396
|
* otherwise (measured 2026-09-03: 238 → 293 ms per 1 000 saves).
|
|
374
397
|
*/
|
|
375
398
|
export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array> {
|
|
376
|
-
// A small artifact
|
|
377
|
-
|
|
399
|
+
// A small artifact is read and packed on this thread (`ON_THREAD_MAX`):
|
|
400
|
+
// no promise or generator hop per file or block.
|
|
401
|
+
if (plan.size <= ON_THREAD_MAX) return packSmall(plan)
|
|
378
402
|
const inputs = await Promise.all(
|
|
379
403
|
plan.inputs.map(async (i) =>
|
|
380
|
-
i.body instanceof Blob
|
|
381
|
-
? {
|
|
382
|
-
...i,
|
|
383
|
-
body: onThread ? readFileSync((i.body as Bun.BunFile).name!) : await i.body.bytes(),
|
|
384
|
-
}
|
|
385
|
-
: i,
|
|
404
|
+
i.body instanceof Blob ? { ...i, body: await i.body.bytes() } : i,
|
|
386
405
|
),
|
|
387
406
|
)
|
|
388
407
|
const out = new Uint8Array(plan.size)
|
|
@@ -395,6 +414,28 @@ export async function packArtifactBytes(plan: ArtifactPlan): Promise<Uint8Array>
|
|
|
395
414
|
return out
|
|
396
415
|
}
|
|
397
416
|
|
|
417
|
+
/** `summed` + `tarPack` for a plan whose bodies fit in memory, synchronously. */
|
|
418
|
+
function packSmall(plan: ArtifactPlan): Uint8Array {
|
|
419
|
+
const sum = new EntrySum()
|
|
420
|
+
const inputs: (TarInput & { body: Uint8Array | string })[] = []
|
|
421
|
+
for (const i of plan.inputs) {
|
|
422
|
+
const body =
|
|
423
|
+
i.body instanceof Blob
|
|
424
|
+
? readFileSync((i.body as Bun.BunFile).name!)
|
|
425
|
+
: typeof i.body === 'string'
|
|
426
|
+
? new TextEncoder().encode(i.body)
|
|
427
|
+
: (i.body as Uint8Array)
|
|
428
|
+
sum.entry(i.name)
|
|
429
|
+
sum.add(body)
|
|
430
|
+
inputs.push({ ...i, body })
|
|
431
|
+
}
|
|
432
|
+
inputs.push({ name: SUM_ENTRY, size: SUM_SIZE, body: sum.hex() })
|
|
433
|
+
const out = new Uint8Array(plan.size)
|
|
434
|
+
const n = tarPackInto(inputs, out)
|
|
435
|
+
if (n !== plan.size) throw new TarFormatError(`packed ${n} bytes, planned ${plan.size}`)
|
|
436
|
+
return out
|
|
437
|
+
}
|
|
438
|
+
|
|
398
439
|
/** Plan and pack in one go, in memory. */
|
|
399
440
|
export async function packArtifact(args: PackArgs): Promise<Uint8Array> {
|
|
400
441
|
return await packArtifactBytes(await planArtifact(args))
|
|
@@ -413,6 +454,51 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
|
413
454
|
})
|
|
414
455
|
}
|
|
415
456
|
|
|
457
|
+
/** What a scan of an artifact reads out of it. */
|
|
458
|
+
interface ArtifactScan {
|
|
459
|
+
entries: ArchiveEntry[]
|
|
460
|
+
stdout: string | null
|
|
461
|
+
exec: ExecUsage | undefined
|
|
462
|
+
key: string | undefined
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** A scan's result, built entry by entry in tar order: the one fold both scanners share. */
|
|
466
|
+
class ScanFold {
|
|
467
|
+
private readonly seen: Array<{ name: string; size: number; mtimeMs: number }> = []
|
|
468
|
+
private files: MetaFile['files'] = {}
|
|
469
|
+
private exec: ExecUsage | undefined
|
|
470
|
+
private key: string | undefined
|
|
471
|
+
stdout: string | null = null
|
|
472
|
+
|
|
473
|
+
sidecar(text: string): void {
|
|
474
|
+
const parsed = JSON.parse(text) as MetaFile
|
|
475
|
+
this.files = parsed.files ?? {}
|
|
476
|
+
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
477
|
+
// usage is taken only when it is a plain non-negative number.
|
|
478
|
+
this.exec = usageOf(parsed.exec)
|
|
479
|
+
if (typeof parsed.key === 'string') this.key = parsed.key
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
entry(name: string, size: number, mtimeMs: number): void {
|
|
483
|
+
this.seen.push({ name, size, mtimeMs })
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
result(): ArtifactScan {
|
|
487
|
+
return {
|
|
488
|
+
entries: this.seen.map((s) => {
|
|
489
|
+
const m = this.files[s.name]
|
|
490
|
+
// A foreign artifact (or one whose sidecar lost an entry) restores
|
|
491
|
+
// readable-but-not-executable rather than failing the hit: the
|
|
492
|
+
// sidecar is metadata, never the authority on whether bytes exist.
|
|
493
|
+
return { name: s.name, size: s.size, mode: m?.[0] ?? 0o644, mtimeMs: m?.[1] ?? s.mtimeMs }
|
|
494
|
+
}),
|
|
495
|
+
stdout: this.stdout,
|
|
496
|
+
exec: this.exec,
|
|
497
|
+
key: this.key,
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
416
502
|
/**
|
|
417
503
|
* List an artifact's regular entries with their restore metadata, and
|
|
418
504
|
* its stdout text (`null` when the entry is absent — not a vx artifact),
|
|
@@ -420,49 +506,55 @@ function streamOf(gen: AsyncGenerator<Uint8Array>): ReadableStream<Uint8Array> {
|
|
|
420
506
|
* name is unsafe — a partial reading of a poisoned artifact is the
|
|
421
507
|
* outcome the traversal defense exists to prevent.
|
|
422
508
|
*/
|
|
423
|
-
export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<{
|
|
424
|
-
|
|
425
|
-
stdout: string | null
|
|
426
|
-
exec: ExecUsage | undefined
|
|
427
|
-
key: string | undefined
|
|
428
|
-
}> {
|
|
429
|
-
const seen: Array<{ name: string; size: number; mtimeMs: number }> = []
|
|
430
|
-
let meta: MetaFile['files'] = {}
|
|
431
|
-
let exec: ExecUsage | undefined
|
|
432
|
-
let key: string | undefined
|
|
433
|
-
let stdout: string | null = null
|
|
509
|
+
export async function scanArtifact(tar: ReadableStream<Uint8Array>): Promise<ArtifactScan> {
|
|
510
|
+
const fold = new ScanFold()
|
|
434
511
|
const check = new SumCheck()
|
|
435
512
|
for await (const e of tarEntries(tar)) {
|
|
436
513
|
if (e.type !== '0') continue
|
|
437
514
|
const body = await check.body(e.name, e.body)
|
|
438
515
|
if (body === null) continue
|
|
439
516
|
if (e.name === META_ENTRY) {
|
|
440
|
-
|
|
441
|
-
meta = parsed.files ?? {}
|
|
442
|
-
// The ingest side is the untrusted boundary: a foreign sidecar's
|
|
443
|
-
// usage is taken only when it is a plain non-negative number.
|
|
444
|
-
exec = usageOf(parsed.exec)
|
|
445
|
-
if (typeof parsed.key === 'string') key = parsed.key
|
|
517
|
+
fold.sidecar(await textOf(body))
|
|
446
518
|
continue
|
|
447
519
|
}
|
|
448
520
|
assertSafeName(e.name)
|
|
449
|
-
if (e.name === STDOUT_ENTRY) stdout = await textOf(body)
|
|
521
|
+
if (e.name === STDOUT_ENTRY) fold.stdout = await textOf(body)
|
|
450
522
|
else await drain(body)
|
|
451
|
-
|
|
523
|
+
fold.entry(e.name, e.size, e.mtimeMs)
|
|
452
524
|
}
|
|
453
525
|
check.done()
|
|
454
|
-
return
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
526
|
+
return fold.result()
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* `scanArtifact` over a tar in memory, synchronously: a save scans the
|
|
531
|
+
* tar it just packed. Same decoder, same checks in the same order, same
|
|
532
|
+
* result. The async generators, per-chunk promises and drain were most
|
|
533
|
+
* of a small save's scan: one 400 B output, 49 → 14 µs (X-191).
|
|
534
|
+
*/
|
|
535
|
+
export function scanTarBytes(tar: Uint8Array): ArtifactScan {
|
|
536
|
+
const fold = new ScanFold()
|
|
537
|
+
const check = new SumCheck()
|
|
538
|
+
for (const e of tarEntriesSync(tar)) {
|
|
539
|
+
if (e.type !== '0') continue
|
|
540
|
+
if (check.enter(e.name)) {
|
|
541
|
+
check.verify(utf8.decode(e.body()))
|
|
542
|
+
continue
|
|
543
|
+
}
|
|
544
|
+
if (e.name === META_ENTRY) {
|
|
545
|
+
const body = e.body()
|
|
546
|
+
check.add(body)
|
|
547
|
+
fold.sidecar(utf8.decode(body))
|
|
548
|
+
continue
|
|
549
|
+
}
|
|
550
|
+
assertSafeName(e.name)
|
|
551
|
+
const body = e.body()
|
|
552
|
+
check.add(body)
|
|
553
|
+
if (e.name === STDOUT_ENTRY) fold.stdout = utf8.decode(body)
|
|
554
|
+
fold.entry(e.name, e.size, e.mtimeMs)
|
|
465
555
|
}
|
|
556
|
+
check.done()
|
|
557
|
+
return fold.result()
|
|
466
558
|
}
|
|
467
559
|
|
|
468
560
|
/** The usage worth carrying: each axis a finite non-negative number, or absent; nothing → undefined. */
|
|
@@ -569,7 +661,7 @@ async function bytesOf(body: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
|
|
|
569
661
|
}
|
|
570
662
|
|
|
571
663
|
const textOf = async (body: AsyncIterable<Uint8Array>): Promise<string> =>
|
|
572
|
-
|
|
664
|
+
utf8.decode(await bytesOf(body))
|
|
573
665
|
|
|
574
666
|
/** One staged entry: written at `tmp`, renamed to `target` on commit. */
|
|
575
667
|
interface Staged {
|
|
@@ -1012,6 +1104,12 @@ function assertSafeName(name: string): void {
|
|
|
1012
1104
|
if (name.includes('//')) {
|
|
1013
1105
|
throw new ArchiveSecurityError(`archive entry name has empty path component (unsafe): ${name}`)
|
|
1014
1106
|
}
|
|
1107
|
+
// vx never packs a `.` segment, and `outputs/.` names the anchor itself:
|
|
1108
|
+
// ingest kept it, and every hit then failed renaming a file over the
|
|
1109
|
+
// project directory, blamed on the user's tree.
|
|
1110
|
+
if (hasDotSegment(name)) {
|
|
1111
|
+
throw new ArchiveSecurityError(`archive entry name has a '.' segment (unsafe): ${name}`)
|
|
1112
|
+
}
|
|
1015
1113
|
// No backslash refusal: vx runs on Linux and macOS (Windows through WSL),
|
|
1016
1114
|
// where it is a name character, and the save scans its own artifact with
|
|
1017
1115
|
// these checks, so refusing one made `dist/back\slash` uncacheable.
|
|
@@ -1049,6 +1147,10 @@ const NAME_MAX = 255
|
|
|
1049
1147
|
/** Bytes a path may hold, its terminating NUL included (limits.h). */
|
|
1050
1148
|
const PATH_MAX = process.platform === 'darwin' ? 1024 : 4096
|
|
1051
1149
|
|
|
1150
|
+
function hasDotSegment(p: string): boolean {
|
|
1151
|
+
return p === '.' || p.startsWith('./') || p.endsWith('/.') || p.includes('/./')
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1052
1154
|
function hasParentSegment(p: string): boolean {
|
|
1053
1155
|
if (p === '..' || p.startsWith('../') || p.endsWith('/..')) return true
|
|
1054
1156
|
return p.includes('/../')
|
package/src/cache/cache.ts
CHANGED
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
type ExecUsage,
|
|
71
71
|
extractArtifactStream,
|
|
72
72
|
scanArtifact,
|
|
73
|
+
scanTarBytes,
|
|
73
74
|
packArtifactBytes,
|
|
74
75
|
packArtifactStream,
|
|
75
76
|
planArtifact,
|
|
@@ -100,7 +101,6 @@ import {
|
|
|
100
101
|
bytesOf,
|
|
101
102
|
decodedTar,
|
|
102
103
|
MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
103
|
-
oneChunk,
|
|
104
104
|
STREAM_DECODE_FROM,
|
|
105
105
|
zstdCompress,
|
|
106
106
|
zstdEncoder,
|
|
@@ -122,6 +122,16 @@ import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
|
122
122
|
*/
|
|
123
123
|
const ORPHAN_GRACE_MS = 60 * 60 * 1000
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* A hit renews an entry's `accessed_at` only once it is older than this:
|
|
127
|
+
* renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
|
|
128
|
+
* close, ~30 ms of it. So `accessed_at` trails the last use by at most
|
|
129
|
+
* this much, and the retention policy reads it plus this as the last use:
|
|
130
|
+
* an entry used within the limit is never pruned, and one may stay up to
|
|
131
|
+
* this long past it (owner, 2026-10-08).
|
|
132
|
+
*/
|
|
133
|
+
const ACCESS_REFRESH_MS = 60 * 60 * 1000
|
|
134
|
+
|
|
125
135
|
/**
|
|
126
136
|
* The only names the orphan sweep may unlink: an artifact under a key
|
|
127
137
|
* `foldKey` prints (16 lowercase hex) and the temp `tempPath` makes of
|
|
@@ -298,11 +308,12 @@ function pickVictims(
|
|
|
298
308
|
const kept: Array<{ at: number; size: number; row?: string; file?: RowlessFile }> = []
|
|
299
309
|
let remaining = 0
|
|
300
310
|
for (const r of rows) {
|
|
301
|
-
|
|
311
|
+
const at = r.accessed_at + ACCESS_REFRESH_MS
|
|
312
|
+
if (olderThanMs !== undefined && at < olderThanMs) {
|
|
302
313
|
hashes.add(r.hash)
|
|
303
314
|
bytesFreed += r.size_bytes
|
|
304
315
|
} else {
|
|
305
|
-
kept.push({ at
|
|
316
|
+
kept.push({ at, size: r.size_bytes, row: r.hash })
|
|
306
317
|
remaining += r.size_bytes
|
|
307
318
|
}
|
|
308
319
|
}
|
|
@@ -536,6 +547,7 @@ function ensurePrivateDir(dir: string): string | null {
|
|
|
536
547
|
// enumeration on the bench workspace before its generator ignored `.vx`. An
|
|
537
548
|
// ignored directory is skipped by the walk entirely. Only written when
|
|
538
549
|
// absent, so a user's own file wins.
|
|
550
|
+
const utf8 = new TextEncoder()
|
|
539
551
|
const IGNORE_ALL = '*\n'
|
|
540
552
|
|
|
541
553
|
/**
|
|
@@ -830,6 +842,7 @@ export class Cache implements CacheLayer {
|
|
|
830
842
|
if (mode !== 'open') {
|
|
831
843
|
throw new UserError(
|
|
832
844
|
`the cache at ${cacheDir} holds index schema ${found} from another vx version; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here, and this verb leaves it untouched. The next \`vx run\` resets it`,
|
|
845
|
+
'VX_E_CACHE_VERSION',
|
|
833
846
|
)
|
|
834
847
|
}
|
|
835
848
|
}
|
|
@@ -966,7 +979,7 @@ export class Cache implements CacheLayer {
|
|
|
966
979
|
)
|
|
967
980
|
this.upsertStdout = lazyStatement(
|
|
968
981
|
this.db,
|
|
969
|
-
'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
|
|
982
|
+
'INSERT INTO entry_stdout(hash, stdout) VALUES (?, CAST(? AS TEXT)) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
|
|
970
983
|
)
|
|
971
984
|
this.deleteStdout = lazyStatement(this.db, 'DELETE FROM entry_stdout WHERE hash = ?')
|
|
972
985
|
this.selectEntry = lazyStatement(this.db, `${SELECT_ENTRY} WHERE e.hash = ?`)
|
|
@@ -1347,7 +1360,7 @@ export class Cache implements CacheLayer {
|
|
|
1347
1360
|
// full-cache run. Hashes are collected and flushed as ONE batched
|
|
1348
1361
|
// UPDATE by flushAccessed() (called from prune/stats/close), which
|
|
1349
1362
|
// is when accessed_at is actually read.
|
|
1350
|
-
this.touched.add(hash)
|
|
1363
|
+
if (row.accessed_at < Date.now() - ACCESS_REFRESH_MS) this.touched.add(hash)
|
|
1351
1364
|
|
|
1352
1365
|
// Pure SQL: outputFiles come from the output_files rows and
|
|
1353
1366
|
// stdout from the entries row. The artifact is NOT touched here —
|
|
@@ -1392,6 +1405,7 @@ export class Cache implements CacheLayer {
|
|
|
1392
1405
|
const rows = this.db.query(`${SELECT_ENTRY} WHERE e.hash ${test}`).all(...params) as EntryRow[]
|
|
1393
1406
|
if (rows.length === 0) return out
|
|
1394
1407
|
const graceStart = Date.now() - ORPHAN_GRACE_MS
|
|
1408
|
+
const refreshStart = Date.now() - ACCESS_REFRESH_MS
|
|
1395
1409
|
const live = rows.filter((r) => {
|
|
1396
1410
|
const st = statSync(this.tarPath(r.hash), { throwIfNoEntry: false })
|
|
1397
1411
|
if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
|
|
@@ -1402,7 +1416,7 @@ export class Cache implements CacheLayer {
|
|
|
1402
1416
|
this.outputs.hold(fileRows)
|
|
1403
1417
|
const dirRows = this.loadOutputDirsBatch(liveHashes)
|
|
1404
1418
|
for (const row of live) {
|
|
1405
|
-
this.touched.add(row.hash)
|
|
1419
|
+
if (row.accessed_at < refreshStart) this.touched.add(row.hash)
|
|
1406
1420
|
const entry = entryOf(row, fileRows.get(row.hash) ?? [])
|
|
1407
1421
|
entry.outputDirRows = dirRows.get(row.hash) ?? []
|
|
1408
1422
|
out.set(row.hash, entry)
|
|
@@ -1990,17 +2004,20 @@ export class Cache implements CacheLayer {
|
|
|
1990
2004
|
}
|
|
1991
2005
|
let scanned: Awaited<ReturnType<typeof scanArtifact>>
|
|
1992
2006
|
try {
|
|
1993
|
-
// ingest() is the UNTRUSTED boundary — its temp holds bytes just
|
|
1994
|
-
// pulled from a remote. Refuse a bomb (declared or sizeless) before it
|
|
1995
|
-
// can expand into memory.
|
|
1996
|
-
const source =
|
|
1997
|
-
compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
|
|
1998
|
-
? compressed
|
|
1999
|
-
: Bun.file(tmpPath)
|
|
2000
2007
|
const endScan = span('save: scan')
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2008
|
+
if (tar !== undefined) {
|
|
2009
|
+
// Our own tar, in memory: read in place, on this thread.
|
|
2010
|
+
scanned = scanTarBytes(tar)
|
|
2011
|
+
} else {
|
|
2012
|
+
// ingest() is the UNTRUSTED boundary — its temp holds bytes just
|
|
2013
|
+
// pulled from a remote. Refuse a bomb (declared or sizeless) before it
|
|
2014
|
+
// can expand into memory.
|
|
2015
|
+
const source =
|
|
2016
|
+
compressed instanceof Uint8Array && compressed.byteLength <= STREAM_DECODE_FROM
|
|
2017
|
+
? compressed
|
|
2018
|
+
: Bun.file(tmpPath)
|
|
2019
|
+
scanned = await scanArtifact(await decodedTar(source, hash, this.artifactCeiling))
|
|
2020
|
+
}
|
|
2004
2021
|
endScan()
|
|
2005
2022
|
// v17 invariant: every artifact carries a `stdout` entry. Its
|
|
2006
2023
|
// absence means the bytes decompressed but aren't a vx artifact.
|
|
@@ -2118,7 +2135,13 @@ export class Cache implements CacheLayer {
|
|
|
2118
2135
|
scanned.exec?.peakRssBytes ?? null,
|
|
2119
2136
|
)
|
|
2120
2137
|
if (stdoutText === '') deleteStdout.run(hash)
|
|
2121
|
-
|
|
2138
|
+
// bun:sqlite drops a leading U+FEFF from a bound string; its UTF-8
|
|
2139
|
+
// bytes, cast to TEXT, keep it.
|
|
2140
|
+
else
|
|
2141
|
+
upsertStdout.run(
|
|
2142
|
+
hash,
|
|
2143
|
+
stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
|
|
2144
|
+
)
|
|
2122
2145
|
outputs.replaceFileRows(hash, outputFileRows)
|
|
2123
2146
|
// INSERT OR IGNORE: identical inputs derive this same hash, so a
|
|
2124
2147
|
// re-save's rows are identical — keep the first set, skip the rest.
|
|
@@ -2143,7 +2166,8 @@ export class Cache implements CacheLayer {
|
|
|
2143
2166
|
|
|
2144
2167
|
/** Apply the deferred accessed_at bumps in one statement. */
|
|
2145
2168
|
private flushAccessed(): void {
|
|
2146
|
-
|
|
2169
|
+
// Apart: a hit whose row is fresh still renews an old file time.
|
|
2170
|
+
if (this.touched.size === 0 && this.staleTimes.size === 0) return
|
|
2147
2171
|
const hashes = [...this.touched]
|
|
2148
2172
|
this.touched.clear()
|
|
2149
2173
|
const now = Date.now()
|
|
@@ -2158,6 +2182,7 @@ export class Cache implements CacheLayer {
|
|
|
2158
2182
|
}
|
|
2159
2183
|
}
|
|
2160
2184
|
this.staleTimes.clear()
|
|
2185
|
+
if (hashes.length === 0) return
|
|
2161
2186
|
// LRU bookkeeping: on a full disk the bumps are dropped, never the prune
|
|
2162
2187
|
// or the stats that asked for them (A-14).
|
|
2163
2188
|
try {
|
|
@@ -2260,7 +2285,8 @@ export class Cache implements CacheLayer {
|
|
|
2260
2285
|
'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
|
|
2261
2286
|
)
|
|
2262
2287
|
.get() as { oldest: number | null; bytes: number }
|
|
2263
|
-
const ageDue =
|
|
2288
|
+
const ageDue =
|
|
2289
|
+
olderThanMs !== undefined && oldest !== null && oldest + ACCESS_REFRESH_MS < olderThanMs
|
|
2264
2290
|
const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
|
|
2265
2291
|
if (ageDue || sizeDue) {
|
|
2266
2292
|
return this.prune({
|
package/src/cache/layer.ts
CHANGED
|
@@ -215,7 +215,8 @@ export interface RunRecord {
|
|
|
215
215
|
/**
|
|
216
216
|
* Why the task failed or was skipped, as the run's own footer said it
|
|
217
217
|
* (v27 columns; NULL on older rows and where the reason does not apply):
|
|
218
|
-
* a skip's root blocker
|
|
218
|
+
* a skip's root blocker (or the one a task that ran behind a failure ran
|
|
219
|
+
* behind), vx's own timeout, the sandbox's violation count,
|
|
219
220
|
* and why a persistent task never became ready.
|
|
220
221
|
*/
|
|
221
222
|
blockedBy?: string
|
package/src/cache/schema.ts
CHANGED
|
@@ -95,7 +95,8 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
95
95
|
-- time by design; NULL on rows older than the column.
|
|
96
96
|
cached INTEGER,
|
|
97
97
|
-- v27: why a task failed or was skipped, as the run's own footer
|
|
98
|
-
-- said it (items 267–270): a skip's root blocker (a task id),
|
|
98
|
+
-- said it (items 267–270): a skip's root blocker (a task id), or
|
|
99
|
+
-- the one a task that ran behind a failure ran behind (X-183), vx's
|
|
99
100
|
-- own timeout (1), the sandbox's violation count, and why a
|
|
100
101
|
-- persistent task never became ready ('timeout' | 'exited' |
|
|
101
102
|
-- 'spawn'). NULL where the reason does not apply.
|