@vzn/vx 0.0.651 → 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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- 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 +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- 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 +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- 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 +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- 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 +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- 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 +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- 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-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- 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 +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- 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 +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- 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 +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -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/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- 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 +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- 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 +142 -6
- 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 +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- 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 +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- 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 +58 -31
- 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',
|
|
@@ -48,21 +48,22 @@ const RUN_COLUMNS = [
|
|
|
48
48
|
'flaky_failures',
|
|
49
49
|
]
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
51
|
+
/**
|
|
52
|
+
* An INSERT of `n` `runs` rows over the columns `kept` names (indexes into
|
|
53
|
+
* `RUN_COLUMNS`); a column it leaves out is NULL, as no `runs` column has
|
|
54
|
+
* a default.
|
|
55
|
+
*/
|
|
56
|
+
function insertRunsSql(kept: readonly number[], n: number): string {
|
|
57
|
+
const tuple = `(${kept.map(() => '?').join(', ')})`
|
|
58
|
+
return `INSERT INTO main.runs(${kept.map((i) => RUN_COLUMNS[i]).join(', ')}) VALUES ${Array(n).fill(tuple).join(', ')}`
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* rows, 40 at a time 6.4 (2026-10-03).
|
|
62
|
+
* Bound variables per INSERT: within 999, the oldest default ceiling. A
|
|
63
|
+
* statement per row was 8.7 ms of 1,000 rows, 40 at a time 6.4
|
|
64
|
+
* (2026-10-03).
|
|
64
65
|
*/
|
|
65
|
-
const
|
|
66
|
+
const VARIABLES_PER_INSERT = 999
|
|
66
67
|
|
|
67
68
|
/** Rows per `task_durations` INSERT: 7 variables each, under 999. */
|
|
68
69
|
const DURATIONS_PER_INSERT = 140
|
|
@@ -92,6 +93,10 @@ const PASSED_HASHES = `
|
|
|
92
93
|
SELECT j.value AS hash FROM json_each(?) AS j
|
|
93
94
|
WHERE EXISTS (SELECT 1 FROM task_outcomes AS o WHERE o.hash = j.value AND o.status <> 'failed')`
|
|
94
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
|
+
|
|
95
100
|
/** A local run's `task_durations.source`: its branch, `''` when it had none. */
|
|
96
101
|
export const branchSource = (branch: string | null): string => `branch:${branch ?? ''}`
|
|
97
102
|
|
|
@@ -139,14 +144,34 @@ export class RunHistory {
|
|
|
139
144
|
this.recordRuns([run])
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
|
|
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 {
|
|
143
153
|
if (runs.length === 0) return
|
|
144
154
|
// One transaction, one fsync: for a 200-task run, one instead of 200.
|
|
145
155
|
// IMMEDIATE, as `recordRunBundle` explains.
|
|
146
156
|
this.db
|
|
147
157
|
.transaction(() => {
|
|
148
158
|
this.insertRuns(runs)
|
|
149
|
-
this.insertDurations(runs, branchSource(
|
|
159
|
+
this.insertDurations(runs, branchSource(head.branch), head.commitSha)
|
|
160
|
+
this.insertOutcomes(runs)
|
|
161
|
+
})
|
|
162
|
+
.immediate()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Hits written while the run goes on (history-stream.ts): their rows and
|
|
167
|
+
* the flaky record's first hit of a key. A hit times nothing, so it
|
|
168
|
+
* leaves no `task_durations` row; IMMEDIATE, as `recordRunBundle` says.
|
|
169
|
+
*/
|
|
170
|
+
recordHits(runs: readonly RunRecord[]): void {
|
|
171
|
+
if (runs.length === 0) return
|
|
172
|
+
this.db
|
|
173
|
+
.transaction(() => {
|
|
174
|
+
this.insertRuns(runs)
|
|
150
175
|
this.insertOutcomes(runs)
|
|
151
176
|
})
|
|
152
177
|
.immediate()
|
|
@@ -209,7 +234,13 @@ export class RunHistory {
|
|
|
209
234
|
}
|
|
210
235
|
}
|
|
211
236
|
|
|
212
|
-
/**
|
|
237
|
+
/**
|
|
238
|
+
* `runs` as few INSERTs; each distinct forward-args list digested once.
|
|
239
|
+
* Only the columns some row fills are bound: a hit leaves half of them
|
|
240
|
+
* NULL, and binding 18 of 36 took the 8,002 rows of a warm run from
|
|
241
|
+
* 31.7 to 24.0 ms alone, the whole record stage 86.8 to 83.3 ms (median
|
|
242
|
+
* of 15 interleaved runs, 2026-11-06).
|
|
243
|
+
*/
|
|
213
244
|
private insertRuns(runs: readonly RunRecord[]): void {
|
|
214
245
|
const digests = new Map<string, string>()
|
|
215
246
|
const digest = (value: string): string => {
|
|
@@ -217,15 +248,41 @@ export class RunHistory {
|
|
|
217
248
|
if (d === undefined) digests.set(value, (d = this.digestValue(value)))
|
|
218
249
|
return d
|
|
219
250
|
}
|
|
220
|
-
|
|
221
|
-
|
|
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))
|
|
258
|
+
const filled = RUN_COLUMNS.map(() => false)
|
|
259
|
+
for (const row of bound)
|
|
260
|
+
for (let c = 0; c < row.length; c++) if (row[c] !== null) filled[c] = true
|
|
261
|
+
const kept: number[] = []
|
|
262
|
+
for (let c = 0; c < filled.length; c++) if (filled[c]) kept.push(c)
|
|
263
|
+
const perInsert = Math.floor(VARIABLES_PER_INSERT / kept.length)
|
|
264
|
+
for (let i = 0; i < bound.length; i += perInsert) {
|
|
265
|
+
const block = bound.slice(i, i + perInsert)
|
|
222
266
|
const params: SQLQueryBindings[] = []
|
|
223
|
-
for (const
|
|
267
|
+
for (const row of block) for (const c of kept) params.push(row[c] as SQLQueryBindings)
|
|
224
268
|
// `query` caches by text: one statement for every full block.
|
|
225
|
-
this.db.query(insertRunsSql(block.length)).run(...params)
|
|
269
|
+
this.db.query(insertRunsSql(kept, block.length)).run(...params)
|
|
226
270
|
}
|
|
227
271
|
}
|
|
228
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
|
+
|
|
229
286
|
recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
|
|
230
287
|
// Whole run records atomically — one transaction, one fsync: the
|
|
231
288
|
// per-task `runs` rows and the one `invocations` header. The
|
|
@@ -262,7 +319,9 @@ export class RunHistory {
|
|
|
262
319
|
runId: string,
|
|
263
320
|
errors: readonly (readonly [hash: string, text: string])[],
|
|
264
321
|
): void {
|
|
265
|
-
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
|
+
)
|
|
266
325
|
this.db
|
|
267
326
|
.transaction(() => {
|
|
268
327
|
for (const [hash, text] of errors) update.run(text, runId, hash)
|
|
@@ -278,7 +337,13 @@ export class RunHistory {
|
|
|
278
337
|
* outlive its rows and `vx last` would list a run whose detail is gone.
|
|
279
338
|
*/
|
|
280
339
|
pruneOlderThan(cutoff: number): void {
|
|
281
|
-
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()
|
|
282
347
|
this.db.prepare('DELETE FROM invocations WHERE started_at < ?').run(cutoff)
|
|
283
348
|
this.db.prepare('DELETE FROM task_durations WHERE recorded_at < ?').run(cutoff)
|
|
284
349
|
this.db.prepare('DELETE FROM task_outcomes WHERE recorded_at < ?').run(cutoff)
|
|
@@ -287,8 +352,12 @@ export class RunHistory {
|
|
|
287
352
|
}
|
|
288
353
|
}
|
|
289
354
|
|
|
290
|
-
/**
|
|
291
|
-
function bindRun(
|
|
355
|
+
/** A RunRecord as one value per `RUN_COLUMNS` entry, in its order. */
|
|
356
|
+
function bindRun(
|
|
357
|
+
run: RunRecord,
|
|
358
|
+
digestValue: (v: string) => string,
|
|
359
|
+
runKey: (runId: string) => number,
|
|
360
|
+
): SQLQueryBindings[] {
|
|
292
361
|
return [
|
|
293
362
|
// The ONE place the no-key sentinel is applied, so the column's
|
|
294
363
|
// NOT NULL invariant can't be violated from a call site.
|
|
@@ -304,7 +373,7 @@ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBi
|
|
|
304
373
|
run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
|
|
305
374
|
run.startedAt,
|
|
306
375
|
run.endedAt,
|
|
307
|
-
run.runId
|
|
376
|
+
run.runId === undefined ? null : runKey(run.runId),
|
|
308
377
|
run.cpuMs ?? null,
|
|
309
378
|
run.peakRssBytes ?? null,
|
|
310
379
|
run.wallclockStartNs !== undefined ? run.wallclockStartNs : null,
|
package/src/cache/schema.ts
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import type { Database, SQLQueryBindings } from 'bun:sqlite'
|
|
8
8
|
|
|
9
|
+
/** SQLite's file-control opcode (sqlite3.h). */
|
|
10
|
+
export const SQLITE_FCNTL_PERSIST_WAL = 10
|
|
11
|
+
|
|
9
12
|
/**
|
|
10
13
|
* A statement prepared on its first `run` or `get`. A warm run executes few
|
|
11
14
|
* of the index's statements, and preparing every one at the open cost each
|
|
@@ -20,6 +23,24 @@ export function lazyStatement(db: Database, sql: string): ReturnType<Database['p
|
|
|
20
23
|
} as unknown as ReturnType<Database['prepare']>
|
|
21
24
|
}
|
|
22
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
|
+
|
|
23
44
|
/**
|
|
24
45
|
* Create every table. `store` names the schema holding the ENTRY tables
|
|
25
46
|
* (`entries`, `entry_stdout`, `output_files`, `entry_inputs`, `store_meta`)
|
|
@@ -48,15 +69,21 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
48
69
|
accessed_at INTEGER NOT NULL,
|
|
49
70
|
-- v26: the producing execution's usage, from the artifact's sidecar.
|
|
50
71
|
cpu_ms INTEGER,
|
|
51
|
-
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
|
|
52
77
|
);
|
|
53
78
|
-- v29: an entry's stdout, which the <hash>.tar.zst artifact also
|
|
54
79
|
-- carries so it survives a remote round trip; a local hit replays it
|
|
55
80
|
-- from here. Its own table: an UPDATE rewrites a whole record, and the
|
|
56
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.
|
|
57
84
|
CREATE TABLE IF NOT EXISTS ${store}.entry_stdout (
|
|
58
85
|
hash TEXT PRIMARY KEY,
|
|
59
|
-
stdout
|
|
86
|
+
stdout BLOB NOT NULL,
|
|
60
87
|
FOREIGN KEY (hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
61
88
|
);
|
|
62
89
|
CREATE TABLE IF NOT EXISTS main.runs (
|
|
@@ -72,10 +99,10 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
72
99
|
forward_args TEXT,
|
|
73
100
|
started_at INTEGER NOT NULL,
|
|
74
101
|
ended_at INTEGER NOT NULL,
|
|
75
|
-
--
|
|
76
|
-
--
|
|
77
|
-
--
|
|
78
|
-
|
|
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,
|
|
79
106
|
cpu_ms INTEGER,
|
|
80
107
|
peak_rss_bytes INTEGER,
|
|
81
108
|
wallclock_start_ns INTEGER,
|
|
@@ -84,7 +111,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
84
111
|
-- v23: attempts a retried task took (>1); NULL for a once-run task.
|
|
85
112
|
-- The direct within-run flaky signal.
|
|
86
113
|
attempts INTEGER,
|
|
87
|
-
-- v25: 1 when the task declared
|
|
114
|
+
-- v25: 1 when the task declared cache: true, 0 when it runs every
|
|
88
115
|
-- time by design; NULL on rows older than the column.
|
|
89
116
|
cached INTEGER,
|
|
90
117
|
-- v27: why a task failed or was skipped, as the run's own footer
|
|
@@ -132,7 +159,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
132
159
|
flaky_failures INTEGER
|
|
133
160
|
);
|
|
134
161
|
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
135
|
-
-- same
|
|
162
|
+
-- same run and a started_at newer than everything before it, so
|
|
136
163
|
-- 1,000 inserts touch a handful of leaf pages. The dropped ones did
|
|
137
164
|
-- not — the DROPs shed them from existing databases (a schema-meta
|
|
138
165
|
-- bump is for stored shapes, and an index is not one):
|
|
@@ -153,7 +180,13 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
153
180
|
DROP INDEX IF EXISTS main.runs_ended;
|
|
154
181
|
DROP INDEX IF EXISTS main.runs_failed;
|
|
155
182
|
CREATE INDEX IF NOT EXISTS main.runs_started_at ON runs(started_at);
|
|
156
|
-
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}
|
|
157
190
|
-- Per-file (mtime, size, content_hash) cache. Lets Cache.key()
|
|
158
191
|
-- skip the content-hash on inputs whose stat hasn't changed
|
|
159
192
|
-- since the last run. Pure performance optimization; the stored
|
|
@@ -212,7 +245,9 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
212
245
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
213
246
|
);
|
|
214
247
|
-- v28, its own table since v32: the inode and ctime THIS workspace saw
|
|
215
|
-
-- 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
|
|
216
251
|
-- row: never current. Apart from output_files because the entry is
|
|
217
252
|
-- shared and the disk is not: two worktrees on one store overwrote each
|
|
218
253
|
-- other's stamps, and every switch between them restored. No foreign
|
|
@@ -223,6 +258,7 @@ export function createTables(db: Database, store: 'main' | 'store' = 'main'): vo
|
|
|
223
258
|
path TEXT NOT NULL,
|
|
224
259
|
ino INTEGER NOT NULL,
|
|
225
260
|
ctime_ms INTEGER NOT NULL,
|
|
261
|
+
mtime_ms INTEGER NOT NULL,
|
|
226
262
|
PRIMARY KEY (entry_hash, path)
|
|
227
263
|
);
|
|
228
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,
|
|
@@ -105,7 +114,7 @@ interface PruneArgs {
|
|
|
105
114
|
format?: 'pretty' | 'json'
|
|
106
115
|
/** `--cache-dir`: prune the cache a run with the same flag uses. */
|
|
107
116
|
cacheDir?: string
|
|
108
|
-
/** `--global`: clear every
|
|
117
|
+
/** `--global`: clear every workspace's store under `~/.vx`. */
|
|
109
118
|
global?: boolean
|
|
110
119
|
error?: string
|
|
111
120
|
}
|
|
@@ -181,7 +190,7 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
|
|
|
181
190
|
}
|
|
182
191
|
if (out.global === true && (out.olderThanMs !== undefined || out.maxBytes !== undefined)) {
|
|
183
192
|
return {
|
|
184
|
-
error: `--global clears every
|
|
193
|
+
error: `--global clears every workspace's store and takes no --older-than or --max-size${seeHelp('cache')}`,
|
|
185
194
|
}
|
|
186
195
|
}
|
|
187
196
|
if (out.global === true && out.cacheDir !== undefined) {
|
|
@@ -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
|