@vzn/vx 0.0.637 → 0.0.638
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/caching.md +41 -14
- package/docs/cli.md +115 -73
- package/docs/execution.md +1 -1
- package/docs/features.md +6 -5
- package/docs/schema.md +24 -15
- package/package.json +5 -5
- package/schemas/plan.json +2 -2
- package/skills/vx/SKILL.md +13 -2
- package/src/bin.ts +13 -7
- package/src/cache/cache.ts +21 -2
- package/src/cache/chained-cache.ts +4 -0
- package/src/cache/index.ts +9 -1
- package/src/cache/inputs.ts +0 -24
- package/src/cache/layer.ts +7 -1
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/plugin-cache.ts +248 -0
- package/src/cache/policy.ts +9 -0
- package/src/cache/run-history.ts +68 -0
- package/src/cache/run-store.ts +77 -0
- package/src/cache/schema.ts +38 -9
- package/src/cache/scoped-cache.ts +15 -2
- package/src/cli/cache.ts +33 -16
- package/src/cli/completions.ts +9 -5
- package/src/cli/help.ts +16 -7
- package/src/cli/index.ts +25 -11
- package/src/cli/info.ts +6 -5
- package/src/cli/init.ts +14 -15
- package/src/cli/last.ts +14 -7
- package/src/cli/lock.ts +39 -27
- package/src/cli/plan-format.ts +5 -3
- package/src/cli/plugin-commands.ts +77 -6
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +14 -12
- package/src/cli/select.ts +18 -9
- package/src/cli/show.ts +66 -40
- package/src/cli/task-verb.ts +4 -4
- package/src/cli/upgrade.ts +20 -11
- package/src/cli/watch-filter.ts +7 -2
- package/src/cli/watch-judge.ts +6 -1
- package/src/cli/watch.ts +44 -20
- package/src/cli/why.ts +10 -1
- package/src/cli/workspace-config.ts +2 -2
- package/src/graph/scheduler.ts +8 -0
- package/src/graph/task-graph.ts +32 -4
- package/src/index.ts +11 -1
- package/src/orchestrator/affected-tasks.ts +8 -3
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +26 -43
- package/src/orchestrator/failure-mode.ts +19 -10
- package/src/orchestrator/framed-output.ts +1 -2
- package/src/orchestrator/history.ts +60 -4
- package/src/orchestrator/index.ts +2 -1
- package/src/orchestrator/logger.ts +9 -2
- package/src/orchestrator/metrics.ts +7 -3
- package/src/orchestrator/plugin-host.ts +14 -4
- package/src/orchestrator/plugin.ts +80 -8
- package/src/orchestrator/prepare.ts +51 -7
- package/src/orchestrator/projects.ts +3 -0
- package/src/orchestrator/remote-prefetch.ts +9 -5
- package/src/orchestrator/run-records.ts +1 -1
- package/src/orchestrator/run.ts +126 -27
- package/src/orchestrator/status-line.ts +1 -1
- package/src/orchestrator/summary.ts +69 -44
- package/src/orchestrator/telemetry.ts +1 -1
- package/src/settings.ts +12 -1
- package/src/{orchestrator → util}/colors.ts +5 -0
- package/src/util/config-frame.ts +31 -2
- package/src/util/errors.ts +32 -0
- package/src/util/index.ts +8 -1
- package/src/util/refusal-notice.ts +111 -0
- package/src/util/report.ts +31 -0
- package/src/util/ui.ts +186 -0
- package/src/workspace/affected.ts +20 -23
- package/src/workspace/dependency-rules.ts +12 -4
- package/src/workspace/migration.ts +87 -91
- package/src/workspace/workspace.ts +5 -4
package/src/orchestrator/run.ts
CHANGED
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
type CacheLayer,
|
|
12
12
|
type CachePolicy,
|
|
13
13
|
cachesNothing,
|
|
14
|
+
formatCachePolicy,
|
|
15
|
+
packRunOutputs,
|
|
16
|
+
resolveOutputs,
|
|
17
|
+
resolveWorkspaceOutputs,
|
|
18
|
+
runEntryName,
|
|
14
19
|
stopRuntimeProbes,
|
|
15
20
|
} from '../cache/index.js'
|
|
16
21
|
import { VERSION } from '../version.js'
|
|
@@ -48,6 +53,9 @@ import {
|
|
|
48
53
|
secretMask,
|
|
49
54
|
isFsRefusal,
|
|
50
55
|
isUserError,
|
|
56
|
+
detectColors,
|
|
57
|
+
relPosix,
|
|
58
|
+
type ColorSupport,
|
|
51
59
|
} from '../util/index.js'
|
|
52
60
|
import { keyedProjects } from './keyed-projects.js'
|
|
53
61
|
import { isDefaultBuild } from './projects.js'
|
|
@@ -58,14 +66,14 @@ import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
|
|
|
58
66
|
import { excludedTaint } from './excluded-keys.js'
|
|
59
67
|
import { keyUpstream } from './upstream.js'
|
|
60
68
|
import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
|
|
61
|
-
import { installPlugins, PluginSetupError } from './plugin.js'
|
|
69
|
+
import { installPlugins, PluginSetupError, type RunReport, type RunReportTask } from './plugin.js'
|
|
70
|
+
import { decodeOutputLog } from './output-log.js'
|
|
62
71
|
import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
|
|
63
72
|
import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
|
|
64
73
|
import { claimOutput, guardedOutput } from './output-host.js'
|
|
65
74
|
import { assembleRunSummary, isPassStatus } from './telemetry.js'
|
|
66
75
|
import type { RunContextRecord } from './telemetry.js'
|
|
67
76
|
import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
|
|
68
|
-
import { detectColors, type ColorSupport } from './colors.js'
|
|
69
77
|
import { plainOutput } from './plain-output.js'
|
|
70
78
|
import { formatPersistentList } from './framed-output.js'
|
|
71
79
|
import { createForecast } from './forecast.js'
|
|
@@ -138,18 +146,19 @@ const emptyShortCircuit = (): ShortCircuit => ({
|
|
|
138
146
|
groupKeys: new Map(),
|
|
139
147
|
})
|
|
140
148
|
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const
|
|
148
|
-
if (
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
149
|
+
/** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
|
|
150
|
+
async function outputsLeft(
|
|
151
|
+
node: TaskNode,
|
|
152
|
+
workspaceRoot: string,
|
|
153
|
+
nestedProjectDirs: string[],
|
|
154
|
+
): Promise<string[]> {
|
|
155
|
+
const declared = node.config.cache?.outputs
|
|
156
|
+
if (declared === undefined) return []
|
|
157
|
+
const [own, ws] = await Promise.all([
|
|
158
|
+
resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
|
|
159
|
+
resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
|
|
160
|
+
])
|
|
161
|
+
return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
|
|
153
162
|
}
|
|
154
163
|
|
|
155
164
|
/**
|
|
@@ -405,11 +414,11 @@ async function runOnBus(
|
|
|
405
414
|
// per cycle under `vx watch`. A plugin whose own setup threw set up
|
|
406
415
|
// nothing, and is the one left out.
|
|
407
416
|
let tornDown = false
|
|
408
|
-
const teardown = async (except?: unknown): Promise<void> => {
|
|
417
|
+
const teardown = async (except?: unknown, report?: RunReport): Promise<void> => {
|
|
409
418
|
if (tornDown) return
|
|
410
419
|
tornDown = true
|
|
411
420
|
const plugins = prepared.plugins.filter((p) => p !== except)
|
|
412
|
-
await teardownPlugins(plugins, (m) => log.status(m))
|
|
421
|
+
await teardownPlugins(plugins, (m) => log.status(m), report)
|
|
413
422
|
}
|
|
414
423
|
// A task whose command re-enters `vx run` in the workspace running it
|
|
415
424
|
// is refused. When the inner run reaches this task again it forks a run
|
|
@@ -437,7 +446,7 @@ async function runOnBus(
|
|
|
437
446
|
// A refusal is the caller's to print (on stderr, as `vx run:`): as a
|
|
438
447
|
// status line it went to stdout, into whatever a script piped there.
|
|
439
448
|
if (prepared.unresolvedTasks.length > 0) {
|
|
440
|
-
const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${
|
|
449
|
+
const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
|
|
441
450
|
await teardown()
|
|
442
451
|
prepared.cache.close()
|
|
443
452
|
return { ok: false, outcomes: [], refused }
|
|
@@ -467,6 +476,13 @@ async function runOnBus(
|
|
|
467
476
|
// the plugin. A plugin without `setup` subscribes nothing.
|
|
468
477
|
let disposePlugins: (() => void) | undefined
|
|
469
478
|
let telemetry: TelemetryHandle | undefined
|
|
479
|
+
// One run-id per `vx run` invocation. Every task in the resulting graph
|
|
480
|
+
// carries it so analytics queries can group by invocation; plugins get it
|
|
481
|
+
// in their teardown's `RunReport`.
|
|
482
|
+
const runId = ulid()
|
|
483
|
+
const head = captureGitContext(prepared.workspaceRoot)
|
|
484
|
+
const ciContext = detectCi(process.env)
|
|
485
|
+
const keepRunLog = prepared.keepRunEntry !== undefined
|
|
470
486
|
try {
|
|
471
487
|
disposePlugins = await installPlugins({
|
|
472
488
|
plugins: prepared.plugins,
|
|
@@ -474,6 +490,9 @@ async function runOnBus(
|
|
|
474
490
|
workspaceRoot: prepared.workspaceRoot,
|
|
475
491
|
cacheDir: prepared.cacheDir,
|
|
476
492
|
warn: (m) => log.status(m),
|
|
493
|
+
cache: prepared.cacheFor,
|
|
494
|
+
ci: ciContext.ci,
|
|
495
|
+
branch: prepared.branch(),
|
|
477
496
|
})
|
|
478
497
|
} catch (err) {
|
|
479
498
|
disposePlugins?.()
|
|
@@ -735,9 +754,6 @@ async function runOnBus(
|
|
|
735
754
|
}),
|
|
736
755
|
)
|
|
737
756
|
try {
|
|
738
|
-
// One run-id per `vx run` invocation. Every task in the resulting
|
|
739
|
-
// graph carries it so analytics queries can group by invocation.
|
|
740
|
-
const runId = ulid()
|
|
741
757
|
const runStartHrTimeNs = process.hrtime.bigint()
|
|
742
758
|
const endedAtMsAtStart = Date.now()
|
|
743
759
|
const remoteCacheEnabled = prepared.hasRemoteLayer
|
|
@@ -768,10 +784,10 @@ async function runOnBus(
|
|
|
768
784
|
// second status spawn.
|
|
769
785
|
// `run.branch` names the branch over git's (`prepared.branch`).
|
|
770
786
|
const gitContext = {
|
|
771
|
-
...
|
|
787
|
+
...head,
|
|
788
|
+
dirty: gitFilesCache.worktreeDirty,
|
|
772
789
|
branch: prepared.branch(),
|
|
773
790
|
}
|
|
774
|
-
const ciContext = detectCi(process.env)
|
|
775
791
|
const hostContext = captureHostContext()
|
|
776
792
|
|
|
777
793
|
// The canonical run-context record — the same git/CI/host data the
|
|
@@ -809,7 +825,7 @@ async function runOnBus(
|
|
|
809
825
|
invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
|
|
810
826
|
),
|
|
811
827
|
requestedTasks: [...options.tasks],
|
|
812
|
-
cachePolicy:
|
|
828
|
+
cachePolicy: formatCachePolicy(policy),
|
|
813
829
|
concurrency,
|
|
814
830
|
flow: options.flow ?? null,
|
|
815
831
|
commitSha: gitContext.commitSha,
|
|
@@ -912,6 +928,13 @@ async function runOnBus(
|
|
|
912
928
|
version: VERSION,
|
|
913
929
|
packageCount: packagesInScope.size,
|
|
914
930
|
remoteCacheEnabled,
|
|
931
|
+
cache: {
|
|
932
|
+
local: policy.localRead || policy.localWrite,
|
|
933
|
+
localWrite: policy.localWrite,
|
|
934
|
+
remote: remoteCacheEnabled && (policy.remoteRead || policy.remoteWrite),
|
|
935
|
+
uploads: prepared.localCache.uploads,
|
|
936
|
+
notDownloaded: () => deferredOutputs.pending().length,
|
|
937
|
+
},
|
|
915
938
|
concurrency,
|
|
916
939
|
workspaceProjectCount,
|
|
917
940
|
}
|
|
@@ -1107,6 +1130,7 @@ async function runOnBus(
|
|
|
1107
1130
|
if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
|
|
1108
1131
|
if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
|
|
1109
1132
|
if (tainted) a.taintedUpstream = true
|
|
1133
|
+
if (keepRunLog) a.keepRunLog = true
|
|
1110
1134
|
if (!dependedOn.has(node.id)) a.noDependants = true
|
|
1111
1135
|
if (holders.has(node.id)) a.terminal = true
|
|
1112
1136
|
if (armSandbox !== undefined) a.armSandbox = armSandbox
|
|
@@ -1324,7 +1348,7 @@ async function runOnBus(
|
|
|
1324
1348
|
const stillDeferred = deferredOutputs.pending()
|
|
1325
1349
|
if (stillDeferred.length > 0) {
|
|
1326
1350
|
log.status(
|
|
1327
|
-
` Deferred:
|
|
1351
|
+
` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
|
|
1328
1352
|
)
|
|
1329
1353
|
}
|
|
1330
1354
|
|
|
@@ -1332,6 +1356,55 @@ async function runOnBus(
|
|
|
1332
1356
|
// change the run's exit code — the run already happened.
|
|
1333
1357
|
// Written again after the keep-alive wait: a kept server's crash or a
|
|
1334
1358
|
// Ctrl-C there is the process's exit, and the first write said ok.
|
|
1359
|
+
// The run's own bytes, kept in a remote that takes its writes (owner
|
|
1360
|
+
// 2026-10-15): a failed task's log and the files its outputs left, an
|
|
1361
|
+
// uncached task's log. Then the report every plugin's teardown reads.
|
|
1362
|
+
const keepRunBytes = async (final: readonly TaskOutcome[]): Promise<RunReport> => {
|
|
1363
|
+
const keep = prepared.keepRunEntry
|
|
1364
|
+
const tasks: RunReportTask[] = []
|
|
1365
|
+
const work: (() => Promise<void>)[] = []
|
|
1366
|
+
for (const o of final) {
|
|
1367
|
+
if (isGroupTask(o.node)) continue
|
|
1368
|
+
const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
|
|
1369
|
+
id: o.node.id,
|
|
1370
|
+
status: o.status,
|
|
1371
|
+
durationMs: Math.round(o.durationMs),
|
|
1372
|
+
...(o.hash !== undefined ? { key: o.hash } : {}),
|
|
1373
|
+
}
|
|
1374
|
+
tasks.push(task)
|
|
1375
|
+
if (keep === undefined) continue
|
|
1376
|
+
const runLog = o.runLog
|
|
1377
|
+
if (runLog !== undefined) {
|
|
1378
|
+
work.push(async () => {
|
|
1379
|
+
const text = decodeOutputLog(runLog)
|
|
1380
|
+
.map((c) => c.text)
|
|
1381
|
+
.join('')
|
|
1382
|
+
if (text.length === 0) return
|
|
1383
|
+
const name = runEntryName(runId, o.node.id, 'log')
|
|
1384
|
+
if (await keep(name, new TextEncoder().encode(text))) task.log = name
|
|
1385
|
+
})
|
|
1386
|
+
}
|
|
1387
|
+
if (o.status === 'failed' && o.where === undefined) {
|
|
1388
|
+
work.push(async () => {
|
|
1389
|
+
const bytes = await packRunOutputs(
|
|
1390
|
+
prepared.workspaceRoot,
|
|
1391
|
+
await outputsLeft(
|
|
1392
|
+
o.node,
|
|
1393
|
+
prepared.workspaceRoot,
|
|
1394
|
+
prepared.nestedDirsByProject.get(o.node.projectName) ?? [],
|
|
1395
|
+
),
|
|
1396
|
+
)
|
|
1397
|
+
if (bytes === null) return
|
|
1398
|
+
const name = runEntryName(runId, o.node.id, 'outputs')
|
|
1399
|
+
if (await keep(name, bytes)) task.outputs = name
|
|
1400
|
+
})
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
for (let i = 0; i < work.length; i += 8) {
|
|
1404
|
+
await Promise.all(work.slice(i, i + 8).map((w) => w()))
|
|
1405
|
+
}
|
|
1406
|
+
return { runId, commit: head.commitSha, ciRun: ciContext, ok, tasks }
|
|
1407
|
+
}
|
|
1335
1408
|
let json: RunSummaryJson | undefined
|
|
1336
1409
|
const summarize = async (runOk: boolean, final = list): Promise<void> => {
|
|
1337
1410
|
if (options.json === true) {
|
|
@@ -1405,7 +1478,7 @@ async function runOnBus(
|
|
|
1405
1478
|
// own output masks it, so cache.db holds no plaintext value.
|
|
1406
1479
|
command: maskInvocation(options.command ?? process.argv.slice(1).join(' ')),
|
|
1407
1480
|
requestedTasks: options.tasks,
|
|
1408
|
-
cachePolicy:
|
|
1481
|
+
cachePolicy: formatCachePolicy(policy),
|
|
1409
1482
|
concurrency,
|
|
1410
1483
|
flow: options.flow ?? null,
|
|
1411
1484
|
forwardArgs: options.forwardArgs,
|
|
@@ -1470,6 +1543,7 @@ async function runOnBus(
|
|
|
1470
1543
|
// drain of the lane stood here until item 634; deleting it changed
|
|
1471
1544
|
// nothing the suite could see, because this gate is the rule.
|
|
1472
1545
|
await cache.drainUploads?.()
|
|
1546
|
+
const report = await keepRunBytes(list)
|
|
1473
1547
|
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1474
1548
|
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1475
1549
|
// whole block is skipped and the run is byte-identical to before.
|
|
@@ -1528,7 +1602,7 @@ async function runOnBus(
|
|
|
1528
1602
|
// whole keep-alive wait below (C-66). Idempotent; the finally's stay.
|
|
1529
1603
|
disposePlugins?.()
|
|
1530
1604
|
telemetry?.dispose()
|
|
1531
|
-
await teardown()
|
|
1605
|
+
await teardown(undefined, report)
|
|
1532
1606
|
await closeCache()
|
|
1533
1607
|
mark('close')
|
|
1534
1608
|
printTimings()
|
|
@@ -1761,7 +1835,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
|
|
|
1761
1835
|
return {
|
|
1762
1836
|
tasks: [],
|
|
1763
1837
|
unresolvedTasks: prepared.unresolvedTasks,
|
|
1764
|
-
unresolvedHint:
|
|
1838
|
+
unresolvedHint: await unresolvedHint(prepared),
|
|
1765
1839
|
}
|
|
1766
1840
|
}
|
|
1767
1841
|
if (prepared.empty === 'no-tasks-declared' && prepared.declaredElsewhere.length > 0) {
|
|
@@ -1865,6 +1939,31 @@ function elsewhereHint(names: readonly string[]): string {
|
|
|
1865
1939
|
return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter to pick them.`
|
|
1866
1940
|
}
|
|
1867
1941
|
|
|
1942
|
+
/**
|
|
1943
|
+
* What follows "no projects declare task(s): …": where the name is, its
|
|
1944
|
+
* nearest spelling, and, with neither, the lock a run reads its configs
|
|
1945
|
+
* from: a task just added to a vx.config is not in it until `vx lock`.
|
|
1946
|
+
*/
|
|
1947
|
+
async function unresolvedHint(prepared: {
|
|
1948
|
+
unresolvedTasks: readonly string[]
|
|
1949
|
+
hintProjects: ReadonlyMap<string, ProjectEntry>
|
|
1950
|
+
declaredElsewhere: readonly string[]
|
|
1951
|
+
anyProjectConfig: boolean
|
|
1952
|
+
}): Promise<string> {
|
|
1953
|
+
const elsewhere = elsewhereHint(prepared.declaredElsewhere)
|
|
1954
|
+
const near = didYouMean(
|
|
1955
|
+
prepared.unresolvedTasks,
|
|
1956
|
+
prepared.hintProjects,
|
|
1957
|
+
prepared.declaredElsewhere,
|
|
1958
|
+
)
|
|
1959
|
+
const init = await initHint(prepared)
|
|
1960
|
+
const lock =
|
|
1961
|
+
init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
|
|
1962
|
+
? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
|
|
1963
|
+
: ''
|
|
1964
|
+
return `${elsewhere}${near}${init}${lock}`
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1868
1967
|
/**
|
|
1869
1968
|
* The first-run case, told apart from a typo: no project in the workspace
|
|
1870
1969
|
* has a `vx.config.*` at all, so no name could have resolved. Reached only
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// ordinary write erases the status display first, writes its content,
|
|
6
6
|
// then redraws, so the display can never interleave with task output.
|
|
7
7
|
|
|
8
|
-
import { paint, type ColorSupport } from '
|
|
8
|
+
import { paint, type ColorSupport } from '../util/index.js'
|
|
9
9
|
import { formatTaskRow, paintIdParts, TIME_COL } from './framed-output.js'
|
|
10
10
|
|
|
11
11
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
@@ -4,57 +4,31 @@
|
|
|
4
4
|
// aren't real work and would inflate "N total" misleadingly.
|
|
5
5
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
|
-
import { paint, type ColorSupport } from './colors.js'
|
|
8
7
|
import { tallyOutcomes } from './tally.js'
|
|
9
8
|
import { isCacheHit } from './telemetry.js'
|
|
10
9
|
import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
|
|
11
10
|
import { isGroupTask } from '../graph/index.js'
|
|
12
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
ERROR,
|
|
13
|
+
formatElapsed,
|
|
14
|
+
gradientRule,
|
|
15
|
+
paint,
|
|
16
|
+
SUCCESS,
|
|
17
|
+
WARN,
|
|
18
|
+
type ColorSupport,
|
|
19
|
+
} from '../util/index.js'
|
|
13
20
|
|
|
14
21
|
const NO_COLOR: ColorSupport = { enabled: false }
|
|
15
22
|
|
|
16
|
-
const SUCCESS = '#22c55e'
|
|
17
|
-
const WARN = '#eab308'
|
|
18
23
|
// Cache provenance hues (owner): local light blue, remote dark blue —
|
|
19
24
|
// yellow is skipped's color on both meters.
|
|
20
25
|
const LOCAL = '#38bdf8' // sky-400
|
|
21
26
|
const REMOTE = '#2563eb' // blue-600
|
|
22
|
-
const ERROR = '#ef4444'
|
|
23
27
|
const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
|
|
24
28
|
const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
|
|
25
29
|
const ACCENT = '#06b6d4' // cyan-500: the live region's running word
|
|
26
30
|
|
|
27
31
|
const BAR_WIDTH = 50
|
|
28
|
-
// Brand gradient for the rule: identity violet → pink (the project /
|
|
29
|
-
// task hues), faded across the dash run. Plain dashes when colors
|
|
30
|
-
// are off.
|
|
31
|
-
const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
|
|
32
|
-
const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
|
|
33
|
-
// Rule ends flush with the bars: 2 indent + 8 label + 2 gap + 50 cells.
|
|
34
|
-
const RULE_DASHES = 57
|
|
35
|
-
|
|
36
|
-
/** Total visible rule width: `\u2500 <mark> ` + dashes. Shared by summary + header. */
|
|
37
|
-
function gradientRule(colors: ColorSupport, mark = 'vx'): string {
|
|
38
|
-
const head = `\u2500 `
|
|
39
|
-
const dashes = '\u2500'.repeat(Math.max(8, RULE_DASHES + 2 - mark.length))
|
|
40
|
-
if (!colors.enabled) return `${head}${mark} ${dashes}`
|
|
41
|
-
const chunks = 8
|
|
42
|
-
const per = Math.ceil(RULE_DASHES / chunks)
|
|
43
|
-
let out = ''
|
|
44
|
-
for (let i = 0; i < chunks; i++) {
|
|
45
|
-
const seg = dashes.slice(i * per, (i + 1) * per)
|
|
46
|
-
if (seg.length === 0) break
|
|
47
|
-
const f = i / (chunks - 1)
|
|
48
|
-
const hex = `#${GRADIENT_FROM.map((c, j) =>
|
|
49
|
-
Math.round(c + (GRADIENT_TO[j]! - c) * f)
|
|
50
|
-
.toString(16)
|
|
51
|
-
.padStart(2, '0'),
|
|
52
|
-
).join('')}`
|
|
53
|
-
out += paint(hex, seg, colors)
|
|
54
|
-
}
|
|
55
|
-
return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
|
|
56
|
-
}
|
|
57
|
-
|
|
58
32
|
/**
|
|
59
33
|
* Stacked state meter: one ▰ run per bucket, proportional cells via
|
|
60
34
|
* largest-remainder, every non-zero bucket guaranteed at least one
|
|
@@ -111,6 +85,10 @@ export interface SummaryStats {
|
|
|
111
85
|
upToDate: number
|
|
112
86
|
restoredLocal: number
|
|
113
87
|
restoredRemote: number
|
|
88
|
+
/** Ran here and saved to the cache: the legend's `↑`. */
|
|
89
|
+
saved?: number
|
|
90
|
+
/** Ran here with a `cache` block, succeeded, and saved nothing. */
|
|
91
|
+
notSaved?: number
|
|
114
92
|
/** Tasks that executed (success + failed) with a `cache` block — the misses. */
|
|
115
93
|
miss: number
|
|
116
94
|
/** Executed with no `cache` block: never consulted the cache, not a miss. */
|
|
@@ -143,6 +121,21 @@ export interface RunContext {
|
|
|
143
121
|
/** Worker-pool size for this run; shown on the `info` row. */
|
|
144
122
|
concurrency?: number
|
|
145
123
|
remoteCacheEnabled: boolean
|
|
124
|
+
/**
|
|
125
|
+
* The cache layers the run used, so the legend reads `local 1= 2↓ 4↑ ·
|
|
126
|
+
* remote 3↓ 4↑`. Absent: the plain `N miss` legend.
|
|
127
|
+
*/
|
|
128
|
+
cache?: {
|
|
129
|
+
/** The run's policy reads or writes the local layer. */
|
|
130
|
+
local: boolean
|
|
131
|
+
localWrite: boolean
|
|
132
|
+
/** A remote layer exists and the policy reads or writes it. */
|
|
133
|
+
remote: boolean
|
|
134
|
+
/** The run's uploads, filled as each lands (a live ref). */
|
|
135
|
+
uploads: { count: number; failed: number }
|
|
136
|
+
/** Tasks whose outputs are still in the remote store. */
|
|
137
|
+
notDownloaded: () => number
|
|
138
|
+
}
|
|
146
139
|
/** Total projects discovery found — the bar's denominator. */
|
|
147
140
|
workspaceProjectCount?: number
|
|
148
141
|
}
|
|
@@ -234,8 +227,8 @@ export function formatSummarySection(
|
|
|
234
227
|
if (taskParts.length > 0) lines.push(legend(taskParts))
|
|
235
228
|
}
|
|
236
229
|
|
|
237
|
-
// Cache meter + numbers
|
|
238
|
-
// remote.
|
|
230
|
+
// Cache meter + numbers: per layer when the run context names its
|
|
231
|
+
// layers, else miss · no-cache · up-to-date · local · remote.
|
|
239
232
|
if (miss + noCache + hits + left + stats.skipped > 0) {
|
|
240
233
|
const cacheBar = segmentBar(
|
|
241
234
|
[
|
|
@@ -250,13 +243,38 @@ export function formatSummarySection(
|
|
|
250
243
|
colors,
|
|
251
244
|
)
|
|
252
245
|
const cacheParts: string[] = []
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
246
|
+
const layers = context?.cache
|
|
247
|
+
if (layers !== undefined) {
|
|
248
|
+
// Per layer (M, 2026-10-15): `●` fresh (outputs already on disk, from
|
|
249
|
+
// either layer) and `○` no cache, both local; `↓` restored from a
|
|
250
|
+
// layer, `↑` saved to it.
|
|
251
|
+
const n = (count: number, glyph: string): string[] => (count > 0 ? [`${count}${glyph}`] : [])
|
|
252
|
+
const local = [
|
|
253
|
+
...(layers.local ? n(stats.upToDate, '\u25cf') : []),
|
|
254
|
+
...n(noCache, '\u25cb'),
|
|
255
|
+
...(layers.local ? n(stats.restoredLocal, '\u2193') : []),
|
|
256
|
+
...(layers.localWrite ? n(stats.saved ?? 0, '\u2191') : []),
|
|
257
|
+
]
|
|
258
|
+
if (local.length > 0) cacheParts.push(paint(LOCAL, `local ${local.join(' ')}`, colors))
|
|
259
|
+
const remote = [...n(stats.restoredRemote, '\u2193'), ...n(layers.uploads.count, '\u2191')]
|
|
260
|
+
if (layers.remote && remote.length > 0)
|
|
261
|
+
cacheParts.push(paint(REMOTE, `remote ${remote.join(' ')}`, colors))
|
|
262
|
+
if (layers.uploads.failed > 0)
|
|
263
|
+
cacheParts.push(paint(ERROR, `${layers.uploads.failed} \u2191 failed`, colors))
|
|
264
|
+
if ((stats.notSaved ?? 0) > 0)
|
|
265
|
+
cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
|
|
266
|
+
const notDownloaded = layers.notDownloaded()
|
|
267
|
+
if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
|
|
268
|
+
} else {
|
|
269
|
+
if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
|
|
270
|
+
if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
271
|
+
if (stats.upToDate > 0)
|
|
272
|
+
cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
|
|
273
|
+
if (stats.restoredLocal > 0)
|
|
274
|
+
cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
|
|
275
|
+
if (stats.restoredRemote > 0)
|
|
276
|
+
cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
|
|
277
|
+
}
|
|
260
278
|
// Skipped tasks ride the bar, yellow like the tasks meter, so the two
|
|
261
279
|
// bars fill alike; their count is the tasks legend's alone (the footer
|
|
262
280
|
// never says a fact twice, M, 2026-10-14).
|
|
@@ -364,6 +382,11 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
|
|
|
364
382
|
|
|
365
383
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
366
384
|
// (module boundary), and the footer only needs one compact form.
|
|
385
|
+
/** Ran here with a `cache` block, succeeded, and its result was not saved. */
|
|
386
|
+
export function ranUnsaved(o: TaskOutcome): boolean {
|
|
387
|
+
return o.status === 'success' && !ranNoCache(o) && o.saved !== true && o.where === undefined
|
|
388
|
+
}
|
|
389
|
+
|
|
367
390
|
export function formatRunSummary(
|
|
368
391
|
outcomes: readonly TaskOutcome[],
|
|
369
392
|
totalMs: number,
|
|
@@ -402,6 +425,8 @@ export function formatRunSummary(
|
|
|
402
425
|
upToDate: t.upToDate,
|
|
403
426
|
restoredLocal: t.restoredLocal,
|
|
404
427
|
restoredRemote: t.restoredRemote,
|
|
428
|
+
saved: outcomes.filter((o) => o.saved === true && !isGroupTask(o.node)).length,
|
|
429
|
+
notSaved: outcomes.filter((o) => !isGroupTask(o.node) && ranUnsaved(o)).length,
|
|
405
430
|
miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
|
|
406
431
|
noCache,
|
|
407
432
|
savedMs,
|
|
@@ -117,7 +117,7 @@ export interface RunContextRecord {
|
|
|
117
117
|
/** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
|
|
118
118
|
command: string
|
|
119
119
|
requestedTasks: readonly string[]
|
|
120
|
-
/**
|
|
120
|
+
/** The `--cache-policy` spec, e.g. `'local:rw,remote:r'`. */
|
|
121
121
|
cachePolicy: string
|
|
122
122
|
concurrency: number
|
|
123
123
|
flow: 'focused' | 'broad' | null
|
package/src/settings.ts
CHANGED
|
@@ -36,6 +36,8 @@ export interface Setting {
|
|
|
36
36
|
readonly task?: string
|
|
37
37
|
/** False: read before `.vx/workspace` loads, so flag and env only. */
|
|
38
38
|
readonly config?: false
|
|
39
|
+
/** False: `.vx/workspace` only, no variable. */
|
|
40
|
+
readonly env?: false
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
const SCHEDULES = ['most-work', 'critical-path', 'direct-dependents', 'ready-order'] as const
|
|
@@ -193,6 +195,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
193
195
|
default: 'true',
|
|
194
196
|
doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
|
|
195
197
|
task: 'cache.outputs.clean',
|
|
198
|
+
env: false,
|
|
196
199
|
},
|
|
197
200
|
{
|
|
198
201
|
path: 'lock.reviewFiles',
|
|
@@ -221,6 +224,13 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
221
224
|
doc: "Print the run's stage timings to stderr.",
|
|
222
225
|
config: false,
|
|
223
226
|
},
|
|
227
|
+
{
|
|
228
|
+
path: 'debug.watch',
|
|
229
|
+
kind: { type: 'bool' },
|
|
230
|
+
default: 'false',
|
|
231
|
+
doc: '`vx watch`: say on stderr each time the loop arms, with the projects it watches.',
|
|
232
|
+
config: false,
|
|
233
|
+
},
|
|
224
234
|
{
|
|
225
235
|
path: 'debug.dumpable',
|
|
226
236
|
kind: { type: 'bool' },
|
|
@@ -356,7 +366,8 @@ export function settingRefusal(
|
|
|
356
366
|
|
|
357
367
|
/** A setting's environment value, parsed; undefined when unset or empty. */
|
|
358
368
|
export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env): unknown {
|
|
359
|
-
const { kind } = setting(path)
|
|
369
|
+
const { kind, env: read } = setting(path)
|
|
370
|
+
if (read === false) return undefined
|
|
360
371
|
const name = envName(path)
|
|
361
372
|
const raw = env[name]
|
|
362
373
|
if (raw === undefined || raw === '') return undefined
|
|
@@ -13,6 +13,11 @@ const RESET = '\x1b[0m'
|
|
|
13
13
|
const BOLD = '\x1b[1m'
|
|
14
14
|
const DIM = '\x1b[2m'
|
|
15
15
|
|
|
16
|
+
// State hues every report shares with the run summary.
|
|
17
|
+
export const SUCCESS = '#22c55e'
|
|
18
|
+
export const WARN = '#eab308'
|
|
19
|
+
export const ERROR = '#ef4444'
|
|
20
|
+
|
|
16
21
|
export interface ColorSupport {
|
|
17
22
|
enabled: boolean
|
|
18
23
|
}
|
package/src/util/config-frame.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
import { readFileSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
|
|
10
|
-
// `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field
|
|
11
|
-
|
|
10
|
+
// `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`,
|
|
11
|
+
// from a project's vx.config or the workspace's .vx/workspace.ts.
|
|
12
|
+
const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* The message with its file named at `line:col`, relative to `cwd`, and a
|
|
@@ -88,3 +89,31 @@ function locate(
|
|
|
88
89
|
function escapeRe(s: string): string {
|
|
89
90
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
90
91
|
}
|
|
92
|
+
|
|
93
|
+
/** A config refusal laid out for `ui.notice`: where, what, the allowed set, the fix, the frame. */
|
|
94
|
+
export interface ConfigRefusal {
|
|
95
|
+
where: string
|
|
96
|
+
problem: string
|
|
97
|
+
allowed?: string
|
|
98
|
+
fix?: string
|
|
99
|
+
frame: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `configErrorFrame`'s answer in parts, or undefined when it has none. */
|
|
103
|
+
export function configRefusal(message: string, cwd: string): ConfigRefusal | undefined {
|
|
104
|
+
const framed = configErrorFrame(message, cwd)
|
|
105
|
+
if (framed === undefined) return undefined
|
|
106
|
+
const cut = framed.indexOf('\n\n')
|
|
107
|
+
const line = framed.slice(0, cut)
|
|
108
|
+
const frame = framed.slice(cut + 2)
|
|
109
|
+
const at = /^(\S+?:\d+:\d+):? (.*)$/s.exec(line)
|
|
110
|
+
if (at === null) return { where: line, problem: '', frame }
|
|
111
|
+
const said = /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — did you mean ([^?]+)\?)?$/s.exec(at[2]!)!
|
|
112
|
+
return {
|
|
113
|
+
where: at[1]!,
|
|
114
|
+
problem: said[1]!.replace(/^has /, ''),
|
|
115
|
+
...(said[2] !== undefined ? { allowed: said[2] } : {}),
|
|
116
|
+
...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
|
|
117
|
+
frame,
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -13,6 +13,8 @@ export class UserError extends Error {
|
|
|
13
13
|
* does not. `VX_E_REFUSED` is any refusal without a code of its own.
|
|
14
14
|
*/
|
|
15
15
|
readonly code: string
|
|
16
|
+
/** The refusal laid out for a terminal (`refusal`); the message is the same in one line. */
|
|
17
|
+
parts?: RefusalParts
|
|
16
18
|
// Two signatures, not an optional parameter: the package-api record
|
|
17
19
|
// keeps the one-argument form every plugin already calls.
|
|
18
20
|
constructor(message: string)
|
|
@@ -24,6 +26,36 @@ export class UserError extends Error {
|
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
/** A refusal in parts, as `ui.notice` draws it: one fact a line, why, and the fix. */
|
|
30
|
+
export interface RefusalParts {
|
|
31
|
+
/** `info`: nothing is wrong, there is just nothing to show yet. Default `error`. */
|
|
32
|
+
readonly tone?: 'info'
|
|
33
|
+
readonly headline: string
|
|
34
|
+
readonly what?: readonly string[]
|
|
35
|
+
readonly why?: string
|
|
36
|
+
readonly fix?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A UserError whose terminal form is `parts`. Its message, what JSON and
|
|
41
|
+
* logs carry, is `message` when given, else the parts in one line.
|
|
42
|
+
*/
|
|
43
|
+
export function refusal(
|
|
44
|
+
parts: RefusalParts,
|
|
45
|
+
opts: { code?: string; message?: string } = {},
|
|
46
|
+
): UserError {
|
|
47
|
+
const message =
|
|
48
|
+
opts.message ??
|
|
49
|
+
[
|
|
50
|
+
[parts.headline, ...(parts.what ?? [])].join(': '),
|
|
51
|
+
...(parts.why !== undefined ? [parts.why] : []),
|
|
52
|
+
...(parts.fix !== undefined ? [parts.fix] : []),
|
|
53
|
+
].join('; ')
|
|
54
|
+
const err = new UserError(message, opts.code ?? 'VX_E_REFUSED')
|
|
55
|
+
err.parts = parts
|
|
56
|
+
return err
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
// A refusal that opens with the config that caused it: the schema's
|
|
28
60
|
// `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
|
|
29
61
|
const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
|
package/src/util/index.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
isPermissionError,
|
|
14
14
|
isUserError,
|
|
15
15
|
PERMISSION_HINT,
|
|
16
|
+
refusal,
|
|
17
|
+
type RefusalParts,
|
|
16
18
|
UserError,
|
|
17
19
|
gitSpawnRefusal,
|
|
18
20
|
isExecutableMissing,
|
|
@@ -21,7 +23,12 @@ export {
|
|
|
21
23
|
OUT_OF_FDS_HINT,
|
|
22
24
|
wantsJson,
|
|
23
25
|
} from './errors.js'
|
|
24
|
-
export { configErrorFrame } from './config-frame.js'
|
|
26
|
+
export { configErrorFrame, configRefusal } from './config-frame.js'
|
|
27
|
+
export { detectColors, ERROR, paint, SUCCESS, WARN, type ColorSupport } from './colors.js'
|
|
28
|
+
export { gradientRule } from './report.js'
|
|
29
|
+
import * as ui from './ui.js'
|
|
30
|
+
export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
|
|
31
|
+
export { ui }
|
|
25
32
|
export { xxh3, xxh3hex } from './hash.js'
|
|
26
33
|
export {
|
|
27
34
|
beginRun,
|