@vzn/vx 0.0.636 → 0.0.638
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 +39 -11
- package/docs/caching.md +246 -213
- package/docs/cli.md +791 -705
- package/docs/execution.md +68 -76
- package/docs/features.md +47 -42
- package/docs/patterns.md +26 -25
- package/docs/schema.md +392 -205
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +4 -4
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +28 -13
- package/src/bin.ts +30 -9
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +124 -53
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +15 -1
- package/src/cache/inputs.ts +102 -36
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +15 -18
- package/src/cache/layered-cache.ts +20 -102
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +32 -27
- package/src/cache/run-history.ts +104 -14
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +58 -31
- package/src/cache/scoped-cache.ts +237 -0
- package/src/cli/cache.ts +173 -34
- package/src/cli/completions.ts +11 -7
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +110 -70
- package/src/cli/index.ts +67 -24
- package/src/cli/info.ts +43 -27
- package/src/cli/init.ts +115 -407
- package/src/cli/last.ts +157 -9
- package/src/cli/lock.ts +356 -122
- package/src/cli/plan-format.ts +7 -5
- package/src/cli/plugin-commands.ts +81 -14
- package/src/cli/plugin-templates.ts +11 -11
- package/src/cli/run.ts +116 -160
- package/src/cli/select.ts +50 -47
- package/src/cli/show.ts +83 -59
- package/src/cli/task-verb.ts +6 -13
- package/src/cli/upgrade.ts +415 -149
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +39 -61
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +18 -4
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +227 -230
- package/src/cli/why.ts +38 -18
- package/src/cli/workspace-config.ts +33 -70
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +27 -7
- package/src/graph/task-graph.ts +76 -24
- package/src/handoff.ts +169 -0
- package/src/index.ts +35 -58
- package/src/orchestrator/affected-tasks.ts +15 -8
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +147 -69
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +9 -5
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +126 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +20 -5
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +80 -41
- package/src/orchestrator/metrics.ts +78 -9
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +100 -66
- package/src/orchestrator/plugin.ts +173 -67
- package/src/orchestrator/prepare.ts +163 -78
- package/src/orchestrator/projects.ts +105 -99
- package/src/orchestrator/remote-prefetch.ts +11 -6
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +244 -119
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +49 -7
- package/src/orchestrator/summary.ts +115 -106
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +4 -4
- package/src/settings.ts +428 -0
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +33 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +16 -3
- package/src/util/paths.ts +10 -0
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ui.ts +186 -0
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +84 -232
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +65 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +307 -215
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +363 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
} from './plugin.js'
|
|
26
26
|
|
|
27
27
|
export interface LockfileClaimOptions {
|
|
28
|
-
/** The
|
|
28
|
+
/** The lockfile's name at the workspace root. */
|
|
29
29
|
readonly file: string
|
|
30
30
|
/**
|
|
31
31
|
* Every project's digest from the file's text: importer directory
|
|
@@ -46,8 +46,7 @@ export interface LockfileClaimOptions {
|
|
|
46
46
|
readonly version: number
|
|
47
47
|
/**
|
|
48
48
|
* `'project'` (default): each task folds its own project's digest.
|
|
49
|
-
* `'workspace'`: the whole file's hash into every task — the coarse key
|
|
50
|
-
* core would fold, through the plugin.
|
|
49
|
+
* `'workspace'`: the whole file's hash into every task — the coarse key.
|
|
51
50
|
*/
|
|
52
51
|
readonly scope?: 'project' | 'workspace'
|
|
53
52
|
/**
|
|
@@ -55,18 +54,46 @@ export interface LockfileClaimOptions {
|
|
|
55
54
|
* (`@vzn/vx-lockfile/pnpm`). Default `'deps'`.
|
|
56
55
|
*/
|
|
57
56
|
readonly part?: string
|
|
57
|
+
/**
|
|
58
|
+
* Each importer's links to other importers, from the same text, kept in
|
|
59
|
+
* the memo beside the digests so a `discover` hook reads them without a
|
|
60
|
+
* parse. Called once per distinct content, under either scope.
|
|
61
|
+
*/
|
|
62
|
+
readonly importers?: (text: string) => ReadonlyMap<string, LockfileImporter>
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
/**
|
|
65
|
+
/**
|
|
66
|
+
* What a lockfile records of one importer's links to the workspace's other
|
|
67
|
+
* importers: dependency name → importer directory (root-relative POSIX,
|
|
68
|
+
* `.` for the root).
|
|
69
|
+
*/
|
|
70
|
+
export interface LockfileImporter {
|
|
71
|
+
/** The entries it installs that resolve to an importer. */
|
|
72
|
+
readonly deps: Readonly<Record<string, string>>
|
|
73
|
+
/** Its peers that resolve to an importer, each with no installed entry of the same name. */
|
|
74
|
+
readonly peers: Readonly<Record<string, string>>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What a lockfile plugin spreads into `definePlugin` (`fingerprint`, `key`), and `importers` for its own stages. */
|
|
61
78
|
export interface LockfileClaimHooks {
|
|
62
79
|
readonly fingerprint: FingerprintClaim
|
|
63
80
|
key(task: TaskNode, ctx: KeyHookContext): Promise<Readonly<Record<string, string>> | undefined>
|
|
81
|
+
/**
|
|
82
|
+
* `importers`' answer for the file as it is now, through the same memo
|
|
83
|
+
* and gates as `key`; undefined when the file is absent. Not a hook: a
|
|
84
|
+
* plugin calls it from its own `discover` or `project`.
|
|
85
|
+
*/
|
|
86
|
+
importers(ctx: {
|
|
87
|
+
readonly workspaceRoot: string
|
|
88
|
+
readonly cacheDir: string
|
|
89
|
+
}): Promise<ReadonlyMap<string, LockfileImporter> | undefined>
|
|
64
90
|
}
|
|
65
91
|
|
|
66
92
|
interface Digests {
|
|
67
93
|
/** xxh3 of the lockfile bytes; '' when the file is absent. */
|
|
68
94
|
readonly lock: string
|
|
69
95
|
readonly importers: ReadonlyMap<string, string>
|
|
96
|
+
readonly edges: ReadonlyMap<string, LockfileImporter>
|
|
70
97
|
}
|
|
71
98
|
|
|
72
99
|
interface Memo {
|
|
@@ -76,6 +103,7 @@ interface Memo {
|
|
|
76
103
|
/** `extraFiles` → content hash when the digests were computed. */
|
|
77
104
|
extras?: Record<string, string>
|
|
78
105
|
importers: Record<string, string>
|
|
106
|
+
edges?: Record<string, LockfileImporter>
|
|
79
107
|
}
|
|
80
108
|
|
|
81
109
|
/** A root-relative extra file's content hash, '' when absent; a path leaving the root is never read. */
|
|
@@ -125,7 +153,16 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
125
153
|
// at the same number read this one's memo: one that named no extra file
|
|
126
154
|
// left the other's patch edits out of its key (X-130). The functions'
|
|
127
155
|
// source stands for the claimant; a rebuild that changes it is a miss.
|
|
128
|
-
|
|
156
|
+
// The scope too: a workspace-scope memo holds no digests.
|
|
157
|
+
const claimant = xxh3hex(
|
|
158
|
+
[
|
|
159
|
+
part,
|
|
160
|
+
scope,
|
|
161
|
+
String(digest),
|
|
162
|
+
String(options.extraFiles ?? ''),
|
|
163
|
+
String(options.importers ?? ''),
|
|
164
|
+
].join('\0'),
|
|
165
|
+
)
|
|
129
166
|
if (scope !== 'project' && scope !== 'workspace') {
|
|
130
167
|
throw new Error(`scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`)
|
|
131
168
|
}
|
|
@@ -149,7 +186,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
149
186
|
try {
|
|
150
187
|
identity = identityOf(await stat(full))
|
|
151
188
|
} catch {
|
|
152
|
-
const digests: Digests = { lock: '', importers: new Map() }
|
|
189
|
+
const digests: Digests = { lock: '', importers: new Map(), edges: new Map() }
|
|
153
190
|
seen.set(workspaceRoot, { identity: 'gone', extras: [], extraStats: {}, digests })
|
|
154
191
|
return digests
|
|
155
192
|
}
|
|
@@ -169,12 +206,14 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
169
206
|
let extras: Record<string, string> = {}
|
|
170
207
|
let extraStats: Record<string, string> = {}
|
|
171
208
|
let importers: ReadonlyMap<string, string> | undefined
|
|
209
|
+
let edges: ReadonlyMap<string, LockfileImporter> = new Map()
|
|
172
210
|
if (memo !== undefined) {
|
|
173
211
|
const now = await hashExtras(workspaceRoot, Object.keys(memo.extras))
|
|
174
212
|
if (Object.entries(memo.extras).every(([rel, h]) => now.hashes[rel] === h)) {
|
|
175
213
|
extras = memo.extras
|
|
176
214
|
extraStats = now.stats
|
|
177
215
|
importers = memo.importers
|
|
216
|
+
edges = memo.edges
|
|
178
217
|
}
|
|
179
218
|
}
|
|
180
219
|
if (importers === undefined) {
|
|
@@ -186,19 +225,19 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
186
225
|
importers =
|
|
187
226
|
scope === 'workspace'
|
|
188
227
|
? new Map<string, string>()
|
|
189
|
-
:
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
228
|
+
: digest(text, new Map(Object.entries(extras)))
|
|
229
|
+
edges = options.importers?.(text) ?? new Map()
|
|
230
|
+
// Workspace scope with no edges to keep has nothing a memo saves.
|
|
231
|
+
if (scope === 'project' || options.importers !== undefined) {
|
|
232
|
+
await writeMemo(memoFile, { version, claimant, lock: lockHash }, extras, importers, edges)
|
|
233
|
+
}
|
|
195
234
|
}
|
|
196
235
|
const rels = Object.keys(extras)
|
|
197
236
|
const lock =
|
|
198
237
|
rels.length === 0
|
|
199
238
|
? lockHash
|
|
200
239
|
: xxh3hex([lockHash, ...rels.map((r) => `${r}\0${extras[r]}`)].join('\0'))
|
|
201
|
-
const digests: Digests = { lock, importers }
|
|
240
|
+
const digests: Digests = { lock, importers, edges }
|
|
202
241
|
seen.set(workspaceRoot, { identity, extras: rels, extraStats, digests })
|
|
203
242
|
return digests
|
|
204
243
|
}
|
|
@@ -216,7 +255,7 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
216
255
|
files: [file],
|
|
217
256
|
affected(change: FingerprintChange, ctx: FingerprintContext) {
|
|
218
257
|
// One side absent: the file appeared or went — every project's
|
|
219
|
-
// installed tree is in question
|
|
258
|
+
// installed tree is in question.
|
|
220
259
|
if (change.before === null || change.after === null) return undefined
|
|
221
260
|
if (scope === 'workspace') return undefined
|
|
222
261
|
// WHICH side failed is the whole difference between "your
|
|
@@ -248,6 +287,10 @@ export function lockfileClaim(options: LockfileClaimOptions): LockfileClaimHooks
|
|
|
248
287
|
[part]: digestFor(digests.importers, importerOf(ctx.workspaceRoot, task.projectDir)),
|
|
249
288
|
}
|
|
250
289
|
},
|
|
290
|
+
async importers(ctx) {
|
|
291
|
+
const digests = await load(ctx.workspaceRoot, ctx.cacheDir)
|
|
292
|
+
return digests.lock === '' ? undefined : digests.edges
|
|
293
|
+
},
|
|
251
294
|
}
|
|
252
295
|
}
|
|
253
296
|
|
|
@@ -280,25 +323,42 @@ async function readMemo(
|
|
|
280
323
|
version: number,
|
|
281
324
|
claimant: string,
|
|
282
325
|
lock: string,
|
|
283
|
-
): Promise<
|
|
326
|
+
): Promise<
|
|
327
|
+
| {
|
|
328
|
+
extras: Record<string, string>
|
|
329
|
+
importers: Map<string, string>
|
|
330
|
+
edges: Map<string, LockfileImporter>
|
|
331
|
+
}
|
|
332
|
+
| undefined
|
|
333
|
+
> {
|
|
284
334
|
try {
|
|
285
335
|
const memo = (await Bun.file(memoFile).json()) as Memo
|
|
286
336
|
if (memo.version !== version || memo.claimant !== claimant || memo.lock !== lock) {
|
|
287
337
|
return undefined
|
|
288
338
|
}
|
|
289
|
-
return {
|
|
339
|
+
return {
|
|
340
|
+
extras: memo.extras ?? {},
|
|
341
|
+
importers: new Map(Object.entries(memo.importers)),
|
|
342
|
+
edges: new Map(Object.entries(memo.edges ?? {})),
|
|
343
|
+
}
|
|
290
344
|
} catch {
|
|
291
345
|
return undefined
|
|
292
346
|
}
|
|
293
347
|
}
|
|
294
348
|
|
|
295
|
-
async function
|
|
349
|
+
async function writeMemo(
|
|
296
350
|
memoFile: string,
|
|
297
351
|
identity: Pick<Memo, 'version' | 'claimant' | 'lock'>,
|
|
298
352
|
extras: Record<string, string>,
|
|
299
353
|
importers: ReadonlyMap<string, string>,
|
|
300
|
-
|
|
301
|
-
|
|
354
|
+
edges: ReadonlyMap<string, LockfileImporter>,
|
|
355
|
+
): Promise<void> {
|
|
356
|
+
const memo: Memo = {
|
|
357
|
+
...identity,
|
|
358
|
+
extras,
|
|
359
|
+
importers: Object.fromEntries(importers),
|
|
360
|
+
...(edges.size > 0 ? { edges: Object.fromEntries(edges) } : {}),
|
|
361
|
+
}
|
|
302
362
|
// Write-then-rename: a reader never sees a half-written memo, and two
|
|
303
363
|
// concurrent runs each land a whole one.
|
|
304
364
|
const tmp = `${memoFile}.tmp-${process.pid}-${Date.now()}`
|
|
@@ -309,7 +369,6 @@ async function computeAndMemo(
|
|
|
309
369
|
// The memo is a speed-up, never a requirement: the digests are
|
|
310
370
|
// computed either way, and the next run computes them again.
|
|
311
371
|
}
|
|
312
|
-
return importers
|
|
313
372
|
}
|
|
314
373
|
|
|
315
374
|
function decode(bytes: Uint8Array): string {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TaskNode, TaskOutcome } from '../graph/index.js'
|
|
2
|
-
import {
|
|
2
|
+
import { settingEnv } from '../settings.js'
|
|
3
3
|
import {
|
|
4
4
|
formatFrameClose,
|
|
5
5
|
formatFrameOpen,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type StatusStream,
|
|
21
21
|
type WorkerSlot,
|
|
22
22
|
} from './status-line.js'
|
|
23
|
-
import { formatSummarySection, neverStarted, type RunContext } from './summary.js'
|
|
23
|
+
import { formatSummarySection, neverStarted, ranUnsaved, type RunContext } from './summary.js'
|
|
24
24
|
import { isGroupTask } from '../graph/index.js'
|
|
25
25
|
import {
|
|
26
26
|
appendTail,
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
resetTail,
|
|
30
30
|
tailText,
|
|
31
31
|
type Tail,
|
|
32
|
+
detectColors,
|
|
33
|
+
type ColorSupport,
|
|
32
34
|
} from '../util/index.js'
|
|
33
35
|
import { isCacheHit } from './telemetry.js'
|
|
34
36
|
import { failedLabel, outcomeWord, ranNoCache } from './events.js'
|
|
@@ -177,7 +179,10 @@ export function resolveOutputView(
|
|
|
177
179
|
...(gha ? { gha: true } : {}),
|
|
178
180
|
...(ci ? { ci: true } : {}),
|
|
179
181
|
})
|
|
180
|
-
|
|
182
|
+
// `run.outputLogs`: chosen before the workspace loads, so flag, then variable.
|
|
183
|
+
const outputLogs =
|
|
184
|
+
options.outputLogs ?? (settingEnv('run.outputLogs', env) as OutputView['mode'] | undefined)
|
|
185
|
+
if (outputLogs !== undefined) return mk(outputLogs)
|
|
181
186
|
if (ci) return mk('full')
|
|
182
187
|
if (options.flow !== undefined) return mk(options.flow)
|
|
183
188
|
return mk('full')
|
|
@@ -198,6 +203,8 @@ export function defaultLogger(
|
|
|
198
203
|
tty?: boolean
|
|
199
204
|
/** The args after `--`: a requested task's `$ <command>` line shows them, as it ran. */
|
|
200
205
|
forwardArgs?: readonly string[]
|
|
206
|
+
/** `false`: no summary in the live region and no blank line opening the log (`RunOptions.footer`). */
|
|
207
|
+
footer?: boolean
|
|
201
208
|
/** The environment that names the terminal (`TERM_PROGRAM`, …); `process.env` by default. */
|
|
202
209
|
env?: Readonly<Record<string, string | undefined>>
|
|
203
210
|
} = {},
|
|
@@ -295,6 +302,7 @@ export function defaultLogger(
|
|
|
295
302
|
let etaMs: number | undefined
|
|
296
303
|
let critical: string | undefined
|
|
297
304
|
let blocks: ReadonlyMap<string, number> | undefined
|
|
305
|
+
let expected: ReadonlyMap<string, number> | undefined
|
|
298
306
|
let etaAtMs = 0
|
|
299
307
|
const signals = writer.enabled
|
|
300
308
|
? terminalSignals(opts.env ?? process.env)
|
|
@@ -311,6 +319,10 @@ export function defaultLogger(
|
|
|
311
319
|
// as `+k more` on the stats line. Focused flow keeps its lifecycle:
|
|
312
320
|
// the region dies the moment a requested node starts streaming.
|
|
313
321
|
let slots: (WorkerSlot | null)[] = []
|
|
322
|
+
// Rows shown: the most workers busy at once so far. An idle row past it
|
|
323
|
+
// was noise (a 10-core machine drew 9 on a one-chain run); it never
|
|
324
|
+
// shrinks, so a row never jumps.
|
|
325
|
+
let shownSlots = 0
|
|
314
326
|
const slotQueue: WorkerSlot[] = []
|
|
315
327
|
let succeeded = 0
|
|
316
328
|
// Executed with no `cache` block: never a miss, counted apart.
|
|
@@ -318,6 +330,8 @@ export function defaultLogger(
|
|
|
318
330
|
let upToDate = 0
|
|
319
331
|
let restoredLocal = 0
|
|
320
332
|
let restoredRemote = 0
|
|
333
|
+
let saved = 0
|
|
334
|
+
let notSaved = 0
|
|
321
335
|
let skippedCount = 0
|
|
322
336
|
let savedMs = 0
|
|
323
337
|
// Cache-miss duration spread, accumulated incrementally (the same
|
|
@@ -375,16 +389,16 @@ export function defaultLogger(
|
|
|
375
389
|
if (text.length < KEPT_LINE_CAP) held.rest = text
|
|
376
390
|
else {
|
|
377
391
|
held.rest = ''
|
|
378
|
-
|
|
392
|
+
taskWrite(fenced(formatKeptLines(node, text, colors)))
|
|
379
393
|
}
|
|
380
394
|
return
|
|
381
395
|
}
|
|
382
396
|
held.rest = text.slice(cut + 1)
|
|
383
|
-
|
|
397
|
+
taskWrite(fenced(formatKeptLines(node, text.slice(0, cut), colors)))
|
|
384
398
|
}
|
|
385
399
|
const flushKept = (): void => {
|
|
386
400
|
for (const held of keptLines.values()) {
|
|
387
|
-
if (held.rest.length > 0)
|
|
401
|
+
if (held.rest.length > 0) taskWrite(fenced(formatKeptLines(held.node, held.rest, colors)))
|
|
388
402
|
held.rest = ''
|
|
389
403
|
}
|
|
390
404
|
}
|
|
@@ -404,6 +418,7 @@ export function defaultLogger(
|
|
|
404
418
|
etaMs = f.etaMs
|
|
405
419
|
critical = f.critical
|
|
406
420
|
blocks = f.blocks
|
|
421
|
+
expected = f.expected
|
|
407
422
|
}
|
|
408
423
|
if (signals.progress && total > 0) {
|
|
409
424
|
const elapsed = now - startedAtMs
|
|
@@ -421,38 +436,44 @@ export function defaultLogger(
|
|
|
421
436
|
}
|
|
422
437
|
// The live summary IS the final summary's section, built from the
|
|
423
438
|
// same formatter so the region visually becomes the printout.
|
|
424
|
-
const summaryLines =
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
439
|
+
const summaryLines =
|
|
440
|
+
opts.footer === false
|
|
441
|
+
? []
|
|
442
|
+
: formatSummarySection(
|
|
443
|
+
{
|
|
444
|
+
failed,
|
|
445
|
+
successful: succeeded + upToDate + restoredLocal + restoredRemote,
|
|
446
|
+
skipped: skippedCount,
|
|
447
|
+
total,
|
|
448
|
+
upToDate,
|
|
449
|
+
restoredLocal,
|
|
450
|
+
restoredRemote,
|
|
451
|
+
saved,
|
|
452
|
+
notSaved,
|
|
453
|
+
miss: succeeded + failed - noCache,
|
|
454
|
+
noCache,
|
|
455
|
+
savedMs,
|
|
456
|
+
left: total - done,
|
|
457
|
+
...(etaMs !== undefined ? { etaMs } : {}),
|
|
458
|
+
spread:
|
|
459
|
+
spreadCount > 0
|
|
460
|
+
? { maxMs: spreadMax, minMs: spreadMin, sumMs: spreadSum, count: spreadCount }
|
|
461
|
+
: null,
|
|
462
|
+
},
|
|
463
|
+
Date.now() - startedAtMs,
|
|
464
|
+
colors,
|
|
465
|
+
runContext,
|
|
466
|
+
)
|
|
447
467
|
writer.setRegion(
|
|
448
468
|
formatStatusRegion(
|
|
449
469
|
{
|
|
450
470
|
pinnedPersistent,
|
|
451
|
-
slots,
|
|
471
|
+
slots: slots.slice(0, shownSlots),
|
|
452
472
|
overflow: slotQueue.length,
|
|
453
473
|
nowMs: Date.now(),
|
|
454
474
|
summaryLines,
|
|
455
475
|
...(blocks !== undefined ? { blocks } : {}),
|
|
476
|
+
...(expected !== undefined ? { expected } : {}),
|
|
456
477
|
...(critical !== undefined ? { critical } : {}),
|
|
457
478
|
},
|
|
458
479
|
colors,
|
|
@@ -478,8 +499,21 @@ export function defaultLogger(
|
|
|
478
499
|
writer.clearStatus()
|
|
479
500
|
}
|
|
480
501
|
|
|
502
|
+
// The task log opens with one blank line before its first row, on a
|
|
503
|
+
// terminal and off one (M, 2026-10-14). A note vx prints before it is
|
|
504
|
+
// no task row (`status`). Without a footer, the caller frames the log
|
|
505
|
+
// (`vx watch` opens each cycle with its own line).
|
|
506
|
+
let logOpened = opts.footer === false
|
|
507
|
+
const taskWrite = (chunk: string): void => {
|
|
508
|
+
if (!logOpened && chunk.length > 0) {
|
|
509
|
+
logOpened = true
|
|
510
|
+
writer.write(`\n${chunk}`)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
writer.write(chunk)
|
|
514
|
+
}
|
|
481
515
|
const emitLine = (line: string): void => {
|
|
482
|
-
|
|
516
|
+
taskWrite(`${line}\n`)
|
|
483
517
|
lineEmitted = true
|
|
484
518
|
}
|
|
485
519
|
// formatTaskBlock returns '' for group tasks (no exec) — skip the
|
|
@@ -487,14 +521,14 @@ export function defaultLogger(
|
|
|
487
521
|
const emitBlock = (block: string): void => {
|
|
488
522
|
if (block.length === 0) return
|
|
489
523
|
const lead = lineEmitted || streamedSinceBlock ? '\n' : ''
|
|
490
|
-
|
|
524
|
+
taskWrite(`${lead}${block}\n`)
|
|
491
525
|
lineEmitted = false
|
|
492
526
|
streamedSinceBlock = false
|
|
493
527
|
}
|
|
494
528
|
// Live-frame close lines end a frame the same way emitBlock does:
|
|
495
529
|
// with a trailing blank line, resetting the separator state.
|
|
496
530
|
const emitFrameClose = (line: string): void => {
|
|
497
|
-
|
|
531
|
+
taskWrite(`${line}\n\n`)
|
|
498
532
|
lineEmitted = false
|
|
499
533
|
streamedSinceBlock = false
|
|
500
534
|
}
|
|
@@ -549,6 +583,7 @@ export function defaultLogger(
|
|
|
549
583
|
startedAtMs = Date.now()
|
|
550
584
|
const cap = Math.max(1, Math.min(info.concurrency ?? 10, 10))
|
|
551
585
|
slots = Array.from({ length: cap }, () => null)
|
|
586
|
+
shownSlots = 0
|
|
552
587
|
if (writer.enabled && !statusDead && ticker === null) {
|
|
553
588
|
// Keeps the per-worker elapsed time ticking between task events
|
|
554
589
|
// (the time IS the motion — there's no spinner). The writer
|
|
@@ -585,8 +620,10 @@ export function defaultLogger(
|
|
|
585
620
|
}
|
|
586
621
|
const slot: WorkerSlot = { id: node.id, startedMs: Date.now() }
|
|
587
622
|
const free = slots.indexOf(null)
|
|
588
|
-
if (free >= 0)
|
|
589
|
-
|
|
623
|
+
if (free >= 0) {
|
|
624
|
+
slots[free] = slot
|
|
625
|
+
shownSlots = Math.max(shownSlots, free + 1)
|
|
626
|
+
} else slotQueue.push(slot)
|
|
590
627
|
refresh(true)
|
|
591
628
|
},
|
|
592
629
|
forecast(load) {
|
|
@@ -675,7 +712,7 @@ export function defaultLogger(
|
|
|
675
712
|
if (streamsLive(node)) {
|
|
676
713
|
streamedSinceBlock = true
|
|
677
714
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
678
|
-
|
|
715
|
+
taskWrite(chunk)
|
|
679
716
|
return
|
|
680
717
|
}
|
|
681
718
|
const tail = persistentTails.get(node.id)
|
|
@@ -691,7 +728,7 @@ export function defaultLogger(
|
|
|
691
728
|
if (streamsLive(node)) {
|
|
692
729
|
streamedSinceBlock = true
|
|
693
730
|
if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
|
|
694
|
-
|
|
731
|
+
taskWrite(chunk)
|
|
695
732
|
return
|
|
696
733
|
}
|
|
697
734
|
const tail = persistentTails.get(node.id)
|
|
@@ -747,7 +784,7 @@ export function defaultLogger(
|
|
|
747
784
|
(framesTerminal && holdsTerminal(node)))
|
|
748
785
|
if (framed && streamsLive(node)) {
|
|
749
786
|
if (streamMidLine) {
|
|
750
|
-
|
|
787
|
+
taskWrite('\n')
|
|
751
788
|
streamMidLine = false
|
|
752
789
|
}
|
|
753
790
|
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
@@ -804,6 +841,8 @@ export function defaultLogger(
|
|
|
804
841
|
case 'success':
|
|
805
842
|
succeeded++
|
|
806
843
|
if (ranNoCache(outcome)) noCache++
|
|
844
|
+
if (outcome.saved === true) saved++
|
|
845
|
+
else if (ranUnsaved(outcome)) notSaved++
|
|
807
846
|
break
|
|
808
847
|
case 'cache-hit':
|
|
809
848
|
if (outcome.restored === false) upToDate++
|
|
@@ -830,7 +869,7 @@ export function defaultLogger(
|
|
|
830
869
|
if (isGroupTask(node)) return
|
|
831
870
|
if (holdsTerminal(node) && streamsLive(node) && outcome.status !== 'skipped') {
|
|
832
871
|
if (streamMidLine) {
|
|
833
|
-
|
|
872
|
+
taskWrite('\n')
|
|
834
873
|
streamMidLine = false
|
|
835
874
|
}
|
|
836
875
|
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
@@ -908,7 +947,7 @@ export function defaultLogger(
|
|
|
908
947
|
// and up-to-date included (owner: "always full frame
|
|
909
948
|
// for a single task").
|
|
910
949
|
if (streamMidLine) {
|
|
911
|
-
|
|
950
|
+
taskWrite('\n')
|
|
912
951
|
streamMidLine = false
|
|
913
952
|
}
|
|
914
953
|
emitFrameClose(formatFrameClose(node, outcome, colors))
|
|
@@ -1003,7 +1042,7 @@ export function defaultLogger(
|
|
|
1003
1042
|
// The message is ours (a literal, an integer and a signal
|
|
1004
1043
|
// name); only the title carries user input.
|
|
1005
1044
|
emitBlock(
|
|
1006
|
-
`::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady)}\n${body}`,
|
|
1045
|
+
`::error title=${ghaProperty(node.id)}::${failedLabel(outcome.exitCode, outcome.timedOut, outcome.sandboxViolations, outcome.notReady, outcome.inputMoved)}\n${body}`,
|
|
1007
1046
|
)
|
|
1008
1047
|
} else {
|
|
1009
1048
|
emitBlock(
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
// Run-history queries — pure functions over a `bun:sqlite` Database.
|
|
2
2
|
//
|
|
3
3
|
// `vx why` and `vx last` read through them, and so can any out-of-process
|
|
4
|
-
// surface
|
|
4
|
+
// surface. One canonical home for every
|
|
5
5
|
// aggregate over the runs / entries tables. The schema itself is owned by
|
|
6
6
|
// src/cache/cache.ts.
|
|
7
7
|
//
|
|
8
|
-
// Pure SQL + JSON-safe return shapes
|
|
8
|
+
// Pure SQL + JSON-safe return shapes (`inputWriters` also stats the
|
|
9
|
+
// files it names). No Cache lifecycle here; the
|
|
9
10
|
// caller opens and closes. bigints are serialized as decimal strings
|
|
10
11
|
// for JSON compatibility (matches the WireEvent timeUnixNano rule).
|
|
11
12
|
|
|
12
13
|
import type { Database } from 'bun:sqlite'
|
|
14
|
+
import { statSync } from 'node:fs'
|
|
15
|
+
import path from 'node:path'
|
|
13
16
|
// The keyed-run predicate lives beside the schema in cache/cache.ts, so a
|
|
14
17
|
// rule written once cannot drift.
|
|
15
18
|
import { KEYED_RUNS_SQL } from '../cache/index.js'
|
|
@@ -18,6 +21,10 @@ import { clampInt, MASKED, secretNamed } from '../util/index.js'
|
|
|
18
21
|
import type { TaskInputComponent } from './task-hash.js'
|
|
19
22
|
import { isPassStatus } from './telemetry.js'
|
|
20
23
|
|
|
24
|
+
// A recorded `cache_policy` is the `--cache-policy` spec (`local:rw,remote:r`).
|
|
25
|
+
const readsCache = (spec: string): boolean => /(?:^|,)(?:local|remote):w?r/.test(spec)
|
|
26
|
+
const writesCache = (spec: string): boolean => /(?:^|,)(?:local|remote):r?w/.test(spec)
|
|
27
|
+
|
|
21
28
|
// ---------------------------------------------------------------------------
|
|
22
29
|
// Run listing + detail
|
|
23
30
|
// ---------------------------------------------------------------------------
|
|
@@ -409,8 +416,8 @@ function unchangedKeyNote(
|
|
|
409
416
|
p: string
|
|
410
417
|
} | null
|
|
411
418
|
)?.p
|
|
412
|
-
if (policy !== undefined && !policy
|
|
413
|
-
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache without read)'
|
|
419
|
+
if (policy !== undefined && !readsCache(policy)) {
|
|
420
|
+
return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
|
|
414
421
|
}
|
|
415
422
|
// `get` answers null, not undefined, for no row.
|
|
416
423
|
const createdAt = (
|
|
@@ -435,7 +442,7 @@ function unchangedKeyNote(
|
|
|
435
442
|
const row = db
|
|
436
443
|
.query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
|
|
437
444
|
.get(id) as { p: string; f: number } | null
|
|
438
|
-
const writes = row
|
|
445
|
+
const writes = row !== null && writesCache(row.p)
|
|
439
446
|
return writes ? (row?.f ?? 0) : 0
|
|
440
447
|
}
|
|
441
448
|
const before = failedWithWrites(prev.runId)
|
|
@@ -469,8 +476,8 @@ function unchangedKeyNote(
|
|
|
469
476
|
p: string
|
|
470
477
|
} | null
|
|
471
478
|
)?.p
|
|
472
|
-
if (prevPolicy !== undefined && !prevPolicy
|
|
473
|
-
return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache without write), so there was nothing to hit'
|
|
479
|
+
if (prevPolicy !== undefined && !writesCache(prevPolicy)) {
|
|
480
|
+
return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
|
|
474
481
|
}
|
|
475
482
|
return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'
|
|
476
483
|
}
|
|
@@ -479,8 +486,7 @@ function unchangedKeyNote(
|
|
|
479
486
|
|
|
480
487
|
/**
|
|
481
488
|
* The latest recorded run of a task (run_id may be NULL on very old rows).
|
|
482
|
-
*
|
|
483
|
-
* tool demanded a run id an agent had to fetch first (2026-09-16).
|
|
489
|
+
* `vx why`'s default when no run id is given.
|
|
484
490
|
*/
|
|
485
491
|
export function latestRunId(db: Database, taskId: string): string | null {
|
|
486
492
|
const [project, task] = splitTaskId(taskId)
|
|
@@ -875,6 +881,69 @@ export function rootCauses(
|
|
|
875
881
|
return roots
|
|
876
882
|
}
|
|
877
883
|
|
|
884
|
+
export interface InputWriter {
|
|
885
|
+
/** The task whose key read the file. */
|
|
886
|
+
taskId: string
|
|
887
|
+
/** Workspace-relative, as the key names it. */
|
|
888
|
+
file: string
|
|
889
|
+
/** The tasks that were running when the file last changed. */
|
|
890
|
+
writers: string[]
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Slack around a task's recorded window: its edges are rounded to the
|
|
895
|
+
* millisecond and a write lands just before the process exits.
|
|
896
|
+
*/
|
|
897
|
+
const WRITE_SLACK_MS = 5
|
|
898
|
+
|
|
899
|
+
/**
|
|
900
|
+
* For each moved input file of `taskId` and of each root cause, the
|
|
901
|
+
* recorded tasks that were running when the file last changed: a task
|
|
902
|
+
* that writes a file another key reads re-keys that task on every run.
|
|
903
|
+
* The file's mtime against the recorded windows, so
|
|
904
|
+
* a writer that runs each time is named from its latest run, and an
|
|
905
|
+
* edit made outside a run names none.
|
|
906
|
+
*/
|
|
907
|
+
export function inputWriters(
|
|
908
|
+
db: Database,
|
|
909
|
+
workspaceRoot: string,
|
|
910
|
+
taskId: string,
|
|
911
|
+
entries: CacheKeyDiff['entries'],
|
|
912
|
+
roots: readonly RootCause[],
|
|
913
|
+
): InputWriter[] {
|
|
914
|
+
const during = db.query(
|
|
915
|
+
`SELECT project, task FROM runs
|
|
916
|
+
WHERE started_at <= ?1 + ${WRITE_SLACK_MS} AND ended_at >= ?1 - ${WRITE_SLACK_MS}
|
|
917
|
+
AND status != 'skipped'
|
|
918
|
+
GROUP BY project, task ORDER BY MIN(started_at) LIMIT 3`,
|
|
919
|
+
)
|
|
920
|
+
const out: InputWriter[] = []
|
|
921
|
+
const owners = [
|
|
922
|
+
{ taskId, entries },
|
|
923
|
+
...roots.map((r) => ({ taskId: r.chain.at(-1)!, entries: r.entries })),
|
|
924
|
+
]
|
|
925
|
+
for (const owner of owners) {
|
|
926
|
+
for (const e of owner.entries) {
|
|
927
|
+
if (e.kind !== 'file' || e.change === 'removed') continue
|
|
928
|
+
let mtime: number
|
|
929
|
+
try {
|
|
930
|
+
mtime = statSync(path.join(workspaceRoot, e.name)).mtimeMs
|
|
931
|
+
} catch {
|
|
932
|
+
continue
|
|
933
|
+
}
|
|
934
|
+
const rows = during.all(mtime) as { project: string; task: string }[]
|
|
935
|
+
if (rows.length > 0) {
|
|
936
|
+
out.push({
|
|
937
|
+
taskId: owner.taskId,
|
|
938
|
+
file: e.name,
|
|
939
|
+
writers: rows.map((r) => `${r.project}#${r.task}`),
|
|
940
|
+
})
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return out
|
|
945
|
+
}
|
|
946
|
+
|
|
878
947
|
// ---------------------------------------------------------------------------
|
|
879
948
|
// Run comparison — diff a run against the immediately-previous invocation
|
|
880
949
|
// ---------------------------------------------------------------------------
|
|
@@ -81,6 +81,8 @@ export interface SaveMissArgs {
|
|
|
81
81
|
ownOutputFiles?: string[] | undefined
|
|
82
82
|
/** `ownOutputFiles` for `cache.outputs.workspaceFiles` (A-43). */
|
|
83
83
|
ownWsOutputFiles?: string[] | undefined
|
|
84
|
+
/** A sandboxed task's reach: a symlinked output it could not read is refused (`sandboxReadable`). */
|
|
85
|
+
readable?: ((real: string) => boolean) | undefined
|
|
84
86
|
/** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
|
|
85
87
|
captured: readonly TaskInputComponent[]
|
|
86
88
|
command: string
|
|
@@ -176,6 +178,7 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
|
|
|
176
178
|
? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
|
|
177
179
|
: {}),
|
|
178
180
|
inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
|
|
181
|
+
readable: a.readable,
|
|
179
182
|
// No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
|
|
180
183
|
// the contract no longer accepts one — so the invariant is enforced by
|
|
181
184
|
// the type rather than by every call site remembering the gate.
|