@vzn/vx 0.0.631 → 0.0.632
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 +7 -8
- package/package.json +7 -5
- package/schemas/cache.json +24 -0
- package/schemas/error.json +23 -0
- package/schemas/info.json +147 -0
- package/schemas/last.json +188 -0
- package/schemas/plan.json +88 -0
- package/schemas/show.json +124 -0
- package/schemas/summary.json +133 -0
- package/schemas/why.json +168 -0
- package/skills/vx/SKILL.md +85 -0
- package/src/bin.ts +9 -0
- package/src/cache/archive.ts +153 -51
- package/src/cache/cache.ts +45 -19
- package/src/cache/layer.ts +2 -1
- package/src/cache/schema.ts +2 -1
- package/src/cache/tar-stream.ts +204 -66
- package/src/cache/zstd.ts +1 -1
- package/src/cli/cache.ts +14 -14
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +30 -5
- package/src/cli/index.ts +23 -11
- package/src/cli/info.ts +38 -9
- package/src/cli/init.ts +97 -19
- package/src/cli/last.ts +54 -3
- package/src/cli/plan-format.ts +23 -1
- package/src/cli/plugin-commands.ts +3 -0
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +40 -21
- package/src/cli/show.ts +10 -7
- package/src/cli/upgrade.ts +1 -1
- package/src/cli/watch.ts +7 -2
- package/src/cli/why.ts +14 -40
- package/src/exec/runner.ts +27 -10
- package/src/graph/scheduler.ts +36 -0
- package/src/graph/task-graph.ts +1 -1
- package/src/index.ts +22 -5
- package/src/orchestrator/admission.ts +29 -1
- package/src/orchestrator/affected-tasks.ts +87 -18
- package/src/orchestrator/doctor.ts +14 -1
- package/src/orchestrator/execute-task.ts +149 -64
- package/src/orchestrator/failure-mode.ts +5 -3
- package/src/orchestrator/framed-output.ts +17 -3
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/logger.ts +57 -6
- package/src/orchestrator/metrics.ts +65 -0
- package/src/orchestrator/options.ts +12 -0
- package/src/orchestrator/output-log.ts +14 -1
- package/src/orchestrator/path-links.ts +79 -0
- package/src/orchestrator/plan.ts +20 -3
- package/src/orchestrator/plugin.ts +7 -0
- package/src/orchestrator/prepare.ts +14 -4
- package/src/orchestrator/run-artifacts.ts +18 -2
- package/src/orchestrator/run-failures.ts +192 -0
- package/src/orchestrator/run-records.ts +10 -8
- package/src/orchestrator/run.ts +87 -22
- package/src/orchestrator/terminal-signals.ts +16 -0
- package/src/orchestrator/upstream.ts +65 -10
- package/src/util/errors.ts +55 -1
- package/src/util/index.ts +3 -0
- package/src/util/secret-mask.ts +13 -1
- package/src/workspace/workspace.ts +1 -0
package/src/orchestrator/run.ts
CHANGED
|
@@ -48,13 +48,14 @@ import {
|
|
|
48
48
|
machineParallelism,
|
|
49
49
|
teardownTimeoutMs,
|
|
50
50
|
secretMask,
|
|
51
|
+
isFsRefusal,
|
|
51
52
|
} from '../util/index.js'
|
|
52
53
|
import { keyedProjects } from './keyed-projects.js'
|
|
53
54
|
import { isDefaultBuild } from './projects.js'
|
|
54
55
|
import { prepareSandbox } from './sandbox-request.js'
|
|
55
56
|
import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
|
|
56
57
|
import { admitTasks, taintTracker } from './admission.js'
|
|
57
|
-
import type
|
|
58
|
+
import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
58
59
|
import { excludedTaint } from './excluded-keys.js'
|
|
59
60
|
import { keyUpstream } from './upstream.js'
|
|
60
61
|
import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
|
|
@@ -70,6 +71,7 @@ import { formatPersistentList } from './framed-output.js'
|
|
|
70
71
|
import { createForecast } from './forecast.js'
|
|
71
72
|
import { LocalHistoryProvider } from './history.js'
|
|
72
73
|
import { plan, type RunPlan } from './plan.js'
|
|
74
|
+
import type { AffectedReason } from './affected-tasks.js'
|
|
73
75
|
import { prepareRun, type PreparedRun } from './prepare.js'
|
|
74
76
|
import { acquireRunLock } from './run-lock.js'
|
|
75
77
|
import {
|
|
@@ -103,6 +105,7 @@ import {
|
|
|
103
105
|
} from './local-shortcircuit.js'
|
|
104
106
|
import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
|
|
105
107
|
|
|
108
|
+
import { writeRunFailures } from './run-failures.js'
|
|
106
109
|
import { assembleRunRecords } from './run-records.js'
|
|
107
110
|
import {
|
|
108
111
|
hasEnded,
|
|
@@ -111,7 +114,12 @@ import {
|
|
|
111
114
|
shutdownPersistent,
|
|
112
115
|
takeHeldServers,
|
|
113
116
|
} from './persistent.js'
|
|
114
|
-
import {
|
|
117
|
+
import {
|
|
118
|
+
runSummaryJson,
|
|
119
|
+
writeRunProfile,
|
|
120
|
+
writeRunSummary,
|
|
121
|
+
type RunSummaryJson,
|
|
122
|
+
} from './run-artifacts.js'
|
|
115
123
|
import { createSaveLane } from './save-lane.js'
|
|
116
124
|
import { formatOutcomeTable, formatRunSummary } from './summary.js'
|
|
117
125
|
import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
|
|
@@ -316,11 +324,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
316
324
|
// See docs/design/event-stream-2026-06.md.
|
|
317
325
|
const terminal =
|
|
318
326
|
options.log === undefined
|
|
319
|
-
? defaultLogger(
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
327
|
+
? defaultLogger(
|
|
328
|
+
colors,
|
|
329
|
+
resolveOutputView(options),
|
|
330
|
+
options.json === true ? process.stderr : process.stdout,
|
|
331
|
+
{
|
|
332
|
+
coalesce: true,
|
|
333
|
+
...(options.tty === true ? { tty: true } : {}),
|
|
334
|
+
...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
|
|
335
|
+
},
|
|
336
|
+
)
|
|
324
337
|
: null
|
|
325
338
|
const sink = options.log ?? terminal!
|
|
326
339
|
// An injected bus already has surfaces subscribed; we add the terminal
|
|
@@ -970,7 +983,8 @@ async function runOnBus(
|
|
|
970
983
|
// dispatch, and the tracker carries it to what is built on it.
|
|
971
984
|
const taintSeeds = new Set(excluded.seeds)
|
|
972
985
|
const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
|
|
973
|
-
|
|
986
|
+
// Each task that ran tainted, and the task at the root of it.
|
|
987
|
+
const taintedRan = new Map<string, string>()
|
|
974
988
|
const dependedOn = new Set<string>()
|
|
975
989
|
for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
|
|
976
990
|
|
|
@@ -995,13 +1009,16 @@ async function runOnBus(
|
|
|
995
1009
|
const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
|
|
996
1010
|
const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
|
|
997
1011
|
const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1012
|
+
let deadServer: string | undefined
|
|
1013
|
+
if (options.continueMode === 'always') {
|
|
1014
|
+
for (const d of node.deps) {
|
|
1015
|
+
deadServer = deadServerBehind(nodes, serverDied, d)
|
|
1016
|
+
if (deadServer !== undefined) break
|
|
1017
|
+
}
|
|
1018
|
+
if (deadServer !== undefined) taintSeeds.add(node.id)
|
|
1019
|
+
}
|
|
1003
1020
|
const tainted = taint.judge(node, upstream)
|
|
1004
|
-
if (tainted) taintedRan.
|
|
1021
|
+
if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
|
|
1005
1022
|
const a: ExecuteArgs = {
|
|
1006
1023
|
node,
|
|
1007
1024
|
upstream,
|
|
@@ -1130,6 +1147,21 @@ async function runOnBus(
|
|
|
1130
1147
|
? Promise.resolve(kept)
|
|
1131
1148
|
: executeWithDedup(node, keyUpstream(node, upstream))
|
|
1132
1149
|
},
|
|
1150
|
+
// An unkeyed group runs nothing: settled in place, unless a taint
|
|
1151
|
+
// may ride it, which `buildExecuteArgs` records on dispatch.
|
|
1152
|
+
...(options.continueMode !== 'always' && taintSeeds.size === 0
|
|
1153
|
+
? {
|
|
1154
|
+
settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
|
|
1155
|
+
isGroupTask(node) && node.config.cache === undefined
|
|
1156
|
+
? unkeyedGroupOutcome(
|
|
1157
|
+
node,
|
|
1158
|
+
keyUpstream(node, upstream),
|
|
1159
|
+
shortCircuit.groupKeys.get(node.id),
|
|
1160
|
+
runStartHrTimeNs,
|
|
1161
|
+
)
|
|
1162
|
+
: undefined,
|
|
1163
|
+
}
|
|
1164
|
+
: {}),
|
|
1133
1165
|
// A `schedule` plugin's weights; the scheduler keeps its structural
|
|
1134
1166
|
// baseline as the tie-break. Empty map → baseline only.
|
|
1135
1167
|
...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
|
|
@@ -1232,7 +1264,20 @@ async function runOnBus(
|
|
|
1232
1264
|
// change the run's exit code — the run already happened.
|
|
1233
1265
|
// Written again after the keep-alive wait: a kept server's crash or a
|
|
1234
1266
|
// Ctrl-C there is the process's exit, and the first write said ok.
|
|
1267
|
+
let json: RunSummaryJson | undefined
|
|
1235
1268
|
const summarize = async (runOk: boolean, final = list): Promise<void> => {
|
|
1269
|
+
if (options.json === true) {
|
|
1270
|
+
json = runSummaryJson({
|
|
1271
|
+
runId,
|
|
1272
|
+
startedAtMs: endedAtMsAtStart,
|
|
1273
|
+
endedAtMs,
|
|
1274
|
+
totalMs,
|
|
1275
|
+
ok: runOk,
|
|
1276
|
+
...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
|
|
1277
|
+
outcomes: final,
|
|
1278
|
+
flaky,
|
|
1279
|
+
})
|
|
1280
|
+
}
|
|
1236
1281
|
if (options.summarize === undefined) return
|
|
1237
1282
|
// The rewrite after the keep-alive wait says nothing: the footer is
|
|
1238
1283
|
// the run's last word.
|
|
@@ -1319,8 +1364,21 @@ async function runOnBus(
|
|
|
1319
1364
|
// cache closes: its history is written once the wait has decided, or it
|
|
1320
1365
|
// read `ok` over the exit 1 and fed the flaky list a pass (X-23).
|
|
1321
1366
|
const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
|
|
1322
|
-
|
|
1367
|
+
// A failed task's output, for `vx last` and an agent: files beside the
|
|
1368
|
+
// index, so a refused write (a full disk, a read-only cache dir) costs
|
|
1369
|
+
// only that output.
|
|
1370
|
+
const recordFailures = (final: readonly TaskOutcome[]): void => {
|
|
1371
|
+
try {
|
|
1372
|
+
writeRunFailures(prepared.cacheDir, runId, final)
|
|
1373
|
+
} catch (err) {
|
|
1374
|
+
if (!isFsRefusal(err)) throw err
|
|
1375
|
+
log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
if (stoppedBy === undefined && !historyAfterWait) {
|
|
1323
1379
|
recordHistory(() => cache.recordRunBundle(records))
|
|
1380
|
+
recordFailures(list)
|
|
1381
|
+
}
|
|
1324
1382
|
mark('record history')
|
|
1325
1383
|
// Drain any still-in-flight background prefetches before closing the
|
|
1326
1384
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
@@ -1533,11 +1591,12 @@ async function runOnBus(
|
|
|
1533
1591
|
local.close()
|
|
1534
1592
|
}
|
|
1535
1593
|
})
|
|
1594
|
+
recordFailures(final)
|
|
1536
1595
|
}
|
|
1537
|
-
return { ok: ok && first.code === 0, outcomes: final }
|
|
1596
|
+
return { ok: ok && first.code === 0, outcomes: final, ...(json && { json }) }
|
|
1538
1597
|
}
|
|
1539
1598
|
|
|
1540
|
-
return { ok, outcomes: list }
|
|
1599
|
+
return { ok, outcomes: list, ...(json && { json }) }
|
|
1541
1600
|
} finally {
|
|
1542
1601
|
// Idempotent. The status-line ticker starts in runStart, and every
|
|
1543
1602
|
// call between it and the success path's runEnd is crash-isolated
|
|
@@ -1627,7 +1686,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1627
1686
|
// The plan is the product and goes to stdout (`--dry=json` is parsed):
|
|
1628
1687
|
// what a stage says on the way goes to stderr (C-6).
|
|
1629
1688
|
const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
|
|
1630
|
-
const
|
|
1689
|
+
const reasons = new Map<string, AffectedReason>()
|
|
1690
|
+
const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
|
|
1631
1691
|
try {
|
|
1632
1692
|
if (prepared.unresolvedTasks.length > 0) {
|
|
1633
1693
|
return {
|
|
@@ -1675,6 +1735,10 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1675
1735
|
...(await planRestorable(prepared, policy, options.forwardArgs)),
|
|
1676
1736
|
})
|
|
1677
1737
|
mark('plan')
|
|
1738
|
+
for (const t of planned.tasks) {
|
|
1739
|
+
const why = reasons.get(t.node.id)
|
|
1740
|
+
if (why !== undefined) t.affected = why
|
|
1741
|
+
}
|
|
1678
1742
|
return planned
|
|
1679
1743
|
} finally {
|
|
1680
1744
|
// The plan called the cache and executor factories; teardown releases
|
|
@@ -1861,7 +1925,7 @@ export function projectNamed(
|
|
|
1861
1925
|
/** The executed, keyed outcomes of a run — what flakiness is judged on. */
|
|
1862
1926
|
function flakyCandidates(
|
|
1863
1927
|
outcomes: readonly TaskOutcome[],
|
|
1864
|
-
tainted:
|
|
1928
|
+
tainted: ReadonlyMap<string, string>,
|
|
1865
1929
|
): FlakyCandidate[] {
|
|
1866
1930
|
const out: FlakyCandidate[] = []
|
|
1867
1931
|
for (const o of outcomes) {
|
|
@@ -1871,9 +1935,10 @@ function flakyCandidates(
|
|
|
1871
1935
|
// and runs every time, so one bad network day would read as a flake for
|
|
1872
1936
|
// thirty days. Groups do no work.
|
|
1873
1937
|
if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
|
|
1874
|
-
// Behind a failed dependency the
|
|
1875
|
-
//
|
|
1876
|
-
|
|
1938
|
+
// Behind a failed dependency the outcome is not the key's: a failure is
|
|
1939
|
+
// the dependency's, and a pass built on bytes the key never named, so a
|
|
1940
|
+
// later failure relapsed from it and read as a flake for thirty days.
|
|
1941
|
+
if (tainted.has(o.node.id)) continue
|
|
1877
1942
|
out.push({
|
|
1878
1943
|
project: o.node.projectName,
|
|
1879
1944
|
task: o.node.taskName,
|
|
@@ -43,3 +43,19 @@ export function notifyEscape(text: string): string {
|
|
|
43
43
|
// eslint-disable-next-line no-control-regex -- the control range is the point
|
|
44
44
|
return `\x1b]9;${text.replace(/[\x00-\x1f\x7f]/g, ' ')}\x07`
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the terminal `env` names opens OSC 8 links. A terminal that does
|
|
49
|
+
* not know the sequence may print it, so only ones known to read it get it;
|
|
50
|
+
* tmux and screen pass it through only when configured to.
|
|
51
|
+
*/
|
|
52
|
+
export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
53
|
+
if (env['TMUX'] !== undefined || env['STY'] !== undefined) return false
|
|
54
|
+
const program = env['TERM_PROGRAM']
|
|
55
|
+
if (program === 'tmux' || program === 'screen') return false
|
|
56
|
+
if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
|
|
57
|
+
if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
|
|
58
|
+
if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
|
|
59
|
+
if (env['KONSOLE_VERSION'] !== undefined) return true
|
|
60
|
+
return Number(env['VTE_VERSION'] ?? 0) >= 5000
|
|
61
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DependencySpecError,
|
|
3
3
|
compileTaskPattern,
|
|
4
|
+
isGroupTask,
|
|
4
5
|
isTaskPattern,
|
|
5
6
|
parseDependencySpec,
|
|
6
7
|
type DependencySpec,
|
|
@@ -76,6 +77,69 @@ export function filterUpstreamHashes(
|
|
|
76
77
|
])
|
|
77
78
|
}
|
|
78
79
|
|
|
80
|
+
/**
|
|
81
|
+
* A filter selects among the task's own dependencies, never through a
|
|
82
|
+
* group: `dependsOn: ['build']` over a group `build` of `build.a` and
|
|
83
|
+
* `build.b` offers the filter `build`, not its members. A pattern that
|
|
84
|
+
* matches only a member (`build.*`) therefore selected nothing, and the key stopped moving
|
|
85
|
+
* with that member's inputs: a stale hit under a green run. Such a spec
|
|
86
|
+
* is refused, once, over the graph every key site reads. One that matches
|
|
87
|
+
* nothing anywhere stays legal (a preset's pattern needn't match in every
|
|
88
|
+
* project). An exact name is refused earlier, at load, unless `dependsOn`
|
|
89
|
+
* names it (`assertFilterNamesDeclaredDeps`).
|
|
90
|
+
*/
|
|
91
|
+
export function refuseFiltersThroughGroups(nodes: ReadonlyMap<string, TaskNode>): void {
|
|
92
|
+
for (const node of nodes.values()) {
|
|
93
|
+
const filter = node.config.cache?.inputs?.tasks
|
|
94
|
+
if (filter === undefined || filter.length === 0) continue
|
|
95
|
+
const deps = keyedDeps(node).flatMap((id) => nodes.get(id) ?? [])
|
|
96
|
+
const groups = deps.filter(isGroupTask)
|
|
97
|
+
if (groups.length === 0) continue
|
|
98
|
+
for (const raw of filter) {
|
|
99
|
+
const spec = parseSpec(raw, node.id)
|
|
100
|
+
if (spec.negated) continue
|
|
101
|
+
const matches = specMatcher(spec)
|
|
102
|
+
const hit = (n: TaskNode): boolean => matches(n, n.projectName === node.projectName)
|
|
103
|
+
if (deps.some(hit)) continue
|
|
104
|
+
for (const group of groups) {
|
|
105
|
+
const member = groupMembers(group, nodes).find(hit)
|
|
106
|
+
if (member === undefined) continue
|
|
107
|
+
throw new UserError(
|
|
108
|
+
`${node.id}: cache.inputs.tasks: '${raw}' matches ${member.id} only through the group ${group.id}, so it selects nothing; name '${group.taskName}' or depend on ${member.id} directly`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function parseSpec(raw: string, selfTaskId: string): DependencySpec {
|
|
116
|
+
try {
|
|
117
|
+
return parseDependencySpec(raw)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (err instanceof DependencySpecError) {
|
|
120
|
+
throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
|
|
121
|
+
}
|
|
122
|
+
throw err
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every task a group stands for, through nested groups, each once. */
|
|
127
|
+
function groupMembers(group: TaskNode, nodes: ReadonlyMap<string, TaskNode>): TaskNode[] {
|
|
128
|
+
const out: TaskNode[] = []
|
|
129
|
+
const seen = new Set<string>([group.id])
|
|
130
|
+
const stack = group.deps.toReversed()
|
|
131
|
+
while (stack.length > 0) {
|
|
132
|
+
const id = stack.pop()!
|
|
133
|
+
if (seen.has(id)) continue
|
|
134
|
+
seen.add(id)
|
|
135
|
+
const n = nodes.get(id)
|
|
136
|
+
if (n === undefined) continue
|
|
137
|
+
out.push(n)
|
|
138
|
+
if (isGroupTask(n)) stack.push(...n.deps.toReversed())
|
|
139
|
+
}
|
|
140
|
+
return out
|
|
141
|
+
}
|
|
142
|
+
|
|
79
143
|
/**
|
|
80
144
|
* One dependency as the key fold sees it: the task, and the value the
|
|
81
145
|
* fold dedups by — its hash on the hash path, a structural stand-in for
|
|
@@ -123,16 +187,7 @@ export function selectFoldedDeps(
|
|
|
123
187
|
): FoldCandidate[] {
|
|
124
188
|
if (filter === undefined) return [...deps]
|
|
125
189
|
|
|
126
|
-
const specs: DependencySpec[] = filter.map((raw) =>
|
|
127
|
-
try {
|
|
128
|
-
return parseDependencySpec(raw)
|
|
129
|
-
} catch (err) {
|
|
130
|
-
if (err instanceof DependencySpecError) {
|
|
131
|
-
throw new UserError(`${selfTaskId}: cache.inputs.tasks: ${err.message}`)
|
|
132
|
-
}
|
|
133
|
-
throw err
|
|
134
|
-
}
|
|
135
|
-
})
|
|
190
|
+
const specs: DependencySpec[] = filter.map((raw) => parseSpec(raw, selfTaskId))
|
|
136
191
|
|
|
137
192
|
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
138
193
|
const matchers = specs.map((spec) => specMatcher(spec))
|
package/src/util/errors.ts
CHANGED
|
@@ -7,12 +7,66 @@ import { realpathSync } from 'node:fs'
|
|
|
7
7
|
import os from 'node:os'
|
|
8
8
|
|
|
9
9
|
export class UserError extends Error {
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* What an agent branches on: `VX_E_…`, printed by a verb that answers
|
|
12
|
+
* JSON (`errorDocument`). The message is prose and may change; a code
|
|
13
|
+
* does not. `VX_E_REFUSED` is any refusal without a code of its own.
|
|
14
|
+
*/
|
|
15
|
+
readonly code: string
|
|
16
|
+
// Two signatures, not an optional parameter: the package-api record
|
|
17
|
+
// keeps the one-argument form every plugin already calls.
|
|
18
|
+
constructor(message: string)
|
|
19
|
+
constructor(message: string, code: string)
|
|
20
|
+
constructor(message: string, code = 'VX_E_REFUSED') {
|
|
11
21
|
super(message)
|
|
12
22
|
this.name = 'UserError'
|
|
23
|
+
this.code = code
|
|
13
24
|
}
|
|
14
25
|
}
|
|
15
26
|
|
|
27
|
+
// A refusal that opens with the config that caused it: the schema's
|
|
28
|
+
// `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
|
|
29
|
+
const CONFIG_REFUSAL = /^\/\S*\/vx\.(?:config|workspace)\.[cm]?[jt]s\b/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The stable code of anything `bin.ts` catches. A UserError's own, but
|
|
33
|
+
* `VX_E_CONFIG` for a codeless one that names its config file; then the
|
|
34
|
+
* environment's refusals; anything else is a defect, `VX_E_INTERNAL`. A
|
|
35
|
+
* UserError from another copy of core has no `code` when that copy
|
|
36
|
+
* predates it.
|
|
37
|
+
*/
|
|
38
|
+
export function errorCode(err: unknown): string {
|
|
39
|
+
if (isUserError(err)) {
|
|
40
|
+
const code = (err as { code?: unknown }).code
|
|
41
|
+
if (typeof code === 'string' && code !== 'VX_E_REFUSED') return code
|
|
42
|
+
return CONFIG_REFUSAL.test(err.message) ? 'VX_E_CONFIG' : 'VX_E_REFUSED'
|
|
43
|
+
}
|
|
44
|
+
if (isOutOfFds(err)) return 'VX_E_FDS'
|
|
45
|
+
if (isFsRefusal(err)) return 'VX_E_FS'
|
|
46
|
+
return 'VX_E_INTERNAL'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Does this argv ask for JSON (`--format json`, `--format=json`,
|
|
51
|
+
* `--dry=json`)? Then a refusal is a JSON document on stdout too, so a
|
|
52
|
+
* reader of stdout gets an answer, not nothing. Args after `--` are the
|
|
53
|
+
* task's, not vx's.
|
|
54
|
+
*/
|
|
55
|
+
export function wantsJson(argv: readonly string[]): boolean {
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i]!
|
|
58
|
+
if (a === '--') return false
|
|
59
|
+
if (a === '--format=json' || a === '--dry=json') return true
|
|
60
|
+
if (a === '--format' && argv[i + 1] === 'json') return true
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The JSON a verb that answers JSON prints for a refusal: one line. */
|
|
66
|
+
export function errorDocument(code: string, message: string): string {
|
|
67
|
+
return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
|
|
68
|
+
}
|
|
69
|
+
|
|
16
70
|
/**
|
|
17
71
|
* `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
|
|
18
72
|
* core. A compiled `vx` binary carries core inside it while a plugin in the
|
package/src/util/index.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
export {
|
|
5
5
|
DISK_FULL_HINT,
|
|
6
|
+
errorCode,
|
|
7
|
+
errorDocument,
|
|
6
8
|
fsRefusalHint,
|
|
7
9
|
isDiskFull,
|
|
8
10
|
isFsRefusal,
|
|
@@ -17,6 +19,7 @@ export {
|
|
|
17
19
|
isOutOfFds,
|
|
18
20
|
notAWorkTree,
|
|
19
21
|
OUT_OF_FDS_HINT,
|
|
22
|
+
wantsJson,
|
|
20
23
|
} from './errors.js'
|
|
21
24
|
export { configErrorFrame } from './config-frame.js'
|
|
22
25
|
export { xxh3, xxh3hex } from './hash.js'
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -32,6 +32,12 @@ export function secretNamed(name: string): boolean {
|
|
|
32
32
|
return secret
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A URL's password (`postgres://app:<pw>@db/app`): a credential whatever
|
|
37
|
+
* the variable is named, so `DATABASE_URL` and `REDIS_URL` printed it.
|
|
38
|
+
*/
|
|
39
|
+
const URL_PASSWORD = /[a-z][a-z0-9+.-]*:\/\/[^\s:/@]*:([^\s/@]+)@/gi
|
|
40
|
+
|
|
35
41
|
/**
|
|
36
42
|
* Values shorter than this are not masked: `KEY=1` would mask every `1`
|
|
37
43
|
* in every line, and a value that short is no secret worth the noise.
|
|
@@ -86,7 +92,13 @@ export function secretMask(
|
|
|
86
92
|
}
|
|
87
93
|
for (const source of sources) {
|
|
88
94
|
if (source === undefined) continue
|
|
89
|
-
for (const name in source)
|
|
95
|
+
for (const name in source) {
|
|
96
|
+
const value = source[name]
|
|
97
|
+
if (secretNamed(name)) add(value)
|
|
98
|
+
if (value?.includes('://') === true && value.includes('@')) {
|
|
99
|
+
for (const m of value.matchAll(URL_PASSWORD)) add(m[1])
|
|
100
|
+
}
|
|
101
|
+
}
|
|
90
102
|
for (const name of named) add(source[name])
|
|
91
103
|
}
|
|
92
104
|
if (values.size === 0) return null
|
|
@@ -165,6 +165,7 @@ export async function findWorkspaceRoot(
|
|
|
165
165
|
`Could not find a workspace root in any parent of ${start} ` +
|
|
166
166
|
`(looked for pnpm-workspace.yaml or package.json): run vx inside a project, ` +
|
|
167
167
|
`or create a package.json (\`bun init\` or \`npm init -y\`) and run \`vx init\``,
|
|
168
|
+
'VX_E_NO_WORKSPACE',
|
|
168
169
|
)
|
|
169
170
|
}
|
|
170
171
|
|