@vzn/vx 0.0.637 → 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/docs/caching.md +41 -14
- package/docs/cli.md +115 -73
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -5
- package/docs/schema.md +24 -15
- package/package.json +5 -5
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +13 -2
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +21 -2
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/index.ts +9 -1
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/run-history.ts +68 -0
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +38 -9
- package/src/cache/scoped-cache.ts +15 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +9 -5
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +6 -5
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +14 -7
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +5 -3
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +14 -12
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +66 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +20 -11
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +44 -20
- package/src/cli/why.ts +10 -1
- package/src/cli/workspace-config.ts +2 -2
- package/src/graph/scheduler.ts +8 -0
- package/src/graph/task-graph.ts +32 -4
- package/src/index.ts +11 -1
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +26 -43
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/framed-output.ts +1 -2
- package/src/orchestrator/history.ts +60 -4
- package/src/orchestrator/index.ts +2 -1
- package/src/orchestrator/logger.ts +9 -2
- package/src/orchestrator/metrics.ts +7 -3
- package/src/orchestrator/plugin-host.ts +14 -4
- package/src/orchestrator/plugin.ts +80 -8
- package/src/orchestrator/prepare.ts +51 -7
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +9 -5
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run.ts +126 -27
- package/src/orchestrator/status-line.ts +1 -1
- package/src/orchestrator/summary.ts +69 -44
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/settings.ts +12 -1
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/index.ts +8 -1
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/ui.ts +186 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
package/src/cache/run-history.ts
CHANGED
|
@@ -34,6 +34,31 @@ const RUNS_PER_INSERT = 40
|
|
|
34
34
|
/** Rows per `task_durations` INSERT: 6 variables each, under 999. */
|
|
35
35
|
const DURATIONS_PER_INSERT = 160
|
|
36
36
|
|
|
37
|
+
/** Rows per `task_outcomes` INSERT: 6 variables each, under 999. */
|
|
38
|
+
const OUTCOMES_PER_INSERT = 160
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a run says anything of its key, so the flaky record may keep it:
|
|
42
|
+
* keyed, cached, and not run behind a failure (its bytes were not the key's).
|
|
43
|
+
*/
|
|
44
|
+
const keyed = (r: RunRecord): boolean =>
|
|
45
|
+
r.hash !== undefined && r.hash !== '' && r.cached !== false && r.blockedBy == null
|
|
46
|
+
|
|
47
|
+
/** A hit, as the orchestrator marks one (`isCacheHit`, run-records.ts). */
|
|
48
|
+
const isHit = (r: RunRecord): boolean => r.cacheHit === true
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Of a JSON array of hashes, those the record holds a pass of: one index
|
|
52
|
+
* probe each (0.6 ms for 1,000; a join reading every row of each key was
|
|
53
|
+
* 5.7 ms at 20 runs of history). A hit's row is kept only when its key has
|
|
54
|
+
* none: a hit replays a pass, and the first is what a later failure
|
|
55
|
+
* relapses from (an entry adopted from its artifact, or older than the
|
|
56
|
+
* record, has no executed row). By hash alone, as the entry it replays is.
|
|
57
|
+
*/
|
|
58
|
+
const PASSED_HASHES = `
|
|
59
|
+
SELECT j.value AS hash FROM json_each(?) AS j
|
|
60
|
+
WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
|
|
61
|
+
|
|
37
62
|
/** A local run's `task_durations.source`: its branch, `''` when it had none. */
|
|
38
63
|
export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
|
|
39
64
|
|
|
@@ -75,6 +100,7 @@ export class RunHistory {
|
|
|
75
100
|
.transaction(() => {
|
|
76
101
|
this.insertRuns(runs)
|
|
77
102
|
this.insertDurations(runs, branchSource(null), null)
|
|
103
|
+
this.insertOutcomes(runs)
|
|
78
104
|
})
|
|
79
105
|
.immediate()
|
|
80
106
|
}
|
|
@@ -94,6 +120,45 @@ export class RunHistory {
|
|
|
94
120
|
}
|
|
95
121
|
}
|
|
96
122
|
|
|
123
|
+
/**
|
|
124
|
+
* The flaky record's rows: every executed outcome, and a key's first hit,
|
|
125
|
+
* in the order given (a batch may hold one key's history).
|
|
126
|
+
*/
|
|
127
|
+
private insertOutcomes(runs: readonly RunRecord[]): void {
|
|
128
|
+
const rows = runs.filter(
|
|
129
|
+
(r) => keyed(r) && (isHit(r) || r.status === 'success' || r.status === 'failed'),
|
|
130
|
+
)
|
|
131
|
+
const hits = rows.filter(isHit)
|
|
132
|
+
let passed: Set<string> | undefined
|
|
133
|
+
if (hits.length > 0) {
|
|
134
|
+
const found = this.db.query(PASSED_HASHES).all(JSON.stringify(hits.map((r) => r.hash))) as {
|
|
135
|
+
hash: string
|
|
136
|
+
}[]
|
|
137
|
+
passed = new Set(found.map((f) => f.hash))
|
|
138
|
+
}
|
|
139
|
+
const kept: RunRecord[] = []
|
|
140
|
+
for (const r of rows) {
|
|
141
|
+
if (isHit(r)) {
|
|
142
|
+
if (passed!.has(r.hash!)) continue
|
|
143
|
+
passed!.add(r.hash!)
|
|
144
|
+
} else if (r.status === 'success') {
|
|
145
|
+
passed?.add(r.hash!)
|
|
146
|
+
}
|
|
147
|
+
kept.push(r)
|
|
148
|
+
}
|
|
149
|
+
for (let i = 0; i < kept.length; i += OUTCOMES_PER_INSERT) {
|
|
150
|
+
const block = kept.slice(i, i + OUTCOMES_PER_INSERT)
|
|
151
|
+
const params: SQLQueryBindings[] = []
|
|
152
|
+
for (const r of block)
|
|
153
|
+
params.push(r.project, r.task, r.hash!, r.status, r.attempts ?? null, r.startedAt)
|
|
154
|
+
this.db
|
|
155
|
+
.query(
|
|
156
|
+
`INSERT INTO task_outcomes(project, task, hash, status, attempts, recorded_at) VALUES ${Array(block.length).fill('(?, ?, ?, ?, ?, ?)').join(', ')}`,
|
|
157
|
+
)
|
|
158
|
+
.run(...params)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
97
162
|
/** `runs` as few INSERTs; each distinct forward-args list digested once. */
|
|
98
163
|
private insertRuns(runs: readonly RunRecord[]): void {
|
|
99
164
|
const digests = new Map<string, string>()
|
|
@@ -134,6 +199,7 @@ export class RunHistory {
|
|
|
134
199
|
branchSource(bundle.invocation.branch),
|
|
135
200
|
bundle.invocation.commitSha,
|
|
136
201
|
)
|
|
202
|
+
this.insertOutcomes(bundle.runs)
|
|
137
203
|
})
|
|
138
204
|
.immediate()
|
|
139
205
|
}
|
|
@@ -149,6 +215,8 @@ export class RunHistory {
|
|
|
149
215
|
this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
|
|
150
216
|
this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
|
|
151
217
|
this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
|
|
218
|
+
this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
|
|
219
|
+
this.db.prepare('DELETE FROM plugin_blobs WHERE at < ?').run(cutoff)
|
|
152
220
|
}
|
|
153
221
|
}
|
|
154
222
|
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// What core keeps of a run that writes a remote (owner 2026-10-15): a failed
|
|
2
|
+
// task's log and the files its outputs left, an uncached task's log. Names
|
|
3
|
+
// come from the run id and task (`run/<id>/<task>/log`); each key is derived
|
|
4
|
+
// in a namespace no task key or plugin entry shares. Remote only: locally
|
|
5
|
+
// `vx last --log` and the working tree already hold them. No size cap (M:
|
|
6
|
+
// the cache is free). Plugins read them by name (`ctx.cache.getRun`).
|
|
7
|
+
|
|
8
|
+
import { xxh3hex } from '../util/index.js'
|
|
9
|
+
import type { PluginCacheRemote } from './plugin-cache.js'
|
|
10
|
+
import { scopedKey } from './scoped-cache.js'
|
|
11
|
+
import { tarPackInto, tarSize } from './tar-stream.js'
|
|
12
|
+
|
|
13
|
+
/** The name core stores a task's `part` of run `runId` under. */
|
|
14
|
+
export function runEntryName(runId: string, taskId: string, part: 'log' | 'outputs'): string {
|
|
15
|
+
return `run/${runId}/${taskId}/${part}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const runKey = (name: string): string => xxh3hex(`vx-run\0${name}`)
|
|
19
|
+
|
|
20
|
+
/** The keys `name` is read under: the default, then the scope's, as tasks read. */
|
|
21
|
+
export function runReadKeys(name: string, scope: string | undefined): string[] {
|
|
22
|
+
const key = runKey(name)
|
|
23
|
+
return scope === undefined ? [key] : [key, scopedKey(scope, key)]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether some remote takes this run's writes: only then does core keep run bytes. */
|
|
27
|
+
export function writesRunStore(remotes: readonly PluginCacheRemote[]): boolean {
|
|
28
|
+
return remotes.some((r) => r.write)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Store `bytes` under `name` in every remote the policy writes. An entry is
|
|
33
|
+
* written once; a remote's failure leaves the others theirs, silently: what
|
|
34
|
+
* a run keeps here is a record, never its verdict.
|
|
35
|
+
*/
|
|
36
|
+
export async function putRunEntry(
|
|
37
|
+
remotes: readonly PluginCacheRemote[],
|
|
38
|
+
scope: string | undefined,
|
|
39
|
+
name: string,
|
|
40
|
+
bytes: Uint8Array,
|
|
41
|
+
): Promise<boolean> {
|
|
42
|
+
const key = scope === undefined ? runKey(name) : scopedKey(scope, runKey(name))
|
|
43
|
+
let kept = false
|
|
44
|
+
for (const { remote, write } of remotes) {
|
|
45
|
+
if (!write) continue
|
|
46
|
+
try {
|
|
47
|
+
if (!(await remote.has(key))) await remote.put(key, new Blob([bytes]), { durationMs: 0 })
|
|
48
|
+
kept = true
|
|
49
|
+
} catch {
|
|
50
|
+
// the other remotes still keep it
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return kept
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The files at `paths` (workspace-relative) as a plain ustar archive, names
|
|
58
|
+
* as given, so any `tar` reads it; null when none is left to read.
|
|
59
|
+
*/
|
|
60
|
+
export async function packRunOutputs(
|
|
61
|
+
workspaceRoot: string,
|
|
62
|
+
paths: readonly string[],
|
|
63
|
+
): Promise<Uint8Array | null> {
|
|
64
|
+
const inputs: { name: string; size: number; body: Uint8Array }[] = []
|
|
65
|
+
for (const name of paths) {
|
|
66
|
+
try {
|
|
67
|
+
const body = await Bun.file(`${workspaceRoot}/${name}`).bytes()
|
|
68
|
+
inputs.push({ name, size: body.byteLength, body })
|
|
69
|
+
} catch {
|
|
70
|
+
// gone since the glob: nothing to keep
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (inputs.length === 0) return null
|
|
74
|
+
const out = new Uint8Array(tarSize(inputs))
|
|
75
|
+
tarPackInto(inputs, out)
|
|
76
|
+
return out
|
|
77
|
+
}
|
package/src/cache/schema.ts
CHANGED
|
@@ -23,7 +23,8 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
23
23
|
/**
|
|
24
24
|
* Create every table. `store` names the schema holding the ENTRY tables
|
|
25
25
|
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
|
|
26
|
-
*
|
|
26
|
+
* the task timings (`task_durations`), the flaky record (`task_outcomes`),
|
|
27
|
+
* the blob sizes (`blob_sizes`) and the plugins' blobs (`plugin_blobs`):
|
|
27
28
|
* `main` when one `cache.db` holds everything (a `cacheDir`), `store` when
|
|
28
29
|
* they live in the shared store attached beside it (v32). The rest is the
|
|
29
30
|
* workspace's own: its history, its memos, and what its disk looked like
|
|
@@ -114,17 +115,14 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
114
115
|
-- runs_ended (2026-09-09) served only the retention DELETE, which
|
|
115
116
|
-- prunes on started_at now (a row ends after it starts, so the
|
|
116
117
|
-- 30-day window moves by at most one task's duration).
|
|
118
|
+
-- runs_failed (v38) served the flakiness probe, which reads the
|
|
119
|
+
-- store's task_outcomes now.
|
|
117
120
|
DROP INDEX IF EXISTS main.runs_hash;
|
|
118
121
|
DROP INDEX IF EXISTS main.runs_project;
|
|
119
122
|
DROP INDEX IF EXISTS main.runs_ended;
|
|
123
|
+
DROP INDEX IF EXISTS main.runs_failed;
|
|
120
124
|
CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
|
|
121
125
|
CREATE INDEX IF NOT EXISTS main.runs_run_id ON runs(run_id);
|
|
122
|
-
-- The one keyed index, PARTIAL over failed rows: a green run's
|
|
123
|
-
-- inserts only evaluate its predicate, so the append-only cost above
|
|
124
|
-
-- holds, and the flakiness probe after a miss (failure-mode.ts,
|
|
125
|
-
-- "did this key ever fail?") reads a handful of leaves instead of
|
|
126
|
-
-- scanning the table (2026-09-10: 10–95 ms at 170k rows without it).
|
|
127
|
-
CREATE INDEX IF NOT EXISTS main.runs_failed ON runs(hash) WHERE status = 'failed';
|
|
128
126
|
-- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
|
|
129
127
|
-- skip the content-hash on inputs whose stat hasn't changed
|
|
130
128
|
-- since the last run. Pure performance optimization; the stored
|
|
@@ -142,8 +140,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
142
140
|
-- The size of each index blob the enumeration checked against the
|
|
143
141
|
-- worktree size git recorded (A-60). Fixed for its OID, so a warm
|
|
144
142
|
-- run asks git for none; swept with file_hashes by seen_at, the
|
|
145
|
-
-- time the row was written.
|
|
146
|
-
|
|
143
|
+
-- time the row was written. v38: in the store, so a worktree reads
|
|
144
|
+
-- what another learned (owner, 2026-10-14).
|
|
145
|
+
CREATE TABLE IF NOT EXISTS ${store}.blob_sizes (
|
|
147
146
|
oid TEXT PRIMARY KEY,
|
|
148
147
|
size INTEGER NOT NULL,
|
|
149
148
|
seen_at INTEGER NOT NULL
|
|
@@ -275,6 +274,36 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
275
274
|
);
|
|
276
275
|
CREATE INDEX IF NOT EXISTS ${store}.task_durations_task
|
|
277
276
|
ON task_durations(project, task, source, id);
|
|
277
|
+
-- v37: a 'remote' row is a default-branch commit's timing a plugin
|
|
278
|
+
-- imported (\`sha\` that commit): one per task, so a repeat import is a
|
|
279
|
+
-- no-op under INSERT OR IGNORE.
|
|
280
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${store}.task_durations_remote
|
|
281
|
+
ON task_durations(sha, project, task) WHERE source = 'remote';
|
|
282
|
+
-- v38: the flaky record (orchestrator/failure-mode.ts): each keyed,
|
|
283
|
+
-- cached, unblocked outcome that observed its key, shared by every
|
|
284
|
+
-- worktree so a key that passed in one and failed in another is flaky
|
|
285
|
+
-- in both (owner, 2026-10-14). Every executed success or failure, and
|
|
286
|
+
-- a key's first hit (run-history.ts): a later hit replays a pass the
|
|
287
|
+
-- record holds, so a warm run on keys that passed writes no row.
|
|
288
|
+
-- Append-only, ordered by id, pruned by age with the run history.
|
|
289
|
+
CREATE TABLE IF NOT EXISTS ${store}.task_outcomes (
|
|
290
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
291
|
+
project TEXT NOT NULL,
|
|
292
|
+
task TEXT NOT NULL,
|
|
293
|
+
hash TEXT NOT NULL,
|
|
294
|
+
status TEXT NOT NULL,
|
|
295
|
+
attempts INTEGER,
|
|
296
|
+
recorded_at INTEGER NOT NULL
|
|
297
|
+
);
|
|
298
|
+
CREATE INDEX IF NOT EXISTS ${store}.task_outcomes_hash ON task_outcomes(hash, status);
|
|
299
|
+
-- v37: what a plugin keeps through \`ctx.cache\` (plugin-cache.ts), by
|
|
300
|
+
-- its namespaced key. Written once, never updated; pruned by age with
|
|
301
|
+
-- the run history.
|
|
302
|
+
CREATE TABLE IF NOT EXISTS ${store}.plugin_blobs (
|
|
303
|
+
key TEXT PRIMARY KEY,
|
|
304
|
+
bytes BLOB NOT NULL,
|
|
305
|
+
at INTEGER NOT NULL
|
|
306
|
+
);
|
|
278
307
|
${artifactTables(store)}
|
|
279
308
|
`)
|
|
280
309
|
}
|
|
@@ -16,7 +16,7 @@ import type {
|
|
|
16
16
|
} from './layer.js'
|
|
17
17
|
|
|
18
18
|
/** The key a scope stores `hash` under: task-key shaped, 16 hex, so every wire stores it unchanged. */
|
|
19
|
-
function scopedKey(scope: string, hash: string): string {
|
|
19
|
+
export function scopedKey(scope: string, hash: string): string {
|
|
20
20
|
return xxh3hex(`vx-cache-scope\0${scope}\0${hash}`)
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -75,6 +75,13 @@ export class ScopedCache implements CacheLayer {
|
|
|
75
75
|
return out
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/** A task key the store holds under the default key or the scope's. */
|
|
79
|
+
async localHasMany(hashes: readonly string[]): Promise<Set<string>> {
|
|
80
|
+
const held = await this.inner.localHasMany?.([...hashes, ...hashes.map((h) => this.own(h))])
|
|
81
|
+
if (held === undefined) return new Set()
|
|
82
|
+
return new Set(hashes.filter((h) => held.has(h) || held.has(this.own(h))))
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
79
86
|
const scoped = hashes.map((h) => this.own(h))
|
|
80
87
|
const [plain, own] = await Promise.all([
|
|
@@ -105,7 +112,13 @@ export class ScopedCache implements CacheLayer {
|
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
108
|
-
|
|
115
|
+
// The scope's key first when only it is local: the default key's
|
|
116
|
+
// remote GET would cost a round-trip for the same inputs.
|
|
117
|
+
const own = this.own(hash)
|
|
118
|
+
const keys = (await this.inner.localHasMany?.([hash, own]))?.has(own)
|
|
119
|
+
? [own, hash]
|
|
120
|
+
: [hash, own]
|
|
121
|
+
for (const key of keys) {
|
|
109
122
|
const entry = await this.inner.get(key, ctx)
|
|
110
123
|
if (entry === null) continue
|
|
111
124
|
this.served.set(hash, key)
|
package/src/cli/cache.ts
CHANGED
|
@@ -3,7 +3,15 @@ import { homedir } from 'node:os'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import { Cache, vxCacheEntries } from '../cache/index.js'
|
|
5
5
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
errorCode,
|
|
8
|
+
nearest,
|
|
9
|
+
parseDuration,
|
|
10
|
+
parseSize,
|
|
11
|
+
relPosix,
|
|
12
|
+
treeBytes,
|
|
13
|
+
ui,
|
|
14
|
+
} from '../util/index.js'
|
|
7
15
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
8
16
|
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
9
17
|
import {
|
|
@@ -254,15 +262,28 @@ function printPruned(
|
|
|
254
262
|
)
|
|
255
263
|
return
|
|
256
264
|
}
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}`
|
|
266
|
+
const facts = [
|
|
267
|
+
...(result.evicted > 0 ? [plural(result.evicted, 'entry', 'entries')] : []),
|
|
268
|
+
...(result.orphans > 0
|
|
269
|
+
? [plural(result.orphans, 'orphaned artifact', 'orphaned artifacts')]
|
|
270
|
+
: []),
|
|
271
|
+
]
|
|
272
|
+
ui.print(
|
|
273
|
+
'out',
|
|
274
|
+
freedStatus(dry, result.bytesFreed + result.orphanBytes, facts.length > 0),
|
|
275
|
+
...(facts.length > 0 ? [ui.text(facts.join(' · '))] : []),
|
|
263
276
|
)
|
|
264
277
|
}
|
|
265
278
|
|
|
279
|
+
/** `✓ vx cache clean freed 1.3 GB`, `› … would free 1.3 GB (dry run)`, or nothing to free. */
|
|
280
|
+
function freedStatus(dry: boolean, bytes: number, any: boolean): ui.Block {
|
|
281
|
+
if (!any) return ui.status('info', 'vx cache clean', `nothing to free${dry ? ' (dry run)' : ''}`)
|
|
282
|
+
return dry
|
|
283
|
+
? ui.status('info', 'vx cache clean', `would free ${formatBytes(bytes)} (dry run)`)
|
|
284
|
+
: ui.status('ok', 'vx cache clean', `freed ${formatBytes(bytes)}`)
|
|
285
|
+
}
|
|
286
|
+
|
|
266
287
|
/** `vx cache clean --format json` with no bound (schemas/cache.json `clear`). */
|
|
267
288
|
export interface ClearResult {
|
|
268
289
|
dryRun: boolean
|
|
@@ -341,16 +362,12 @@ async function clear(
|
|
|
341
362
|
if (parsed.format === 'json') {
|
|
342
363
|
const result: ClearResult = { dryRun: dry, cleared: shown, bytesFreed: bytes }
|
|
343
364
|
process.stdout.write(`${JSON.stringify(result)}\n`)
|
|
344
|
-
} else
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
: "this repository's store, shared by its checkouts, and this workspace's run history"
|
|
350
|
-
process.stdout.write(
|
|
351
|
-
`${dry ? 'Would clear' : 'Cleared'} ${scope} (${formatBytes(bytes)}${dry ? '' : ' freed'}):\n${shown.map((t) => ` ${t}\n`).join('')}`,
|
|
365
|
+
} else
|
|
366
|
+
ui.print(
|
|
367
|
+
'out',
|
|
368
|
+
freedStatus(dry, bytes, shown.length > 0),
|
|
369
|
+
...(shown.length > 0 ? [ui.text(...shown)] : []),
|
|
352
370
|
)
|
|
353
|
-
}
|
|
354
371
|
return 0
|
|
355
372
|
}
|
|
356
373
|
|
package/src/cli/completions.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// Tab is the wrong price).
|
|
7
7
|
|
|
8
8
|
import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
|
|
9
|
-
import { nearest } from '../util/index.js'
|
|
9
|
+
import { nearest, sayNotice } from '../util/index.js'
|
|
10
10
|
|
|
11
11
|
/** A flag whose value is one of a fixed set; the tests hold each to its parser. */
|
|
12
12
|
export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
|
|
@@ -139,8 +139,10 @@ export async function completionsCmd(
|
|
|
139
139
|
if (args.length !== 1 || !(SHELLS as readonly string[]).includes(shell ?? '')) {
|
|
140
140
|
const flag = args.find((a) => a.startsWith('-'))
|
|
141
141
|
if (flag !== undefined) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
sayNotice(
|
|
143
|
+
'error',
|
|
144
|
+
'vx completions',
|
|
145
|
+
`unknown flag: ${flag}${flagHint('completions', flag)}${seeHelp('completions')}`,
|
|
144
146
|
)
|
|
145
147
|
return 1
|
|
146
148
|
}
|
|
@@ -148,8 +150,10 @@ export async function completionsCmd(
|
|
|
148
150
|
// One edit: shell names are three or four letters, so two edits took
|
|
149
151
|
// `tcsh` to `bash`, a different shell.
|
|
150
152
|
const best = one ? nearest(shell, SHELLS, 1) : undefined
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
sayNotice(
|
|
154
|
+
'error',
|
|
155
|
+
'vx completions',
|
|
156
|
+
`expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}`,
|
|
153
157
|
)
|
|
154
158
|
return 1
|
|
155
159
|
}
|
package/src/cli/help.ts
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
CORE_VERBS,
|
|
3
|
+
detectColors,
|
|
4
|
+
errorDocument,
|
|
5
|
+
maskedLine,
|
|
6
|
+
nearest,
|
|
7
|
+
paint,
|
|
8
|
+
refusalNotice,
|
|
9
|
+
ui,
|
|
10
|
+
wantsJson,
|
|
11
|
+
type ColorSupport,
|
|
12
|
+
} from '../util/index.js'
|
|
3
13
|
|
|
4
14
|
export async function printHelp(
|
|
5
15
|
pluginCommands: readonly string[] = [],
|
|
6
16
|
verb?: string,
|
|
7
17
|
): Promise<void> {
|
|
8
18
|
const text = verb === undefined ? helpText(pluginCommands) : verbHelpText(verb)
|
|
9
|
-
// The orchestrator is already loaded by the plugin-verb lookup every
|
|
10
|
-
// caller awaits first, so this import costs `vx --version` nothing.
|
|
11
|
-
const { detectColors, paint } = await import('../orchestrator/index.js')
|
|
12
19
|
process.stdout.write(paintHelp(text, helpColors(process.stdout, detectColors), paint))
|
|
13
20
|
}
|
|
14
21
|
|
|
@@ -33,7 +40,7 @@ const TERM = '#22d3ee' // cyan-400 — a flag, a selector, an example
|
|
|
33
40
|
* `(for <verb>)` dim), `vx <verb>` bold cyan, and the term an option or
|
|
34
41
|
* example line opens with cyan. Prose and continuation lines stay plain.
|
|
35
42
|
*/
|
|
36
|
-
export function paintHelp(text: string, colors: ColorSupport, paint:
|
|
43
|
+
export function paintHelp(text: string, colors: ColorSupport, paint: Paint): string {
|
|
37
44
|
if (!colors.enabled) return text
|
|
38
45
|
const lines = text.split('\n')
|
|
39
46
|
return lines
|
|
@@ -445,7 +452,9 @@ export function refuse(
|
|
|
445
452
|
code: string,
|
|
446
453
|
): number {
|
|
447
454
|
const line = `vx ${verb}: ${message}`
|
|
448
|
-
|
|
455
|
+
ui.print('err', refusalNotice(`vx ${verb}`, message))
|
|
449
456
|
if (wantsJson(args)) process.stdout.write(errorDocument(code, maskedLine(line)))
|
|
450
457
|
return 1
|
|
451
458
|
}
|
|
459
|
+
|
|
460
|
+
type Paint = typeof paint
|
package/src/cli/index.ts
CHANGED
|
@@ -8,11 +8,14 @@ import { FOREIGN_VERBS } from './foreign-flags.js'
|
|
|
8
8
|
import {
|
|
9
9
|
errorDocument,
|
|
10
10
|
hideEnvFromTasks,
|
|
11
|
+
sayNotice,
|
|
11
12
|
isFsRefusal,
|
|
12
13
|
isOutOfFds,
|
|
13
14
|
isUserError,
|
|
14
15
|
maskedLine,
|
|
15
16
|
nearest,
|
|
17
|
+
refusalNotice,
|
|
18
|
+
ui,
|
|
16
19
|
UserError,
|
|
17
20
|
wantsJson,
|
|
18
21
|
} from '../util/index.js'
|
|
@@ -74,7 +77,8 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
74
77
|
await findWorkspaceRoot(process.cwd())
|
|
75
78
|
} catch (err) {
|
|
76
79
|
if (!isUserError(err)) throw err
|
|
77
|
-
|
|
80
|
+
const parts = err.parts ?? { headline: err.message }
|
|
81
|
+
ui.print('err', refusalNotice('vx', err.message, { ...parts, why: 'see vx help' }))
|
|
78
82
|
return 1
|
|
79
83
|
}
|
|
80
84
|
await printHelp(await (await plugins()).pluginCommandHelp())
|
|
@@ -97,19 +101,23 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
97
101
|
const unknown = resolved === null || 'declaredVerbs' in resolved
|
|
98
102
|
const pointer = FOREIGN_VERBS[verb]
|
|
99
103
|
if (unknown && pointer !== undefined) {
|
|
100
|
-
|
|
104
|
+
sayNotice('info', 'vx', pointer)
|
|
101
105
|
return 1
|
|
102
106
|
}
|
|
103
107
|
if (unknown && verb.startsWith('-')) {
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
sayNotice(
|
|
109
|
+
'error',
|
|
110
|
+
'vx help',
|
|
111
|
+
`unknown flag: ${verb}${flagHint('help', verb)} (see \`vx help\`)`,
|
|
106
112
|
)
|
|
107
113
|
return 1
|
|
108
114
|
}
|
|
109
115
|
if (unknown) {
|
|
110
116
|
const declared = resolved === null ? [] : resolved.declaredVerbs
|
|
111
|
-
|
|
112
|
-
|
|
117
|
+
sayNotice(
|
|
118
|
+
'error',
|
|
119
|
+
'vx help',
|
|
120
|
+
`unknown command: ${verb}${didYouMeanVerb(verb, declared)} (see \`vx help\`)`,
|
|
113
121
|
)
|
|
114
122
|
return 1
|
|
115
123
|
}
|
|
@@ -123,8 +131,10 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
123
131
|
// takes no more of is refused, as every other verb refuses one.
|
|
124
132
|
const extra = rest[0]
|
|
125
133
|
if (extra !== undefined) {
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
sayNotice(
|
|
135
|
+
'error',
|
|
136
|
+
'vx version',
|
|
137
|
+
`${refusedWord(extra)}: ${extra}${flagHint('version', extra)}${seeHelp('version')}`,
|
|
128
138
|
)
|
|
129
139
|
return 1
|
|
130
140
|
}
|
|
@@ -193,8 +203,12 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
193
203
|
// points at the file that broke it.
|
|
194
204
|
// Every answer below is "no such verb here": under --format json the
|
|
195
205
|
// agent reads it as one code, as a verb's own refusal gives one.
|
|
196
|
-
const unknown = (
|
|
197
|
-
|
|
206
|
+
const unknown = (
|
|
207
|
+
text: string,
|
|
208
|
+
code = 'VX_E_UNKNOWN_COMMAND',
|
|
209
|
+
tone: 'error' | 'info' = 'error',
|
|
210
|
+
): number => {
|
|
211
|
+
sayNotice(tone, 'vx', text.replace(/^vx: /, '').trimEnd())
|
|
198
212
|
if (wantsJson(rest))
|
|
199
213
|
process.stdout.write(errorDocument(code, maskedLine(text.split('\n')[0]!)))
|
|
200
214
|
return 1
|
|
@@ -207,7 +221,7 @@ async function dispatch(command: string | undefined, rest: string[]): Promise<nu
|
|
|
207
221
|
// their chance — a workspace that declares a plugin verb of that name keeps it.
|
|
208
222
|
const moved = FOREIGN_VERBS[command]
|
|
209
223
|
if (moved !== undefined) {
|
|
210
|
-
return unknown(`${moved}${loadNote}\n
|
|
224
|
+
return unknown(`${moved}${loadNote}\n`, undefined, 'info')
|
|
211
225
|
}
|
|
212
226
|
// A task typed where the verb goes (`turbo build`, `nx build app`),
|
|
213
227
|
// `turbo dev` included: a repo's own `dev` task beats the no-service note.
|
package/src/cli/info.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/ind
|
|
|
8
8
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
9
9
|
import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
|
|
10
10
|
import { formatBytes } from './format.js'
|
|
11
|
-
import { MIN_BUN } from '../util/index.js'
|
|
11
|
+
import { detectColors, MIN_BUN, ui, type ColorSupport } from '../util/index.js'
|
|
12
12
|
import { envName } from '../settings.js'
|
|
13
13
|
|
|
14
14
|
interface InfoArgs {
|
|
@@ -55,12 +55,14 @@ export async function infoCmd(args: readonly string[]): Promise<number> {
|
|
|
55
55
|
warn: warnToStderr,
|
|
56
56
|
})
|
|
57
57
|
process.stdout.write(
|
|
58
|
-
parsed.format === 'json'
|
|
58
|
+
parsed.format === 'json'
|
|
59
|
+
? `${JSON.stringify(facts)}\n`
|
|
60
|
+
: `${renderInfo(facts, detectColors(process.stdout))}\n`,
|
|
59
61
|
)
|
|
60
62
|
return 0
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
export function renderInfo(f: InfoFacts): string {
|
|
65
|
+
export function renderInfo(f: InfoFacts, colors: ColorSupport = { enabled: false }): string {
|
|
64
66
|
const rows: [string, string][] = [
|
|
65
67
|
[
|
|
66
68
|
'vx',
|
|
@@ -122,8 +124,7 @@ export function renderInfo(f: InfoFacts): string {
|
|
|
122
124
|
['sandbox', describeSandbox(f.sandbox)],
|
|
123
125
|
['projects lock', f.lockfile ? 'yes' : 'no'],
|
|
124
126
|
]
|
|
125
|
-
|
|
126
|
-
return rows.map(([label, value]) => `${`${label}:`.padEnd(labelW + 1)} ${value}`).join('\n')
|
|
127
|
+
return ui.report('vx info', rows).lines(colors).join('\n')
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
/** `812 (1.2 GB)`, or against `cacheRetention.maxSize`: `812 (1.2 GB of 10 GB, 12%)`. */
|
package/src/cli/init.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { lockAfterMigration } from './lock.js'
|
|
|
15
15
|
import { PLUGIN_TEMPLATES } from './plugin-templates.js'
|
|
16
16
|
import { isCompiledBinary } from './upgrade.js'
|
|
17
17
|
import { VERSION } from '../version.js'
|
|
18
|
-
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, UserError } from '../util/index.js'
|
|
18
|
+
import { DOT_VX_GITATTRIBUTES, DOT_VX_GITIGNORE, ui, UserError, sayNotice } from '../util/index.js'
|
|
19
19
|
import {
|
|
20
20
|
findWorkspaceRoot,
|
|
21
21
|
PROJECT_CONFIG_FILENAMES,
|
|
@@ -125,7 +125,7 @@ async function scaffoldPlugin(seam: string, root: string, args: InitArgs): Promi
|
|
|
125
125
|
if (!args.force) {
|
|
126
126
|
for (const [rel] of files) {
|
|
127
127
|
if (await Bun.file(path.join(root, rel)).exists()) {
|
|
128
|
-
|
|
128
|
+
sayNotice('error', 'vx init', `${rel} exists. --force overwrites it`)
|
|
129
129
|
return 1
|
|
130
130
|
}
|
|
131
131
|
}
|
|
@@ -193,27 +193,26 @@ async function starter(root: string, noGit: boolean, args: InitArgs): Promise<nu
|
|
|
193
193
|
)
|
|
194
194
|
return 0
|
|
195
195
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
]
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
report.push(args.dry ? `would write ${named} (dry run, nothing written).` : `wrote ${named}.`)
|
|
202
|
-
if (kept.length > 0) report.push(`kept ${kept.join(', ')}.`)
|
|
203
|
-
if (replaced.length > 0)
|
|
204
|
-
report.push(`${args.dry ? 'would replace' : 'replaced'} ${replaced.join(', ')}.`)
|
|
196
|
+
const rows: ui.FieldRow[] = [['from', 'no package.json: one project, the root']]
|
|
197
|
+
if (args.dry) rows.push(['dry run', 'nothing written'])
|
|
198
|
+
if (files.length > 0) rows.push(['files', ...files.map((f) => f.path)])
|
|
199
|
+
if (kept.length > 0) rows.push(['kept', ...kept])
|
|
200
|
+
if (replaced.length > 0) rows.push(['replaced', ...replaced])
|
|
205
201
|
if (args.dry) {
|
|
206
202
|
for (const f of files) process.stdout.write(`── ${f.path} ──\n${f.contents}\n`)
|
|
207
|
-
|
|
203
|
+
ui.print('out', ui.report(`vx init ${VERSION}`, [...rows, null, ['next', next]]))
|
|
208
204
|
return 0
|
|
209
205
|
}
|
|
210
206
|
for (const f of files) await Bun.write(path.join(root, f.path), f.contents)
|
|
211
207
|
for (const f of replaced) await unlink(path.join(root, f))
|
|
212
208
|
// The lock that follows loads the workspace file just written.
|
|
213
209
|
workspaceFileWritten()
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
210
|
+
const lock = await lockAfterMigration(root)
|
|
211
|
+
rows.push(['lock', lock.code === 0 ? lock.said.replace(/^locked /, '') : 'not written'], null, [
|
|
212
|
+
'next',
|
|
213
|
+
lock.code === 0 ? next : `fix the above, then vx lock and ${next}`,
|
|
214
|
+
])
|
|
215
|
+
ui.print('out', ui.report(`vx init ${VERSION}`, rows))
|
|
217
216
|
return 0
|
|
218
217
|
}
|
|
219
218
|
|