@vzn/vx 0.0.652 → 0.0.653
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 +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/cache/run-history.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { lazyStatement } from './schema.js'
|
|
|
8
8
|
import type { InvocationRecord, RunRecord } from './layer.js'
|
|
9
9
|
import { isMeasure } from '../util/index.js'
|
|
10
10
|
|
|
11
|
-
/** The `runs` columns `bindRun` fills, in its order. */
|
|
11
|
+
/** The `main.runs` columns `bindRun` fills, in its order. */
|
|
12
12
|
const RUN_COLUMNS = [
|
|
13
13
|
'hash',
|
|
14
14
|
'project',
|
|
@@ -19,7 +19,7 @@ const RUN_COLUMNS = [
|
|
|
19
19
|
'forward_args',
|
|
20
20
|
'started_at',
|
|
21
21
|
'ended_at',
|
|
22
|
-
'
|
|
22
|
+
'run',
|
|
23
23
|
'cpu_ms',
|
|
24
24
|
'peak_rss_bytes',
|
|
25
25
|
'wallclock_start_ns',
|
|
@@ -55,7 +55,7 @@ const RUN_COLUMNS = [
|
|
|
55
55
|
*/
|
|
56
56
|
function insertRunsSql(kept: readonly number[], n: number): string {
|
|
57
57
|
const tuple = `(${kept.map(() => '?').join(', ')})`
|
|
58
|
-
return `INSERT INTO runs(${kept.map((i) => RUN_COLUMNS[i]).join(', ')}) VALUES ${Array(n).fill(tuple).join(', ')}`
|
|
58
|
+
return `INSERT INTO main.runs(${kept.map((i) => RUN_COLUMNS[i]).join(', ')}) VALUES ${Array(n).fill(tuple).join(', ')}`
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
/**
|
|
@@ -93,6 +93,10 @@ const PASSED_HASHES = `
|
|
|
93
93
|
SELECT j.value AS hash FROM json_each(?) AS j
|
|
94
94
|
WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
|
|
95
95
|
|
|
96
|
+
/** The commit a run ran on, as `task_durations` files its timings. */
|
|
97
|
+
type RunHead = Pick<InvocationRecord, 'branch' | 'commitSha'>
|
|
98
|
+
const NO_HEAD: RunHead = { branch: null, commitSha: null }
|
|
99
|
+
|
|
96
100
|
/** A local run's `task_durations.source`: its branch, `''` when it had none. */
|
|
97
101
|
export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
|
|
98
102
|
|
|
@@ -140,14 +144,19 @@ export class RunHistory {
|
|
|
140
144
|
this.recordRuns([run])
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Rows with no `invocations` header (a stopped run, item 854). `head` is
|
|
149
|
+
* the commit the run ran on: its timings file under that branch, as a
|
|
150
|
+
* finished run's do, or the branch's forecast never reads them.
|
|
151
|
+
*/
|
|
152
|
+
recordRuns(runs: readonly RunRecord[], head: RunHead = NO_HEAD): void {
|
|
144
153
|
if (runs.length === 0) return
|
|
145
154
|
// One transaction, one fsync: for a 200-task run, one instead of 200.
|
|
146
155
|
// IMMEDIATE, as `recordRunBundle` explains.
|
|
147
156
|
this.db
|
|
148
157
|
.transaction(() => {
|
|
149
158
|
this.insertRuns(runs)
|
|
150
|
-
this.insertDurations(runs, branchSource(
|
|
159
|
+
this.insertDurations(runs, branchSource(head.branch), head.commitSha)
|
|
151
160
|
this.insertOutcomes(runs)
|
|
152
161
|
})
|
|
153
162
|
.immediate()
|
|
@@ -239,7 +248,13 @@ export class RunHistory {
|
|
|
239
248
|
if (d === undefined) digests.set(value, (d = this.digestValue(value)))
|
|
240
249
|
return d
|
|
241
250
|
}
|
|
242
|
-
const
|
|
251
|
+
const keys = new Map<string, number>()
|
|
252
|
+
const key = (runId: string): number => {
|
|
253
|
+
let k = keys.get(runId)
|
|
254
|
+
if (k === undefined) keys.set(runId, (k = this.runKey(runId)))
|
|
255
|
+
return k
|
|
256
|
+
}
|
|
257
|
+
const bound = runs.map((r) => bindRun(r, digest, key))
|
|
243
258
|
const filled = RUN_COLUMNS.map(() => false)
|
|
244
259
|
for (const row of bound)
|
|
245
260
|
for (let c = 0; c < row.length; c++) if (row[c] !== null) filled[c] = true
|
|
@@ -255,6 +270,19 @@ export class RunHistory {
|
|
|
255
270
|
}
|
|
256
271
|
}
|
|
257
272
|
|
|
273
|
+
/** A run's row in `run_ids`, made on its first rows; inside their transaction. */
|
|
274
|
+
private runKey(runId: string): number {
|
|
275
|
+
const found = this.db.query('SELECT id FROM run_ids WHERE run_id = ?').get(runId) as {
|
|
276
|
+
id: number
|
|
277
|
+
} | null
|
|
278
|
+
return (
|
|
279
|
+
found ??
|
|
280
|
+
(this.db.query('INSERT INTO run_ids(run_id) VALUES (?) RETURNING id').get(runId) as {
|
|
281
|
+
id: number
|
|
282
|
+
})
|
|
283
|
+
).id
|
|
284
|
+
}
|
|
285
|
+
|
|
258
286
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
259
287
|
// Whole run records atomically — one transaction, one fsync: the
|
|
260
288
|
// per-task `runs` rows and the one `invocations` header. The
|
|
@@ -291,7 +319,9 @@ export class RunHistory {
|
|
|
291
319
|
runId: string,
|
|
292
320
|
errors: readonly (readonly [hash: string, text: string])[],
|
|
293
321
|
): void {
|
|
294
|
-
const update = this.db.prepare(
|
|
322
|
+
const update = this.db.prepare(
|
|
323
|
+
'UPDATE main.runs SET remote_error = ? WHERE run = (SELECT id FROM run_ids WHERE run_id = ?) AND hash = ?',
|
|
324
|
+
)
|
|
295
325
|
this.db
|
|
296
326
|
.transaction(() => {
|
|
297
327
|
for (const [hash, text] of errors) update.run(text, runId, hash)
|
|
@@ -307,7 +337,13 @@ export class RunHistory {
|
|
|
307
337
|
* outlive its rows and `vx last` would list a run whose detail is gone.
|
|
308
338
|
*/
|
|
309
339
|
pruneOlderThan(cutoff: number): void {
|
|
310
|
-
this.db.prepare('DELETE FROM runs WHERE started_at < ?').run(cutoff)
|
|
340
|
+
this.db.prepare('DELETE FROM main.runs WHERE started_at < ?').run(cutoff)
|
|
341
|
+
// A run's id goes with its last row; ids only grow, so the oldest kept row bounds them.
|
|
342
|
+
this.db
|
|
343
|
+
.prepare(
|
|
344
|
+
'DELETE FROM run_ids WHERE id < COALESCE((SELECT MIN(run) FROM main.runs), (SELECT MAX(id) + 1 FROM run_ids))',
|
|
345
|
+
)
|
|
346
|
+
.run()
|
|
311
347
|
this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
|
|
312
348
|
this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
|
|
313
349
|
this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
|
|
@@ -317,7 +353,11 @@ export class RunHistory {
|
|
|
317
353
|
}
|
|
318
354
|
|
|
319
355
|
/** A RunRecord as one value per `RUN_COLUMNS` entry, in its order. */
|
|
320
|
-
function bindRun(
|
|
356
|
+
function bindRun(
|
|
357
|
+
run: RunRecord,
|
|
358
|
+
digestValue: (v: string) => string,
|
|
359
|
+
runKey: (runId: string) => number,
|
|
360
|
+
): SQLQueryBindings[] {
|
|
321
361
|
return [
|
|
322
362
|
// The ONE place the no-key sentinel is applied, so the column's
|
|
323
363
|
// NOT NULL invariant can't be violated from a call site.
|
|
@@ -333,7 +373,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
|
|
|
333
373
|
run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
|
|
334
374
|
run.startedAt,
|
|
335
375
|
run.endedAt,
|
|
336
|
-
run.runId
|
|
376
|
+
run.runId === undefined ? null : runKey(run.runId),
|
|
337
377
|
run.cpuMs ?? null,
|
|
338
378
|
run.peakRssBytes ?? null,
|
|
339
379
|
run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
|
package/src/cache/schema.ts
CHANGED
|
@@ -23,6 +23,24 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
23
23
|
} as unknown as ReturnType<Database['prepare']>
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What every reader and `vx last --sql` means by `runs`: the rows with
|
|
28
|
+
* their run_id, in the columns' old order. TEMP, so no other vx meets it: a
|
|
29
|
+
* persistent view would fail an older vx's `DROP TABLE runs` when it resets
|
|
30
|
+
* the index. It shadows main.runs, which only the writers name, so a
|
|
31
|
+
* VACUUM drops it first: rebuilding `runs_run ON runs(run)` resolves `runs`
|
|
32
|
+
* to the view and fails ("views may not be indexed").
|
|
33
|
+
*/
|
|
34
|
+
export const RUNS_VIEW = `CREATE TEMP VIEW IF NOT EXISTS runs AS
|
|
35
|
+
SELECT r.id, r.hash, r.project, r.task, r.status, r.exit_code, r.duration_ms,
|
|
36
|
+
r.forward_args, r.started_at, r.ended_at, k.run_id, r.cpu_ms, r.peak_rss_bytes,
|
|
37
|
+
r.wallclock_start_ns, r.wallclock_end_ns, r.cache_hit, r.attempts, r.cached,
|
|
38
|
+
r.blocked_by, r.timed_out, r.sandbox_violations, r.not_ready, r.restored,
|
|
39
|
+
r.remote_error, r.peak_cores, r.avg_rss_bytes, r.placement, r.placement_cores,
|
|
40
|
+
r.placement_memory, r.keyless_by, r.def, r.cpu_share, r.queued_ms,
|
|
41
|
+
r.output_bytes, r.blocks, r.flaky_passes, r.flaky_failures
|
|
42
|
+
FROM main.runs r LEFT JOIN main.run_ids k ON k.id = r.run;`
|
|
43
|
+
|
|
26
44
|
/**
|
|
27
45
|
* Create every table. `store` names the schema holding the ENTRY tables
|
|
28
46
|
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
|
|
@@ -51,15 +69,21 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
51
69
|
accessed_at INTEGER NOT NULL,
|
|
52
70
|
-- v26: the producing execution's usage, from the artifact's sidecar.
|
|
53
71
|
cpu_ms INTEGER,
|
|
54
|
-
peak_rss_bytes INTEGER
|
|
72
|
+
peak_rss_bytes INTEGER,
|
|
73
|
+
-- v54: the run whose save or download wrote the entry as it stands
|
|
74
|
+
-- (created_at is when): \`vx last --log\` shows a run's log from the
|
|
75
|
+
-- entry only when it holds that run's output.
|
|
76
|
+
run_id TEXT
|
|
55
77
|
);
|
|
56
78
|
-- v29: an entry's stdout, which the <hash>.tar.zst artifact also
|
|
57
79
|
-- carries so it survives a remote round trip; a local hit replays it
|
|
58
80
|
-- from here. Its own table: an UPDATE rewrites a whole record, and the
|
|
59
81
|
-- accessed_at bump rewrote up to 16 MB a hit. No row: empty stdout.
|
|
82
|
+
-- v52: a zstd frame of the UTF-8 (storedLog); as text, logs were most
|
|
83
|
+
-- of a store's bytes.
|
|
60
84
|
CREATE TABLE IF NOT EXISTS ${store}.entry_stdout (
|
|
61
85
|
hash TEXT PRIMARY KEY,
|
|
62
|
-
stdout
|
|
86
|
+
stdout BLOB NOT NULL,
|
|
63
87
|
FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
64
88
|
);
|
|
65
89
|
CREATE TABLE IF NOT EXISTS main.runs (
|
|
@@ -75,10 +99,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
75
99
|
forward_args TEXT,
|
|
76
100
|
started_at INTEGER NOT NULL,
|
|
77
101
|
ended_at INTEGER NOT NULL,
|
|
78
|
-
--
|
|
79
|
-
--
|
|
80
|
-
--
|
|
81
|
-
|
|
102
|
+
-- v53: the run's row in run_ids, not its 36-char id: the text
|
|
103
|
+
-- and its index were a third of what an all-hit run added. The
|
|
104
|
+
-- \`runs\` view below shows it as run_id again.
|
|
105
|
+
run INTEGER,
|
|
82
106
|
cpu_ms INTEGER,
|
|
83
107
|
peak_rss_bytes INTEGER,
|
|
84
108
|
wallclock_start_ns INTEGER,
|
|
@@ -135,7 +159,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
135
159
|
flaky_failures INTEGER
|
|
136
160
|
);
|
|
137
161
|
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
138
|
-
-- same
|
|
162
|
+
-- same run and a started_at newer than everything before it, so
|
|
139
163
|
-- 1,000 inserts touch a handful of leaf pages. The dropped ones did
|
|
140
164
|
-- not — the DROPs shed them from existing databases (a schema-meta
|
|
141
165
|
-- bump is for stored shapes, and an index is not one):
|
|
@@ -156,7 +180,13 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
156
180
|
DROP INDEX IF EXISTS main.runs_ended;
|
|
157
181
|
DROP INDEX IF EXISTS main.runs_failed;
|
|
158
182
|
CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
|
|
159
|
-
CREATE INDEX IF NOT EXISTS main.
|
|
183
|
+
CREATE INDEX IF NOT EXISTS main.runs_run ON runs(run);
|
|
184
|
+
-- v53: each run's id once; a runs row names it by this rowid.
|
|
185
|
+
CREATE TABLE IF NOT EXISTS main.run_ids (
|
|
186
|
+
id INTEGER PRIMARY KEY,
|
|
187
|
+
run_id TEXT NOT NULL UNIQUE
|
|
188
|
+
);
|
|
189
|
+
${RUNS_VIEW}
|
|
160
190
|
-- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
|
|
161
191
|
-- skip the content-hash on inputs whose stat hasn't changed
|
|
162
192
|
-- since the last run. Pure performance optimization; the stored
|
|
@@ -215,7 +245,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
215
245
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
216
246
|
);
|
|
217
247
|
-- v28, its own table since v32: the inode and ctime THIS workspace saw
|
|
218
|
-
-- after the save or restore that left the file equal to the entry
|
|
248
|
+
-- after the save or restore that left the file equal to the entry, and
|
|
249
|
+
-- since v55 its mtime: a restore dates a file it writes now, not with
|
|
250
|
+
-- the entry's saved mtime, so the row's is not the file's. No
|
|
219
251
|
-- row: never current. Apart from output_files because the entry is
|
|
220
252
|
-- shared and the disk is not: two worktrees on one store overwrote each
|
|
221
253
|
-- other's stamps, and every switch between them restored. No foreign
|
|
@@ -226,6 +258,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
226
258
|
path TEXT NOT NULL,
|
|
227
259
|
ino INTEGER NOT NULL,
|
|
228
260
|
ctime_ms INTEGER NOT NULL,
|
|
261
|
+
mtime_ms INTEGER NOT NULL,
|
|
229
262
|
PRIMARY KEY (entry_hash, path)
|
|
230
263
|
);
|
|
231
264
|
-- Every directory under a whole-subtree output glob, with its mtime
|
package/src/cli/cache.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
accessSync,
|
|
3
|
+
constants,
|
|
4
|
+
existsSync,
|
|
5
|
+
lstatSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
} from 'node:fs'
|
|
2
10
|
import { homedir } from 'node:os'
|
|
3
11
|
import path from 'node:path'
|
|
4
|
-
import { Cache, vxCacheEntries } from '../cache/index.js'
|
|
12
|
+
import { Cache, holdCacheDir, holdStore, vxCacheEntries } from '../cache/index.js'
|
|
5
13
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
6
14
|
import {
|
|
7
15
|
errorCode,
|
|
@@ -9,12 +17,13 @@ import {
|
|
|
9
17
|
nearest,
|
|
10
18
|
parseDuration,
|
|
11
19
|
parseSize,
|
|
20
|
+
refuseForeignCacheDir,
|
|
12
21
|
relPosix,
|
|
13
22
|
treeBytes,
|
|
14
23
|
} from '../util/index.js'
|
|
15
24
|
import * as ui from '../ui/index.js'
|
|
16
25
|
import { acquireRunLock } from '../orchestrator/index.js'
|
|
17
|
-
import { findWorkspaceRoot } from '../workspace/index.js'
|
|
26
|
+
import { findWorkspaceRoot, repoIdsOf } from '../workspace/index.js'
|
|
18
27
|
import {
|
|
19
28
|
cliCacheDir,
|
|
20
29
|
loadCliWorkspace,
|
|
@@ -233,16 +242,6 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
|
|
|
233
242
|
printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
|
|
234
243
|
return 0
|
|
235
244
|
}
|
|
236
|
-
const cache =
|
|
237
|
-
parsed.dry === true
|
|
238
|
-
? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
|
|
239
|
-
: new Cache(dir, undefined, root, undefined, 'open', storeRoot)
|
|
240
|
-
// A prune deletes rows and artifacts; a cache this user cannot write is
|
|
241
|
-
// refused up front with the directory named, as a run refuses it, rather
|
|
242
|
-
// than dying in the first DELETE with SQLite's "readonly database" and a
|
|
243
|
-
// stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
|
|
244
|
-
// run only reads, and reads a read-only cache fine.
|
|
245
|
-
if (!parsed.dry) cache.assertWritable()
|
|
246
245
|
// A prune beside a run on this workspace evicts what the run has just
|
|
247
246
|
// probed as hits, whose `accessed_at` bumps it has not flushed yet; the
|
|
248
247
|
// run then re-runs those tasks (upstream survey, nx#36688). So a prune
|
|
@@ -251,13 +250,37 @@ async function cleanCmd(args: readonly string[]): Promise<number> {
|
|
|
251
250
|
? async (): Promise<void> => {}
|
|
252
251
|
: await acquireRunLock(root, { log: warnToStderr })
|
|
253
252
|
try {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
253
|
+
// A run in another checkout of the repository holds no lock of this
|
|
254
|
+
// one's, and shares the store: the prune waits for every process that
|
|
255
|
+
// has the store open, then opens its own handle on it. A cache
|
|
256
|
+
// directory with no store may be another workspace's: the prune holds
|
|
257
|
+
// it until done, as its runs do.
|
|
258
|
+
if (!parsed.dry && storeRoot !== null) (await holdStore(storeRoot, warnToStderr))()
|
|
259
|
+
const releaseDir =
|
|
260
|
+
!parsed.dry && storeRoot === null
|
|
261
|
+
? await holdCacheDir(dir, 'exclusive', warnToStderr)
|
|
262
|
+
: () => {}
|
|
263
|
+
const cache =
|
|
264
|
+
parsed.dry === true
|
|
265
|
+
? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
|
|
266
|
+
: new Cache(dir, undefined, root, undefined, 'open', storeRoot)
|
|
267
|
+
try {
|
|
268
|
+
// A prune deletes rows and artifacts; a cache this user cannot write is
|
|
269
|
+
// refused up front with the directory named, as a run refuses it, rather
|
|
270
|
+
// than dying in the first DELETE with SQLite's "readonly database" and a
|
|
271
|
+
// stack (an unprivileged user on a root-owned `.vx`, 2026-09-16). A dry
|
|
272
|
+
// run only reads, and reads a read-only cache fine.
|
|
273
|
+
if (!parsed.dry) cache.assertWritable()
|
|
274
|
+
const opts: { olderThanMs?: number; maxBytes?: number; dry?: boolean } = {}
|
|
275
|
+
if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
|
|
276
|
+
if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
|
|
277
|
+
if (parsed.dry) opts.dry = true
|
|
278
|
+
printPruned(await cache.prune(opts), dry, json)
|
|
279
|
+
} finally {
|
|
280
|
+
cache.close()
|
|
281
|
+
releaseDir()
|
|
282
|
+
}
|
|
259
283
|
} finally {
|
|
260
|
-
cache.close()
|
|
261
284
|
await releaseRunLock()
|
|
262
285
|
}
|
|
263
286
|
return 0
|
|
@@ -325,28 +348,36 @@ async function clear(
|
|
|
325
348
|
const release =
|
|
326
349
|
parsed.dry === true ? async () => {} : await acquireRunLock(root, { log: warnToStderr })
|
|
327
350
|
try {
|
|
328
|
-
return clearHeld(args, root, dir, storeRoot, parsed)
|
|
351
|
+
return await clearHeld(args, root, dir, storeRoot, parsed)
|
|
329
352
|
} finally {
|
|
330
353
|
await release()
|
|
331
354
|
}
|
|
332
355
|
}
|
|
333
356
|
|
|
334
|
-
function clearHeld(
|
|
357
|
+
async function clearHeld(
|
|
335
358
|
args: readonly string[],
|
|
336
359
|
root: string,
|
|
337
360
|
dir: string,
|
|
338
361
|
storeRoot: string | null,
|
|
339
362
|
parsed: PruneArgs,
|
|
340
|
-
): number {
|
|
363
|
+
): Promise<number> {
|
|
341
364
|
// The store the index recorded and today's (a changed HOME, an origin
|
|
342
365
|
// added) both go; a directory with no index is not a vx cache to delete.
|
|
343
366
|
const targets: string[] = []
|
|
367
|
+
try {
|
|
368
|
+
refuseForeignCacheDir(dir, root)
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (!isUserError(err)) throw err
|
|
371
|
+
return refuse('cache clean', args, err.message, errorCode(err))
|
|
372
|
+
}
|
|
344
373
|
if (existsSync(path.join(dir, 'cache.db'))) {
|
|
345
374
|
// An index SQLite cannot read is what its own refusal sends here to
|
|
346
375
|
// clear: today's store stands in for the one it recorded.
|
|
347
376
|
let store: string | undefined = storeRoot ?? undefined
|
|
348
377
|
try {
|
|
349
|
-
|
|
378
|
+
// No store root: given today's, the index answers with it, never
|
|
379
|
+
// the store it recorded.
|
|
380
|
+
const cache = new Cache(dir, undefined, undefined, undefined, 'preview')
|
|
350
381
|
store = cache.storeDir
|
|
351
382
|
cache.close()
|
|
352
383
|
} catch (err) {
|
|
@@ -354,8 +385,12 @@ function clearHeld(
|
|
|
354
385
|
}
|
|
355
386
|
targets.push(dir)
|
|
356
387
|
// The index is a file in the checkout (committed, or restored from a CI
|
|
357
|
-
// cache): the store it names is deleted only when it is one
|
|
358
|
-
|
|
388
|
+
// cache): the store it names is deleted only when it is one, and this
|
|
389
|
+
// workspace's: an index copied from another named that one's store.
|
|
390
|
+
if (store !== undefined && store !== storeRoot && isStore(store)) {
|
|
391
|
+
const id = path.basename(path.dirname(path.resolve(store)))
|
|
392
|
+
if ((await repoIdsOf(root)).includes(id)) targets.push(store)
|
|
393
|
+
}
|
|
359
394
|
// Today's store too: the next run reads it, whichever store the index
|
|
360
395
|
// last recorded (a moved HOME, an origin added).
|
|
361
396
|
if (storeRoot !== null) targets.push(storeRoot)
|
|
@@ -363,13 +398,18 @@ function clearHeld(
|
|
|
363
398
|
if (parsed.global === true) {
|
|
364
399
|
const vxHome = path.join(process.env['HOME'] || homedir(), '.vx')
|
|
365
400
|
const ids = existsSync(vxHome) ? readdirSync(vxHome) : []
|
|
366
|
-
|
|
401
|
+
// `~/.vx` is not vx's alone to fill: a `cache` directory with no
|
|
402
|
+
// store.db is someone else's and stays.
|
|
403
|
+
for (const id of ids) {
|
|
404
|
+
const store = path.join(vxHome, id, 'cache')
|
|
405
|
+
if (isStore(store)) targets.push(store)
|
|
406
|
+
}
|
|
367
407
|
}
|
|
368
408
|
// `cache.dir: '.vx'` (or the root) would take the workspace config and
|
|
369
409
|
// the lock with it.
|
|
370
410
|
const dotVx = path.join(root, '.vx')
|
|
371
|
-
const
|
|
372
|
-
const unsafe =
|
|
411
|
+
const found = [...new Set(targets.map((t) => path.resolve(t)))].filter((t) => existsSync(t))
|
|
412
|
+
const unsafe = found.find((t) => t === dotVx || !path.relative(t, dotVx).startsWith('..'))
|
|
373
413
|
if (unsafe !== undefined) {
|
|
374
414
|
return refuse(
|
|
375
415
|
'cache clean',
|
|
@@ -381,23 +421,69 @@ function clearHeld(
|
|
|
381
421
|
const dry = parsed.dry === true
|
|
382
422
|
// `.vx/cache` and the stores are vx's own; any other directory is the
|
|
383
423
|
// user's, cleared entry by entry and removed only if that empties it.
|
|
384
|
-
// A `.vx/cache` linked to a volume (a CI cache mount) is
|
|
385
|
-
// the link: removing the link alone left the cache where it points.
|
|
424
|
+
// A `.vx/cache` linked to a volume (a CI cache mount) is cleared through
|
|
425
|
+
// the link: removing the link alone left the cache where it points. Only
|
|
426
|
+
// vx's entries there: emptying it took whatever else the target held.
|
|
386
427
|
const own = path.join(dotVx, 'cache')
|
|
387
428
|
const named = path.resolve(dir) === own ? undefined : path.resolve(dir)
|
|
388
429
|
const linked = named === undefined && existsSync(own) && lstatSync(own).isSymbolicLink()
|
|
430
|
+
// Every target is asked before the first delete: a store another user
|
|
431
|
+
// owns (one `sudo vx run`) failed the clear after `.vx/cache` was gone.
|
|
432
|
+
// Such a store is left, as runs leave it for `.vx/cache`; a cache
|
|
433
|
+
// directory this user cannot remove refuses the clear whole.
|
|
434
|
+
const doomed: string[] = []
|
|
435
|
+
for (const t of found) {
|
|
436
|
+
const why = removeBlocked(t, t !== named && !(linked && t === own))
|
|
437
|
+
if (why === null) doomed.push(t)
|
|
438
|
+
else if (t !== path.resolve(dir))
|
|
439
|
+
warnToStderr(`cache store ${t} left as is: this user cannot remove it (${why})`)
|
|
440
|
+
else
|
|
441
|
+
return refuse(
|
|
442
|
+
'cache clean',
|
|
443
|
+
args,
|
|
444
|
+
`cache directory ${t} cannot be removed by this user (${why}); nothing was deleted`,
|
|
445
|
+
'VX_E_FS',
|
|
446
|
+
)
|
|
447
|
+
}
|
|
389
448
|
const entries = (t: string): string[] =>
|
|
390
449
|
t === named
|
|
391
450
|
? vxCacheEntries(t).map((e) => path.join(t, e))
|
|
392
451
|
: linked && t === own
|
|
393
|
-
?
|
|
452
|
+
? [...vxCacheEntries(t), 'compiled']
|
|
453
|
+
.map((e) => path.join(t, e))
|
|
454
|
+
.filter((e) => existsSync(e))
|
|
394
455
|
: [t]
|
|
395
456
|
const bytes = doomed.reduce((n, t) => n + entries(t).reduce((m, e) => m + treeBytes(e), 0), 0)
|
|
396
457
|
if (!dry) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
458
|
+
// A run in another checkout of the repository holds no lock of this
|
|
459
|
+
// one's: each store is deleted once no process has it open, and held
|
|
460
|
+
// until then; so is a cache directory with no store, which another
|
|
461
|
+
// workspace's runs may share. In path order, so two clears never wait
|
|
462
|
+
// on each other.
|
|
463
|
+
const shared = storeRoot === null ? path.resolve(dir) : undefined
|
|
464
|
+
const holds: (() => void)[] = []
|
|
465
|
+
const asides: string[] = []
|
|
466
|
+
try {
|
|
467
|
+
for (const t of [...doomed].sort()) {
|
|
468
|
+
holds.push(await holdStore(t, warnToStderr))
|
|
469
|
+
if (t === shared) holds.push(await holdCacheDir(t, 'exclusive', warnToStderr))
|
|
470
|
+
}
|
|
471
|
+
for (const t of doomed) {
|
|
472
|
+
for (const e of entries(t)) {
|
|
473
|
+
// A store leaves its name at once, under the hold, and is deleted
|
|
474
|
+
// after it. Deleted where it stood, a run in another checkout
|
|
475
|
+
// opening it mid-delete made a store.db there that the delete then
|
|
476
|
+
// took, with the entry the run saved.
|
|
477
|
+
const aside = e === t && t !== path.resolve(dir) ? moveAside(t) : undefined
|
|
478
|
+
if (aside !== undefined) asides.push(aside)
|
|
479
|
+
else rmSync(e, { recursive: true, force: true })
|
|
480
|
+
}
|
|
481
|
+
if (t === named && readdirSync(t).length === 0) rmSync(t, { recursive: true })
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
for (const release of holds) release()
|
|
400
485
|
}
|
|
486
|
+
for (const aside of asides) rmSync(aside, { recursive: true, force: true })
|
|
401
487
|
}
|
|
402
488
|
const shown = doomed.map((t) => (path.relative(root, t).startsWith('..') ? t : relPosix(root, t)))
|
|
403
489
|
if (parsed.format === 'json') {
|
|
@@ -408,10 +494,37 @@ function clearHeld(
|
|
|
408
494
|
'out',
|
|
409
495
|
freedStatus(dry, bytes, shown.length > 0),
|
|
410
496
|
...(shown.length > 0 ? [ui.text(...shown)] : []),
|
|
497
|
+
// The index holds the run history: said where the user decides.
|
|
498
|
+
...(doomed.includes(path.resolve(dir))
|
|
499
|
+
? [ui.text(`run history ${dry ? 'goes' : 'cleared'} too: vx last and vx why start over`)]
|
|
500
|
+
: []),
|
|
411
501
|
)
|
|
412
502
|
return 0
|
|
413
503
|
}
|
|
414
504
|
|
|
505
|
+
/** `dir` renamed beside itself, over a crashed clean's leftover, or undefined where it cannot be. */
|
|
506
|
+
function moveAside(dir: string): string | undefined {
|
|
507
|
+
const aside = `${dir}.removing`
|
|
508
|
+
try {
|
|
509
|
+
rmSync(aside, { recursive: true, force: true })
|
|
510
|
+
renameSync(dir, aside)
|
|
511
|
+
return aside
|
|
512
|
+
} catch {
|
|
513
|
+
return undefined
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Why this user cannot remove `t` (`whole`) or empty it, or null. */
|
|
518
|
+
function removeBlocked(t: string, whole: boolean): string | null {
|
|
519
|
+
try {
|
|
520
|
+
accessSync(t, constants.R_OK | constants.W_OK | constants.X_OK)
|
|
521
|
+
if (whole) accessSync(path.dirname(t), constants.W_OK | constants.X_OK)
|
|
522
|
+
return null
|
|
523
|
+
} catch (err) {
|
|
524
|
+
return (err as NodeJS.ErrnoException).code ?? String(err)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
415
528
|
/** A store's shape: `<home>/.vx/<id>/cache` holding its `store.db`. */
|
|
416
529
|
function isStore(stored: string): boolean {
|
|
417
530
|
const dir = path.resolve(stored)
|
package/src/cli/completions.ts
CHANGED
|
@@ -48,7 +48,7 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
|
|
|
48
48
|
verb: v,
|
|
49
49
|
// A plugin verb owns its own arguments; only `--help` is known here.
|
|
50
50
|
words: [
|
|
51
|
-
...(VERB_WORDS[v] ?? []),
|
|
51
|
+
...(v === 'help' ? verbs.filter((w) => w !== 'help') : (VERB_WORDS[v] ?? [])),
|
|
52
52
|
...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
|
|
53
53
|
],
|
|
54
54
|
}))
|
|
@@ -155,7 +155,7 @@ export async function completionsCmd(
|
|
|
155
155
|
'VX_E_USAGE',
|
|
156
156
|
)
|
|
157
157
|
}
|
|
158
|
-
const one = shell !== undefined && args.length === 1
|
|
158
|
+
const one = shell !== undefined && shell !== '' && args.length === 1
|
|
159
159
|
// One edit: shell names are three or four letters, so two edits took
|
|
160
160
|
// `tcsh` to `bash`, a different shell.
|
|
161
161
|
const best = one ? nearest(shell, SHELLS, 1) : undefined
|
package/src/cli/core-alias.ts
CHANGED
|
@@ -9,7 +9,10 @@
|
|
|
9
9
|
* and two module states — two `definePlugin` memos, two of everything
|
|
10
10
|
* keyed by identity rather than by registry symbol.
|
|
11
11
|
*
|
|
12
|
-
* The alias is a Bun virtual module for the exact
|
|
12
|
+
* The alias is a Bun virtual module for the exact specifiers `@vzn/vx`
|
|
13
|
+
* and `@vzn/vx/config` (the second for `import { defineProject }` in a
|
|
14
|
+
* config: with a node_modules and no `@vzn/vx` in it, the import failed
|
|
15
|
+
* though the page says vx runs a config without it),
|
|
13
16
|
* served from the host's own façade, loaded lazily on the first import
|
|
14
17
|
* so a verb that never loads a plugin never loads the façade. The
|
|
15
18
|
* source form (`bun src/bin.ts`) resolves the specifier to the same file
|
|
@@ -18,11 +21,15 @@
|
|
|
18
21
|
* (workspace/config-bytecode.ts) is CommonJS, and `require` refuses a
|
|
19
22
|
* module whose loader is async.
|
|
20
23
|
*/
|
|
21
|
-
export function registerCoreAlias(
|
|
24
|
+
export function registerCoreAlias(
|
|
25
|
+
load: () => Record<string, unknown>,
|
|
26
|
+
loadConfig: () => Record<string, unknown>,
|
|
27
|
+
): void {
|
|
22
28
|
Bun.plugin({
|
|
23
29
|
name: 'vx-core-alias',
|
|
24
30
|
setup(build) {
|
|
25
31
|
build.module('@vzn/vx', () => ({ exports: load(), loader: 'object' }))
|
|
32
|
+
build.module('@vzn/vx/config', () => ({ exports: loadConfig(), loader: 'object' }))
|
|
26
33
|
},
|
|
27
34
|
})
|
|
28
35
|
}
|
package/src/cli/docs.ts
CHANGED
|
@@ -31,10 +31,12 @@ export function parseDocsArgs(args: readonly string[]): DocsArgs {
|
|
|
31
31
|
out.json = fv === 'json'
|
|
32
32
|
} else if (a === '--limit' || a.startsWith('--limit=')) {
|
|
33
33
|
const v = a === '--limit' ? args[++i] : a.slice('--limit='.length)
|
|
34
|
-
|
|
34
|
+
// The space form took the next flag as the count and refused it as one.
|
|
35
|
+
const flag = a === '--limit' && v !== undefined && /^-\D/.test(v) ? ` (got flag ${v})` : ''
|
|
36
|
+
if (v === undefined || v === '' || flag !== '') {
|
|
35
37
|
return {
|
|
36
38
|
...out,
|
|
37
|
-
error: `--limit requires a value: a whole number above 0${seeHelp('docs')}`,
|
|
39
|
+
error: `--limit requires a value: a whole number above 0${flag}${seeHelp('docs')}`,
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
const n = parseDecimalInt(v)
|
package/src/cli/help.ts
CHANGED
|
@@ -90,12 +90,33 @@ export function verbHelpText(verb: string): string {
|
|
|
90
90
|
if (own.length > 0) kept.push(['Usage:', ...own].join('\n'))
|
|
91
91
|
continue
|
|
92
92
|
}
|
|
93
|
-
if (lines.some(names)) kept.push(
|
|
93
|
+
if (lines.some(names)) kept.push(own(lines, names, quoted))
|
|
94
94
|
}
|
|
95
95
|
if (kept.length === 0) return helpText()
|
|
96
96
|
return [title, ...kept, 'Full reference: vx help', ''].join('\n\n')
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* A section's entries for one verb, when the section holds several verbs'
|
|
101
|
+
* entries (Introspection): an entry is a two-space line and the deeper
|
|
102
|
+
* lines under it; a shared one (`info, why and last:`) stays if it names
|
|
103
|
+
* the verb. A one-verb or `(for <verb>)` section stays whole.
|
|
104
|
+
*/
|
|
105
|
+
function own(lines: string[], names: (line: string) => boolean, quoted: string): string {
|
|
106
|
+
const entries: string[][] = []
|
|
107
|
+
for (const line of lines.slice(1)) {
|
|
108
|
+
if (/^ {2}\S/.test(line) || entries.length === 0) entries.push([line])
|
|
109
|
+
else entries.at(-1)!.push(line)
|
|
110
|
+
}
|
|
111
|
+
const verbs = new Set(entries.map((e) => /^ {2}vx ([\w-]+)/.exec(e[0]!)?.[1]).filter(Boolean))
|
|
112
|
+
if (verbs.size < 2) return lines.join('\n')
|
|
113
|
+
const word = new RegExp(`\\b${quoted}\\b`)
|
|
114
|
+
const mine = entries.filter(
|
|
115
|
+
(e) => names(e[0]!) || (!e[0]!.startsWith(' vx ') && word.test(e[0]!)),
|
|
116
|
+
)
|
|
117
|
+
return [lines[0]!, ...mine.flat()].join('\n')
|
|
118
|
+
}
|
|
119
|
+
|
|
99
120
|
export function helpText(pluginCommands: readonly string[] = []): string {
|
|
100
121
|
return [
|
|
101
122
|
'vx — task orchestration you own',
|
|
@@ -141,7 +162,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
|
|
|
141
162
|
' --no-cache No cache reads or writes; outputs are left',
|
|
142
163
|
' alone.',
|
|
143
164
|
' --force Skip cache reads but still write: every task',
|
|
144
|
-
' runs
|
|
165
|
+
' runs; a key already stored is kept.',
|
|
145
166
|
' --cache-policy <spec> Per-layer read/write: a comma list of',
|
|
146
167
|
' <layer>:<flags> (layer local or remote, flags',
|
|
147
168
|
' from rw). A named layer is set to exactly its',
|
|
@@ -366,6 +387,8 @@ export function flagHint(verb: string, arg: string): string {
|
|
|
366
387
|
// `--json` is gh's and Nx's spelling; a verb with `--format` reads it
|
|
367
388
|
// there, and no edit budget reaches `--format json` from `--json`.
|
|
368
389
|
if (name === '--json' && flags.includes('--format')) return ' (did you mean --format json?)'
|
|
390
|
+
// pnpm's and Turbo's short `--filter`.
|
|
391
|
+
if (name === '-F') return flags.includes('--filter') ? ' (did you mean --filter?)' : ''
|
|
369
392
|
// `vx run` prints no JSON of its own, and edit distance reached `--force`
|
|
370
393
|
// from `--format`: a run with its cache reads turned off.
|
|
371
394
|
if ((name === '--format' || name === '--json') && flags.includes('--dry'))
|