@vzn/vx 0.0.648 → 0.0.650
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 +24 -27
- package/src/cache/fold-memo.ts +8 -1
- package/src/cache/index.ts +11 -0
- package/src/cache/lockfile-memo.ts +119 -0
- package/src/cache/remote-hits.ts +9 -1
- package/src/cache/run-notes.ts +123 -0
- package/src/cache/schema.ts +43 -0
- package/src/cli/last.ts +12 -21
- package/src/cli/plugin-commands.ts +2 -1
- package/src/cli/why.ts +3 -3
- package/src/graph/scheduler.ts +11 -5
- package/src/history/history.ts +34 -2
- package/src/history/index.ts +3 -0
- package/src/index.ts +9 -2
- package/src/orchestrator/doctor.ts +2 -2
- package/src/orchestrator/hit-restore.ts +3 -0
- package/src/orchestrator/index.ts +0 -1
- package/src/orchestrator/live-usage.ts +93 -0
- package/src/orchestrator/lockfile-claim.ts +74 -79
- package/src/orchestrator/plugin.ts +2 -2
- package/src/orchestrator/prepare.ts +2 -1
- package/src/orchestrator/run-failures.ts +34 -113
- package/src/orchestrator/run.ts +47 -15
- package/src/orchestrator/steps.ts +2 -2
- package/src/report/events.ts +11 -3
- package/src/report/forecast.ts +24 -23
- package/src/report/framed-output.ts +156 -28
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +44 -15
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +38 -14
- package/src/report/summary.ts +4 -0
- package/src/util/colors.ts +3 -0
- package/src/util/hidden-prompt.ts +8 -2
- package/src/util/secrets.ts +3 -0
- package/src/orchestrator/run-steps.ts +0 -93
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.650",
|
|
4
4
|
"description": "Task orchestration you own: correct, sandboxed, extensible, with a content-addressed build cache.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.650",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.650",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.650",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.650"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -117,8 +117,8 @@ import { FileHashStore } from './file-hashes.js'
|
|
|
117
117
|
import { DIR_MODE, LINK_MODE, OutputIndex } from './output-index.js'
|
|
118
118
|
import { RunHistory } from './run-history.js'
|
|
119
119
|
import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
120
|
-
import { FoldMemo } from './fold-memo.js'
|
|
121
|
-
import { RemoteHits } from './remote-hits.js'
|
|
120
|
+
import { type FoldMemo, IndexFoldMemo } from './fold-memo.js'
|
|
121
|
+
import { IndexRemoteHits, type RemoteHits } from './remote-hits.js'
|
|
122
122
|
import { behindLink } from './linked-inputs.js'
|
|
123
123
|
|
|
124
124
|
/**
|
|
@@ -164,13 +164,8 @@ const VX_CACHE_NAMES = new Set([
|
|
|
164
164
|
'cache.db-wal',
|
|
165
165
|
'cache.db-shm',
|
|
166
166
|
'cache.db-journal',
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// `.vx/cache/compiled`, so a `runs` or `compiled` here is the user's.
|
|
170
|
-
'failures',
|
|
171
|
-
'steps',
|
|
172
|
-
'warnings',
|
|
173
|
-
'lockfile-claims',
|
|
167
|
+
// Every record vx keeps lives in cache.db, and the config bundle always
|
|
168
|
+
// in `.vx/cache/compiled`, so any directory here is the user's.
|
|
174
169
|
])
|
|
175
170
|
|
|
176
171
|
/**
|
|
@@ -357,7 +352,10 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
|
|
|
357
352
|
// v49: runs.cpu_share — the share of a core each runnable thread got
|
|
358
353
|
// where the task ran, so a crowded run's CPU reads as need. The
|
|
359
354
|
// cache KEY is unchanged.
|
|
360
|
-
|
|
355
|
+
// v50: steps, warnings, failures and lockfile_memos — what a run said
|
|
356
|
+
// and a lockfile claimant parsed, files under the cache dir until
|
|
357
|
+
// now (if it can be in the DB, it is). The cache KEY is unchanged.
|
|
358
|
+
export const SCHEMA_VERSION = 'v50'
|
|
361
359
|
|
|
362
360
|
/**
|
|
363
361
|
* An entry's key components as its one `entry_inputs` row (v34): JSON
|
|
@@ -948,7 +946,7 @@ export class Cache implements CacheLayer {
|
|
|
948
946
|
/** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
|
|
949
947
|
private readonly files: FileHashStore
|
|
950
948
|
private readonly outputs: OutputIndex
|
|
951
|
-
private readonly folds:
|
|
949
|
+
private readonly folds: IndexFoldMemo
|
|
952
950
|
private readonly history: RunHistory
|
|
953
951
|
/** What the remotes said they hold, across runs (remote-hits.ts). */
|
|
954
952
|
readonly remoteHits: RemoteHits
|
|
@@ -1172,7 +1170,7 @@ export class Cache implements CacheLayer {
|
|
|
1172
1170
|
// keeps it so).
|
|
1173
1171
|
if (found !== undefined) {
|
|
1174
1172
|
this.db.exec(
|
|
1175
|
-
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits;',
|
|
1173
|
+
'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits; DROP TABLE IF EXISTS main.steps; DROP TABLE IF EXISTS main.warnings; DROP TABLE IF EXISTS main.failures; DROP TABLE IF EXISTS main.lockfile_memos;',
|
|
1176
1174
|
)
|
|
1177
1175
|
}
|
|
1178
1176
|
// The tables land under the same write lock as the stamp: made
|
|
@@ -1336,9 +1334,9 @@ export class Cache implements CacheLayer {
|
|
|
1336
1334
|
// the schema above is the one place every table is declared.
|
|
1337
1335
|
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
1338
1336
|
this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1339
|
-
this.folds = new
|
|
1337
|
+
this.folds = new IndexFoldMemo(this.db, this.write && !this.inspecting)
|
|
1340
1338
|
this.history = new RunHistory(this.db, (v) => this.digestValue(v))
|
|
1341
|
-
this.remoteHits = new
|
|
1339
|
+
this.remoteHits = new IndexRemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
|
|
1342
1340
|
|
|
1343
1341
|
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
1344
1342
|
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
@@ -3210,17 +3208,6 @@ export class Cache implements CacheLayer {
|
|
|
3210
3208
|
this.db.prepare(`UPDATE entries SET accessed_at = ? WHERE hash ${test}`).run(now, ...params)
|
|
3211
3209
|
}
|
|
3212
3210
|
|
|
3213
|
-
/**
|
|
3214
|
-
* Raw `bun:sqlite` Database handle. Exposed for subsystems that
|
|
3215
|
-
* need to issue their own queries (LocalHistoryProvider's CTE,
|
|
3216
|
-
* future analytics consumers) without us proxying every method.
|
|
3217
|
-
* Callers must NOT close the handle directly — `Cache.close()` owns
|
|
3218
|
-
* the lifetime.
|
|
3219
|
-
*/
|
|
3220
|
-
dbHandle(): Database {
|
|
3221
|
-
return this.db
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
3211
|
// --- run history: delegated to `RunHistory` (see run-history.ts) ---
|
|
3225
3212
|
// A run over a directory it cannot write records no history:
|
|
3226
3213
|
// `readOnlyNotice` said so once.
|
|
@@ -3564,7 +3551,17 @@ export class Cache implements CacheLayer {
|
|
|
3564
3551
|
}
|
|
3565
3552
|
|
|
3566
3553
|
/** SQLite's file-control opcode (sqlite3.h). */
|
|
3567
|
-
const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
3554
|
+
export const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
3555
|
+
|
|
3556
|
+
/**
|
|
3557
|
+
* The cache's `bun:sqlite` handle, for core's own readers (`vx last`, `vx
|
|
3558
|
+
* why`, the history provider). A function, not a member: the façade hands
|
|
3559
|
+
* `Cache` to plugins and no plugin touches cache.db (2026-10-29). Callers
|
|
3560
|
+
* never close it — `Cache.close()` owns the lifetime.
|
|
3561
|
+
*/
|
|
3562
|
+
export function indexDb(cache: Cache): Database {
|
|
3563
|
+
return cache['db']
|
|
3564
|
+
}
|
|
3568
3565
|
|
|
3569
3566
|
/**
|
|
3570
3567
|
* Close for real. A plain `close()` leaves the connection open while any
|
|
@@ -3577,7 +3574,7 @@ const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
|
3577
3574
|
* `vx run` exited 1 with that stack after its tasks had passed (M-44). There
|
|
3578
3575
|
* the deferred close is the one that closes.
|
|
3579
3576
|
*/
|
|
3580
|
-
function closeDb(db: Database): void {
|
|
3577
|
+
export function closeDb(db: Database): void {
|
|
3581
3578
|
try {
|
|
3582
3579
|
db.close(true)
|
|
3583
3580
|
} catch {
|
package/src/cache/fold-memo.ts
CHANGED
|
@@ -13,7 +13,14 @@ export interface FoldMemoRow {
|
|
|
13
13
|
key: string
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
/** What a key fold asks of the memo; the rows stay core's (`IndexFoldMemo`). */
|
|
17
|
+
export interface FoldMemo {
|
|
18
|
+
/** The key remembered for `taskId` under `probe`, if that is the probe it was folded under. */
|
|
19
|
+
get(taskId: string, probe: string): string | undefined
|
|
20
|
+
set(taskId: string, probe: string, key: string): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class IndexFoldMemo implements FoldMemo {
|
|
17
24
|
private rows: Map<string, FoldMemoRow> | undefined
|
|
18
25
|
private readonly dirty = new Map<string, FoldMemoRow>()
|
|
19
26
|
|
package/src/cache/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
type CachePolicy,
|
|
19
19
|
cachesNothing,
|
|
20
20
|
EXECUTED_RUNS_SQL,
|
|
21
|
+
indexDb,
|
|
21
22
|
formatCachePolicy,
|
|
22
23
|
FULL_CACHE_POLICY,
|
|
23
24
|
type InvocationRecord,
|
|
@@ -102,3 +103,13 @@ export {
|
|
|
102
103
|
} from './plugin-cache.js'
|
|
103
104
|
export { packRunOutputs, putRunEntry, runEntryName, writesRunStore } from './run-store.js'
|
|
104
105
|
export { branchSource } from './run-history.js'
|
|
106
|
+
export {
|
|
107
|
+
type KeptFailure,
|
|
108
|
+
keepRunNotes,
|
|
109
|
+
keptFailures,
|
|
110
|
+
keptSteps,
|
|
111
|
+
keptWarnings,
|
|
112
|
+
type RunNotes,
|
|
113
|
+
type RunStep,
|
|
114
|
+
} from './run-notes.js'
|
|
115
|
+
export { readLockfileMemo, writeLockfileMemo, type LockfileMemo } from './lockfile-memo.js'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// A lockfile claimant's parse, kept in cache.db's `lockfile_memos` (v50; a
|
|
2
|
+
// `lockfile-claims/` file before): what orchestrator/lockfile-claim.ts reads
|
|
3
|
+
// so a warm run never parses the lockfile. Its readers run where no `Cache`
|
|
4
|
+
// is open yet — a `discover` hook, `--affected`'s selection, a verb that
|
|
5
|
+
// opens none — so each call opens the index on its own and closes it. Only
|
|
6
|
+
// an index that exists and holds this vx's schema is read or written: one
|
|
7
|
+
// another vx wrote is reset by the next run, and a directory with none is
|
|
8
|
+
// left for the run that makes it. A failure of any kind is a miss: the memo
|
|
9
|
+
// is a speed-up, never a requirement.
|
|
10
|
+
|
|
11
|
+
import { Database } from 'bun:sqlite'
|
|
12
|
+
import { existsSync } from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import { closeDb, SCHEMA_VERSION, SQLITE_FCNTL_PERSIST_WAL } from './cache.js'
|
|
15
|
+
|
|
16
|
+
/** One claimant's parse of one lockfile's content. */
|
|
17
|
+
export interface LockfileMemo {
|
|
18
|
+
/** The claimant's own counter: bumped when it digests differently. */
|
|
19
|
+
version: number
|
|
20
|
+
/** xxh3 of the lockfile's bytes. */
|
|
21
|
+
lock: string
|
|
22
|
+
/** Each extra file the digests read → its content hash then. */
|
|
23
|
+
extras: Record<string, string>
|
|
24
|
+
importers: Record<string, string>
|
|
25
|
+
edges: Record<string, unknown>
|
|
26
|
+
/** Each importer's recorded dependency names; absent when the claimant reads none. */
|
|
27
|
+
recorded?: Record<string, readonly string[]>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withIndex<T>(cacheDir: string, use: (db: Database) => T): T | undefined {
|
|
31
|
+
const file = path.join(cacheDir, 'cache.db')
|
|
32
|
+
if (!existsSync(file)) return undefined
|
|
33
|
+
let db: Database
|
|
34
|
+
try {
|
|
35
|
+
db = new Database(file, { readwrite: true })
|
|
36
|
+
} catch {
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
// As `Cache` opens it: wait for another run's write, and keep `-wal`
|
|
41
|
+
// and `-shm` when this is the last connection to close (O-10).
|
|
42
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
43
|
+
db.fileControl(SQLITE_FCNTL_PERSIST_WAL, 1)
|
|
44
|
+
db.exec('PRAGMA journal_size_limit = 67108864')
|
|
45
|
+
return use(db)
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
} finally {
|
|
49
|
+
closeDb(db)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CURRENT = "(SELECT value FROM schema_meta WHERE key = 'version') = ?"
|
|
54
|
+
|
|
55
|
+
/** The memo `claimant` kept of `file`; undefined when none or another content's. */
|
|
56
|
+
export function readLockfileMemo(
|
|
57
|
+
cacheDir: string,
|
|
58
|
+
file: string,
|
|
59
|
+
claimant: string,
|
|
60
|
+
): LockfileMemo | undefined {
|
|
61
|
+
return withIndex(cacheDir, (db) => {
|
|
62
|
+
const row = db
|
|
63
|
+
.query(
|
|
64
|
+
`SELECT version, lock, extras, importers, edges, recorded FROM lockfile_memos
|
|
65
|
+
WHERE file = ? AND claimant = ? AND ${CURRENT}`,
|
|
66
|
+
)
|
|
67
|
+
.get(file, claimant, SCHEMA_VERSION) as {
|
|
68
|
+
version: number
|
|
69
|
+
lock: string
|
|
70
|
+
extras: string
|
|
71
|
+
importers: string
|
|
72
|
+
edges: string
|
|
73
|
+
recorded: string | null
|
|
74
|
+
} | null
|
|
75
|
+
if (row === null) return undefined
|
|
76
|
+
return {
|
|
77
|
+
version: row.version,
|
|
78
|
+
lock: row.lock,
|
|
79
|
+
extras: JSON.parse(row.extras) as Record<string, string>,
|
|
80
|
+
importers: JSON.parse(row.importers) as Record<string, string>,
|
|
81
|
+
edges: JSON.parse(row.edges) as Record<string, unknown>,
|
|
82
|
+
...(row.recorded !== null
|
|
83
|
+
? { recorded: JSON.parse(row.recorded) as Record<string, readonly string[]> }
|
|
84
|
+
: {}),
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Keep `memo` as `claimant`'s of `file`, replacing the last; false when it was not kept. */
|
|
90
|
+
export function writeLockfileMemo(
|
|
91
|
+
cacheDir: string,
|
|
92
|
+
file: string,
|
|
93
|
+
claimant: string,
|
|
94
|
+
memo: LockfileMemo,
|
|
95
|
+
): boolean {
|
|
96
|
+
return (
|
|
97
|
+
withIndex(
|
|
98
|
+
cacheDir,
|
|
99
|
+
(db) =>
|
|
100
|
+
db
|
|
101
|
+
.query(
|
|
102
|
+
`INSERT OR REPLACE INTO lockfile_memos
|
|
103
|
+
(file, claimant, version, lock, extras, importers, edges, recorded)
|
|
104
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ? WHERE ${CURRENT}`,
|
|
105
|
+
)
|
|
106
|
+
.run(
|
|
107
|
+
file,
|
|
108
|
+
claimant,
|
|
109
|
+
memo.version,
|
|
110
|
+
memo.lock,
|
|
111
|
+
JSON.stringify(memo.extras),
|
|
112
|
+
JSON.stringify(memo.importers),
|
|
113
|
+
JSON.stringify(memo.edges),
|
|
114
|
+
memo.recorded === undefined ? null : JSON.stringify(memo.recorded),
|
|
115
|
+
SCHEMA_VERSION,
|
|
116
|
+
).changes > 0,
|
|
117
|
+
) ?? false
|
|
118
|
+
)
|
|
119
|
+
}
|
package/src/cache/remote-hits.ts
CHANGED
|
@@ -12,7 +12,15 @@ export interface RemoteHit {
|
|
|
12
12
|
readonly durationMs?: number
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/** What a layered cache asks of the remote-hit rows; the rows stay core's (`IndexRemoteHits`). */
|
|
16
|
+
export interface RemoteHits {
|
|
17
|
+
/** The keys of `hashes` a remote last said it holds. */
|
|
18
|
+
get(hashes: readonly string[]): Map<string, RemoteHit>
|
|
19
|
+
record(asked: readonly string[], found: ReadonlyMap<string, RemoteHit>, now?: number): void
|
|
20
|
+
forget(hash: string): void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class IndexRemoteHits implements RemoteHits {
|
|
16
24
|
constructor(
|
|
17
25
|
private readonly db: Database,
|
|
18
26
|
/** Off for a reading verb or an index this user cannot write. */
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// What a run said that its `runs` rows do not hold, for `vx last`: the steps
|
|
2
|
+
// it did not wait on (`vx:prefetch`, 2026-10-16), which never show in a
|
|
3
|
+
// run's output; its warnings, which scroll past on a terminal (an agent
|
|
4
|
+
// reading the run afterwards saw only the footer's count, 2026-10-28); and
|
|
5
|
+
// each failed task's output and the files it names. Rows of cache.db's
|
|
6
|
+
// `steps`, `warnings` and `failures` (v50; files under the cache dir
|
|
7
|
+
// before), so `vx last --sql` reads them too. The newest `KEPT` runs that
|
|
8
|
+
// had any are kept, per table. A run with none writes nothing.
|
|
9
|
+
|
|
10
|
+
import type { Database } from 'bun:sqlite'
|
|
11
|
+
|
|
12
|
+
/** Runs whose steps, warnings or failures are kept; run ids are UUIDv7, so they sort by age. */
|
|
13
|
+
const KEPT = 50
|
|
14
|
+
|
|
15
|
+
/** One of vx's own steps that ran beside the tasks. */
|
|
16
|
+
export interface RunStep {
|
|
17
|
+
id: string
|
|
18
|
+
durationMs: number
|
|
19
|
+
ok: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A failed task's kept output: plain, bounded, secrets masked. */
|
|
23
|
+
export interface KeptFailure {
|
|
24
|
+
taskId: string
|
|
25
|
+
inputMoved?: string
|
|
26
|
+
output: string
|
|
27
|
+
locations: readonly unknown[]
|
|
28
|
+
sandboxFix?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RunNotes {
|
|
32
|
+
readonly steps?: readonly RunStep[]
|
|
33
|
+
readonly warnings?: readonly string[]
|
|
34
|
+
readonly failures?: readonly KeptFailure[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Drop every row of `table` older than its newest `KEPT` runs. */
|
|
38
|
+
function trim(db: Database, table: 'steps' | 'warnings' | 'failures'): void {
|
|
39
|
+
db.query(
|
|
40
|
+
`DELETE FROM ${table} WHERE run_id < (SELECT run_id FROM (SELECT DISTINCT run_id FROM ${table} ORDER BY run_id DESC LIMIT 1 OFFSET ${KEPT - 1}))`,
|
|
41
|
+
).run()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Write `notes` for `runId` in one transaction, then keep the newest `KEPT` runs of each table written. */
|
|
45
|
+
export function keepRunNotes(db: Database, runId: string, notes: RunNotes): void {
|
|
46
|
+
const steps = notes.steps ?? []
|
|
47
|
+
const warnings = notes.warnings ?? []
|
|
48
|
+
const failures = notes.failures ?? []
|
|
49
|
+
if (steps.length + warnings.length + failures.length === 0) return
|
|
50
|
+
db.transaction(() => {
|
|
51
|
+
if (steps.length > 0) {
|
|
52
|
+
const insert = db.query(
|
|
53
|
+
'INSERT OR REPLACE INTO steps(run_id, seq, step, duration_ms, ok) VALUES (?, ?, ?, ?, ?)',
|
|
54
|
+
)
|
|
55
|
+
steps.forEach((s, i) => insert.run(runId, i, s.id, Math.round(s.durationMs), s.ok ? 1 : 0))
|
|
56
|
+
trim(db, 'steps')
|
|
57
|
+
}
|
|
58
|
+
if (warnings.length > 0) {
|
|
59
|
+
const insert = db.query('INSERT OR REPLACE INTO warnings(run_id, seq, text) VALUES (?, ?, ?)')
|
|
60
|
+
warnings.forEach((w, i) => insert.run(runId, i, w))
|
|
61
|
+
trim(db, 'warnings')
|
|
62
|
+
}
|
|
63
|
+
if (failures.length > 0) {
|
|
64
|
+
const insert = db.query(
|
|
65
|
+
`INSERT OR REPLACE INTO failures(run_id, task_id, input_moved, output, locations, sandbox_fix)
|
|
66
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
67
|
+
)
|
|
68
|
+
for (const f of failures) {
|
|
69
|
+
insert.run(
|
|
70
|
+
runId,
|
|
71
|
+
f.taskId,
|
|
72
|
+
f.inputMoved ?? null,
|
|
73
|
+
f.output,
|
|
74
|
+
JSON.stringify(f.locations),
|
|
75
|
+
f.sandboxFix === undefined ? null : JSON.stringify(f.sandboxFix),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
trim(db, 'failures')
|
|
79
|
+
}
|
|
80
|
+
}).immediate()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The background steps `runId` kept, in the order they ended; none when it had none. */
|
|
84
|
+
export function keptSteps(db: Database, runId: string): RunStep[] {
|
|
85
|
+
const rows = db
|
|
86
|
+
.query('SELECT step, duration_ms, ok FROM steps WHERE run_id = ? ORDER BY seq')
|
|
87
|
+
.all(runId) as { step: string; duration_ms: number; ok: number }[]
|
|
88
|
+
return rows.map((r) => ({ id: r.step, durationMs: r.duration_ms, ok: r.ok === 1 }))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The warnings `runId` said, colour stripped; none when it said none. */
|
|
92
|
+
export function keptWarnings(db: Database, runId: string): string[] {
|
|
93
|
+
const rows = db.query('SELECT text FROM warnings WHERE run_id = ? ORDER BY seq').all(runId) as {
|
|
94
|
+
text: string
|
|
95
|
+
}[]
|
|
96
|
+
return rows.map((r) => r.text)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What `runId` kept of its failed tasks, by task id; empty when it kept none. */
|
|
100
|
+
export function keptFailures(db: Database, runId: string): Map<string, KeptFailure> {
|
|
101
|
+
const rows = db
|
|
102
|
+
.query(
|
|
103
|
+
'SELECT task_id, input_moved, output, locations, sandbox_fix FROM failures WHERE run_id = ?',
|
|
104
|
+
)
|
|
105
|
+
.all(runId) as {
|
|
106
|
+
task_id: string
|
|
107
|
+
input_moved: string | null
|
|
108
|
+
output: string
|
|
109
|
+
locations: string
|
|
110
|
+
sandbox_fix: string | null
|
|
111
|
+
}[]
|
|
112
|
+
const kept = new Map<string, KeptFailure>()
|
|
113
|
+
for (const r of rows) {
|
|
114
|
+
kept.set(r.task_id, {
|
|
115
|
+
taskId: r.task_id,
|
|
116
|
+
...(r.input_moved !== null ? { inputMoved: r.input_moved } : {}),
|
|
117
|
+
output: r.output,
|
|
118
|
+
locations: JSON.parse(r.locations) as unknown[],
|
|
119
|
+
...(r.sandbox_fix !== null ? { sandboxFix: JSON.parse(r.sandbox_fix) as unknown } : {}),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return kept
|
|
123
|
+
}
|
package/src/cache/schema.ts
CHANGED
|
@@ -261,6 +261,49 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
261
261
|
arch TEXT,
|
|
262
262
|
vx_version TEXT NOT NULL
|
|
263
263
|
);
|
|
264
|
+
-- v50: what a run said that its runs rows do not hold (run-notes.ts),
|
|
265
|
+
-- for \`vx last\`: the steps it did not wait on (\`vx:prefetch\`), its
|
|
266
|
+
-- warnings, colour stripped, and each failed task's output, plain,
|
|
267
|
+
-- bounded and masked, with the files it names (JSON). Rows of the
|
|
268
|
+
-- newest 50 runs that had any; run ids are UUIDv7, so they sort by age.
|
|
269
|
+
CREATE TABLE IF NOT EXISTS main.steps (
|
|
270
|
+
run_id TEXT NOT NULL,
|
|
271
|
+
seq INTEGER NOT NULL,
|
|
272
|
+
step TEXT NOT NULL,
|
|
273
|
+
duration_ms INTEGER NOT NULL,
|
|
274
|
+
ok INTEGER NOT NULL,
|
|
275
|
+
PRIMARY KEY (run_id, seq)
|
|
276
|
+
);
|
|
277
|
+
CREATE TABLE IF NOT EXISTS main.warnings (
|
|
278
|
+
run_id TEXT NOT NULL,
|
|
279
|
+
seq INTEGER NOT NULL,
|
|
280
|
+
text TEXT NOT NULL,
|
|
281
|
+
PRIMARY KEY (run_id, seq)
|
|
282
|
+
);
|
|
283
|
+
CREATE TABLE IF NOT EXISTS main.failures (
|
|
284
|
+
run_id TEXT NOT NULL,
|
|
285
|
+
task_id TEXT NOT NULL,
|
|
286
|
+
input_moved TEXT,
|
|
287
|
+
output TEXT NOT NULL,
|
|
288
|
+
locations TEXT NOT NULL,
|
|
289
|
+
sandbox_fix TEXT,
|
|
290
|
+
PRIMARY KEY (run_id, task_id)
|
|
291
|
+
);
|
|
292
|
+
-- v50: a lockfile claimant's parse (orchestrator/lockfile-claim.ts): one
|
|
293
|
+
-- row per lockfile and claimant, read while \`version\` and \`lock\` (the
|
|
294
|
+
-- file's xxh3) match, replaced when they do not. JSON: the extra files'
|
|
295
|
+
-- hashes, the importers' digests and edges, the names each records.
|
|
296
|
+
CREATE TABLE IF NOT EXISTS main.lockfile_memos (
|
|
297
|
+
file TEXT NOT NULL,
|
|
298
|
+
claimant TEXT NOT NULL,
|
|
299
|
+
version INTEGER NOT NULL,
|
|
300
|
+
lock TEXT NOT NULL,
|
|
301
|
+
extras TEXT NOT NULL,
|
|
302
|
+
importers TEXT NOT NULL,
|
|
303
|
+
edges TEXT NOT NULL,
|
|
304
|
+
recorded TEXT,
|
|
305
|
+
PRIMARY KEY (file, claimant)
|
|
306
|
+
);
|
|
264
307
|
CREATE INDEX IF NOT EXISTS main.invocations_started ON invocations(started_at);
|
|
265
308
|
CREATE INDEX IF NOT EXISTS main.invocations_branch ON invocations(branch);
|
|
266
309
|
CREATE INDEX IF NOT EXISTS main.invocations_ci ON invocations(ci);
|
package/src/cli/last.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Read-only — no config evaluation, no re-hash, no cache probe.
|
|
6
6
|
|
|
7
7
|
import type { Database } from 'bun:sqlite'
|
|
8
|
-
import { Cache } from '../cache/index.js'
|
|
8
|
+
import { indexDb, Cache, keptSteps, keptWarnings, type RunStep } from '../cache/index.js'
|
|
9
9
|
import { formatBytes } from './format.js'
|
|
10
10
|
import { flagHint, formatValue, seeHelp } from './help.js'
|
|
11
11
|
import {
|
|
@@ -24,9 +24,6 @@ import {
|
|
|
24
24
|
exitSignal,
|
|
25
25
|
resolveRunId,
|
|
26
26
|
runFailures,
|
|
27
|
-
type RunStep,
|
|
28
|
-
runSteps,
|
|
29
|
-
runWarnings,
|
|
30
27
|
shellQuote,
|
|
31
28
|
shortRunId,
|
|
32
29
|
taskLog,
|
|
@@ -358,8 +355,9 @@ async function printStats(
|
|
|
358
355
|
}
|
|
359
356
|
|
|
360
357
|
/**
|
|
361
|
-
* `--sql`: one SELECT over the history (`runs`, `invocations`,
|
|
362
|
-
* store's `entries`, `entry_inputs`,
|
|
358
|
+
* `--sql`: one SELECT over the history (`runs`, `invocations`, `steps`,
|
|
359
|
+
* `warnings`, `failures`, and the store's `entries`, `entry_inputs`,
|
|
360
|
+
* `task_durations`, `task_outcomes`),
|
|
363
361
|
* so an agent asks what no flag answers: a task's memory trend, the
|
|
364
362
|
* flakiest project, which input moved a key. `query_only` stops a write
|
|
365
363
|
* the statement hides (`WITH … DELETE`); it does not stop ATTACH, which
|
|
@@ -806,7 +804,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
806
804
|
const cacheDir = await cliCacheDir(root, parsed.cacheDir)
|
|
807
805
|
const cache = Cache.inspect(cacheDir)
|
|
808
806
|
try {
|
|
809
|
-
const db = cache
|
|
807
|
+
const db = indexDb(cache)
|
|
810
808
|
// Nothing recorded at all is its own answer: `--failed` said "no
|
|
811
809
|
// recorded run failed" and a run id pointed at a `--list` that lists
|
|
812
810
|
// nothing, while `--list --failed` past green runs said there were none.
|
|
@@ -825,12 +823,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
825
823
|
parsed.runId === undefined
|
|
826
824
|
? undefined
|
|
827
825
|
: (resolveRunId(db, parsed.runId, 'vx last') ?? parsed.runId)
|
|
828
|
-
const log = taskLog(
|
|
829
|
-
cacheDir,
|
|
830
|
-
db,
|
|
831
|
-
await bareTaskId(db, await rootSpelled(root, parsed.log)),
|
|
832
|
-
runId,
|
|
833
|
-
)
|
|
826
|
+
const log = taskLog(db, await bareTaskId(db, await rootSpelled(root, parsed.log)), runId)
|
|
834
827
|
if (log === null) {
|
|
835
828
|
// The id's form is hinted only when it lacks one: a task that
|
|
836
829
|
// never ran was told how to spell a task id.
|
|
@@ -921,9 +914,7 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
921
914
|
const detail = getRun(db, inv.runId)
|
|
922
915
|
|
|
923
916
|
if (parsed.format === 'json') {
|
|
924
|
-
const failures = new Map(
|
|
925
|
-
(runFailures(cacheDir, db, inv.runId)?.tasks ?? []).map((f) => [f.taskId, f]),
|
|
926
|
-
)
|
|
917
|
+
const failures = new Map((runFailures(db, inv.runId)?.tasks ?? []).map((f) => [f.taskId, f]))
|
|
927
918
|
const tasks = (detail?.tasks ?? []).map((t) => {
|
|
928
919
|
const f = failures.get(`${t.project}#${t.task}`)
|
|
929
920
|
return f === undefined
|
|
@@ -937,8 +928,8 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
937
928
|
...(f.fixedIn !== undefined ? { fixedIn: f.fixedIn } : {}),
|
|
938
929
|
}
|
|
939
930
|
})
|
|
940
|
-
const steps =
|
|
941
|
-
const warnings =
|
|
931
|
+
const steps = keptSteps(db, inv.runId)
|
|
932
|
+
const warnings = keptWarnings(db, inv.runId)
|
|
942
933
|
process.stdout.write(
|
|
943
934
|
`${JSON.stringify({
|
|
944
935
|
invocation: inv,
|
|
@@ -971,12 +962,12 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
|
|
|
971
962
|
)
|
|
972
963
|
const moved = new Map<string, string>()
|
|
973
964
|
if (inv.failedCount > 0) {
|
|
974
|
-
for (const f of runFailures(
|
|
965
|
+
for (const f of runFailures(db, inv.runId)?.tasks ?? [])
|
|
975
966
|
if (f.inputMoved !== undefined) moved.set(f.taskId, f.inputMoved)
|
|
976
967
|
}
|
|
977
968
|
lines.push(...formatTaskRows(detail?.tasks ?? [], c, moved))
|
|
978
|
-
lines.push(...formatStepRows(
|
|
979
|
-
const warnings =
|
|
969
|
+
lines.push(...formatStepRows(keptSteps(db, inv.runId), c))
|
|
970
|
+
const warnings = keptWarnings(db, inv.runId)
|
|
980
971
|
if (warnings.length > 0) {
|
|
981
972
|
lines.push('', ' warnings', ...warnings.flatMap((w) => w.split('\n').map((l) => ` ${l}`)))
|
|
982
973
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// reports an unknown command as it always did. Core's verbs are matched
|
|
8
8
|
// before this is asked, so a plugin cannot shadow `run`.
|
|
9
9
|
|
|
10
|
+
import { indexDb } from '../cache/index.js'
|
|
10
11
|
import type { CachePolicy, PluginCache, ResolvedRemote } from '../cache/index.js'
|
|
11
12
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
13
|
import type {
|
|
@@ -184,7 +185,7 @@ async function verbCache(
|
|
|
184
185
|
remotes,
|
|
185
186
|
})
|
|
186
187
|
await openVerbRemotes(remotes, openRemotes)
|
|
187
|
-
return pluginCache(plugin, { db: localCache
|
|
188
|
+
return pluginCache(plugin, { db: indexDb(localCache), policy: VERB_POLICY, remotes, scope })
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
/**
|
package/src/cli/why.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// moved input file, no re-hash. Configs are evaluated only when a moved
|
|
6
6
|
// upstream is a group: it records no run, so the graph names what it gathers.
|
|
7
7
|
|
|
8
|
-
import { Cache } from '../cache/index.js'
|
|
8
|
+
import { indexDb, Cache } from '../cache/index.js'
|
|
9
9
|
import { anchoredSpecError, flagHint, formatValue, seeHelp } from './help.js'
|
|
10
10
|
import { buildTaskGraph, isGroupTask, splitTaskId } from '../graph/index.js'
|
|
11
11
|
import {
|
|
@@ -166,7 +166,7 @@ async function resolveTarget(
|
|
|
166
166
|
target: string,
|
|
167
167
|
cwdProject: () => Promise<string | null>,
|
|
168
168
|
): Promise<string> {
|
|
169
|
-
const db = cache
|
|
169
|
+
const db = indexDb(cache)
|
|
170
170
|
if (target.includes('#')) {
|
|
171
171
|
// An exact id needs one row, and its newest is where a scan from the
|
|
172
172
|
// top of the table finds it first — the full DISTINCT walk below is for
|
|
@@ -263,7 +263,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
|
|
|
263
263
|
const root = await findWorkspaceRoot(process.cwd())
|
|
264
264
|
const cache = Cache.inspect(await cliCacheDir(root, parsed.cacheDir))
|
|
265
265
|
try {
|
|
266
|
-
const db = cache
|
|
266
|
+
const db = indexDb(cache)
|
|
267
267
|
const taskId = await resolveTarget(cache, await rootSpelled(root, parsed.target), () =>
|
|
268
268
|
findCwdProject(process.cwd()),
|
|
269
269
|
)
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -88,6 +88,10 @@ export interface TaskOutcome {
|
|
|
88
88
|
* its save wrote (measured only when a telemetry sink listens).
|
|
89
89
|
*/
|
|
90
90
|
artifactBytes?: number
|
|
91
|
+
/**
|
|
92
|
+
* The raw bytes of a hit's outputs: the entry's files as restored.
|
|
93
|
+
*/
|
|
94
|
+
outputBytes?: number
|
|
91
95
|
/** On a remote hit this run pulled: the download and its ingest. */
|
|
92
96
|
fetchMs?: number
|
|
93
97
|
/** On a miss that saved, with a sink listening: the save's own time. */
|
|
@@ -304,7 +308,8 @@ export interface ScheduleOptions {
|
|
|
304
308
|
*/
|
|
305
309
|
onFailFast?: () => void
|
|
306
310
|
execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
|
|
307
|
-
|
|
311
|
+
/** `queuedMs`: how long the task waited ready for a slot, when it did. */
|
|
312
|
+
onStart?: (node: TaskNode, queuedMs?: number) => void
|
|
308
313
|
onFinish?: (outcome: TaskOutcome) => void
|
|
309
314
|
/**
|
|
310
315
|
* Where the line naming a rejected `execute` goes, before its failed
|
|
@@ -959,9 +964,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
959
964
|
return undefined
|
|
960
965
|
}
|
|
961
966
|
}
|
|
962
|
-
const started = (node: TaskNode): void => {
|
|
967
|
+
const started = (node: TaskNode, queuedMs = 0): void => {
|
|
963
968
|
try {
|
|
964
|
-
onStart?.(node)
|
|
969
|
+
if (queuedMs > 0) onStart?.(node, queuedMs)
|
|
970
|
+
else onStart?.(node)
|
|
965
971
|
} catch (err) {
|
|
966
972
|
const m = thrownMessage(err)
|
|
967
973
|
ui.say('warn', `[vx] onStart observer threw for ${node.id}: ${m}\n`)
|
|
@@ -1100,7 +1106,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1100
1106
|
const settle = settleInPlace(node, upstream)
|
|
1101
1107
|
let now: TaskOutcome | undefined
|
|
1102
1108
|
if (settle !== undefined) {
|
|
1103
|
-
if (!demoted.has(id)) started(node)
|
|
1109
|
+
if (!demoted.has(id)) started(node, queuedMs)
|
|
1104
1110
|
try {
|
|
1105
1111
|
now = settle()
|
|
1106
1112
|
} catch {
|
|
@@ -1130,7 +1136,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
1130
1136
|
// the dispatch loop (it would strand the tick with the slot held).
|
|
1131
1137
|
// A demoted task's second dispatch is the same task, already started,
|
|
1132
1138
|
// as is one whose settle threw.
|
|
1133
|
-
if (!demoted.has(id) && settle === undefined) started(node)
|
|
1139
|
+
if (!demoted.has(id) && settle === undefined) started(node, queuedMs)
|
|
1134
1140
|
|
|
1135
1141
|
// `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
|
|
1136
1142
|
// rejection arm handles ONLY `execute()` rejecting; a throw from
|