@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/report/summary.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { TaskOutcome } from '../graph/index.js'
|
|
7
7
|
import { tallyOutcomes } from './tally.js'
|
|
8
8
|
import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
|
|
9
|
-
import { isGroupTask } from '../graph/index.js'
|
|
9
|
+
import { isGroupTask, isKeyFailed, markKeyFailed } from '../graph/index.js'
|
|
10
10
|
import { formatBytes, formatElapsed } from '../util/index.js'
|
|
11
11
|
import * as ui from '../ui/index.js'
|
|
12
12
|
import {
|
|
@@ -62,12 +62,17 @@ export interface SummaryStats {
|
|
|
62
62
|
restoredRemote: number
|
|
63
63
|
/** Ran here and saved to the cache: the legend's `↑`. */
|
|
64
64
|
saved?: number
|
|
65
|
-
/** Ran here with
|
|
65
|
+
/** Ran here with `cache: true`, succeeded, and saved nothing. */
|
|
66
66
|
notSaved?: number
|
|
67
|
-
/** Tasks that executed (success + failed) with
|
|
67
|
+
/** Tasks that executed (success + failed) with `cache: true` — the misses. */
|
|
68
68
|
miss: number
|
|
69
|
-
/** Executed
|
|
69
|
+
/** Executed without `cache: true`: never consulted the cache, not a miss. */
|
|
70
70
|
noCache?: number
|
|
71
|
+
/**
|
|
72
|
+
* Failed hits whose restore threw: in no cache count, neither a miss nor
|
|
73
|
+
* a hit. They fill the cache bar as `failed` colours the tasks bar.
|
|
74
|
+
*/
|
|
75
|
+
restoreFailed?: number
|
|
71
76
|
/** Still to run (live section only; 0 in the final summary) — renders
|
|
72
77
|
* as a gray ▱ remainder so the live meters FILL toward the final. */
|
|
73
78
|
left?: number
|
|
@@ -114,6 +119,8 @@ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
|
114
119
|
? 'notSaved'
|
|
115
120
|
: 'miss'
|
|
116
121
|
case 'failed':
|
|
122
|
+
// A hit whose restore failed is in no bucket: not a miss, not a hit.
|
|
123
|
+
if (o.restoreFailed === true) return undefined
|
|
117
124
|
return ranNoCache(o) ? 'noCache' : 'miss'
|
|
118
125
|
case 'cache-hit':
|
|
119
126
|
return o.restored === false ? 'upToDate' : 'restoredLocal'
|
|
@@ -237,6 +244,7 @@ export function formatSummarySection(
|
|
|
237
244
|
const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
|
|
238
245
|
const miss = stats.miss
|
|
239
246
|
const noCache = stats.noCache ?? 0
|
|
247
|
+
const restoreFailed = stats.restoreFailed ?? 0
|
|
240
248
|
const left = stats.left ?? 0
|
|
241
249
|
const aborted = stats.aborted ?? 0
|
|
242
250
|
const notRun = stats.notRun ?? 0
|
|
@@ -264,7 +272,7 @@ export function formatSummarySection(
|
|
|
264
272
|
if (stats.skipped > 0) taskParts.push(paint('warn', `${stats.skipped} skipped`, colors))
|
|
265
273
|
if (aborted > 0) taskParts.push(paint('aborted', `${aborted} aborted`, colors))
|
|
266
274
|
if (notRun > 0) taskParts.push(paint('notRun', `${notRun} not run`, colors))
|
|
267
|
-
if (running > 0) taskParts.push(paint('
|
|
275
|
+
if (running > 0) taskParts.push(paint('warn', `${running} running`, colors))
|
|
268
276
|
if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
|
|
269
277
|
if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
|
|
270
278
|
// What is not done yet, running included: the count the bar's unfilled part shows.
|
|
@@ -277,7 +285,7 @@ export function formatSummarySection(
|
|
|
277
285
|
{ n: stats.skipped, color: 'warn' },
|
|
278
286
|
{ n: aborted, color: 'aborted' },
|
|
279
287
|
{ n: notRun, color: 'notRun' },
|
|
280
|
-
{ n: running, color: '
|
|
288
|
+
{ n: running, color: 'warn' },
|
|
281
289
|
{ n: scheduled, color: 'muted', dim: true },
|
|
282
290
|
{ n: blocked, color: 'muted', dim: true },
|
|
283
291
|
remainder,
|
|
@@ -329,7 +337,7 @@ export function formatSummarySection(
|
|
|
329
337
|
|
|
330
338
|
// Cache meter + numbers: per layer when the run context names its
|
|
331
339
|
// layers, else miss · no-cache · up-to-date · local · remote.
|
|
332
|
-
if (miss + noCache + hits + left + stats.skipped > 0) {
|
|
340
|
+
if (miss + noCache + hits + left + stats.skipped + restoreFailed > 0) {
|
|
333
341
|
const layers = context?.cache
|
|
334
342
|
const b = cacheBuckets(stats, layers)
|
|
335
343
|
const cacheBar = segmentBar(
|
|
@@ -344,6 +352,7 @@ export function formatSummarySection(
|
|
|
344
352
|
{ n: b.uploaded, color: 'remote' },
|
|
345
353
|
{ n: b.uploading, color: 'info' },
|
|
346
354
|
{ n: b.remoteFailed, color: 'error' },
|
|
355
|
+
{ n: restoreFailed, color: 'error' },
|
|
347
356
|
{ n: stats.skipped, color: 'warn' },
|
|
348
357
|
remainder,
|
|
349
358
|
],
|
|
@@ -395,9 +404,9 @@ export function formatSummarySection(
|
|
|
395
404
|
if (stats.restoredRemote > 0)
|
|
396
405
|
cacheParts.push(paint('remote', `${stats.restoredRemote} remote`, colors))
|
|
397
406
|
}
|
|
398
|
-
// Skipped tasks ride the bar, yellow like the tasks meter,
|
|
399
|
-
// bars fill alike; their
|
|
400
|
-
// never says a fact twice, 2026-10-14).
|
|
407
|
+
// Skipped tasks ride the bar, yellow like the tasks meter, and failed
|
|
408
|
+
// restores red, so the two bars fill alike; their counts are the tasks
|
|
409
|
+
// legend's alone (the footer never says a fact twice, 2026-10-14).
|
|
401
410
|
lines.push(row('cache', cacheBar))
|
|
402
411
|
if (cacheParts.length > 0) lines.push(legend(cacheParts))
|
|
403
412
|
}
|
|
@@ -478,7 +487,9 @@ export function formatSummarySection(
|
|
|
478
487
|
}
|
|
479
488
|
// The Summary panel; the final one names the vx that ran it (the live
|
|
480
489
|
// one has no context yet).
|
|
490
|
+
// Two blank lines: the Summary stands apart from the task list above it.
|
|
481
491
|
return [
|
|
492
|
+
'',
|
|
482
493
|
'',
|
|
483
494
|
panelTop(
|
|
484
495
|
'Summary',
|
|
@@ -539,7 +550,9 @@ export function formatOutcomeTable(
|
|
|
539
550
|
.filter((o) => !isGroupTask(o.node))
|
|
540
551
|
.map((o) => {
|
|
541
552
|
const v = projectOutcome(o)
|
|
542
|
-
const status = neverStarted(o)
|
|
553
|
+
const status = neverStarted(o)
|
|
554
|
+
? 'not run'
|
|
555
|
+
: outcomeLabel(isKeyFailed(o) ? markKeyFailed(v) : v)
|
|
543
556
|
return { o, task: v.taskId, status, duration: `${v.durationMs}ms` }
|
|
544
557
|
})
|
|
545
558
|
if (rows.length === 0) return []
|
|
@@ -582,9 +595,19 @@ function tableStatusColor(o: TaskOutcome): Style {
|
|
|
582
595
|
|
|
583
596
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
584
597
|
// (module boundary), and the footer only needs one compact form.
|
|
585
|
-
/**
|
|
598
|
+
/**
|
|
599
|
+
* Ran here with `cache: true`, succeeded, and its result was not saved.
|
|
600
|
+
* Outputs left in the executor's store (`outputs: 'deferred'`) ran
|
|
601
|
+
* elsewhere, whether or not the executor names a `where`.
|
|
602
|
+
*/
|
|
586
603
|
export function ranUnsaved(o: TaskOutcome): boolean {
|
|
587
|
-
return
|
|
604
|
+
return (
|
|
605
|
+
o.status === 'success' &&
|
|
606
|
+
!ranNoCache(o) &&
|
|
607
|
+
o.saved !== true &&
|
|
608
|
+
o.where === undefined &&
|
|
609
|
+
o.outputs !== 'deferred'
|
|
610
|
+
)
|
|
588
611
|
}
|
|
589
612
|
|
|
590
613
|
export function formatRunSummary(
|
|
@@ -593,17 +616,29 @@ export function formatRunSummary(
|
|
|
593
616
|
colors: ColorSupport = NO_COLOR,
|
|
594
617
|
context?: RunContext,
|
|
595
618
|
): string[] {
|
|
596
|
-
// Counted here, not in the Tally: a task
|
|
619
|
+
// Counted here, not in the Tally: a task without `cache: true` never
|
|
597
620
|
// consulted the cache, so it is not a miss.
|
|
598
621
|
const noCache = outcomes.filter(
|
|
599
622
|
(o) =>
|
|
600
623
|
!isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
|
|
601
624
|
).length
|
|
625
|
+
const restoreFailed = outcomes.filter(
|
|
626
|
+
(o) => !isGroupTask(o.node) && o.status === 'failed' && o.restoreFailed === true,
|
|
627
|
+
).length
|
|
628
|
+
// A task with no key asked no cache and ran nothing: not a miss.
|
|
629
|
+
const keyFailed = outcomes.filter(
|
|
630
|
+
(o) => !isGroupTask(o.node) && o.status === 'failed' && isKeyFailed(o),
|
|
631
|
+
).length
|
|
602
632
|
const t = tallyOutcomes(outcomes)
|
|
603
633
|
// Spread over executed tasks only — `success`/`failed` statuses ran
|
|
604
|
-
// a command; cache-hit statuses replayed one.
|
|
634
|
+
// a command; cache-hit statuses replayed one, and a failed restore ran none.
|
|
605
635
|
const durations = outcomes
|
|
606
|
-
.filter(
|
|
636
|
+
.filter(
|
|
637
|
+
(o) =>
|
|
638
|
+
(o.status === 'success' ||
|
|
639
|
+
(o.status === 'failed' && o.restoreFailed !== true && !isKeyFailed(o))) &&
|
|
640
|
+
!isGroupTask(o.node),
|
|
641
|
+
)
|
|
607
642
|
.map((o) => o.durationMs)
|
|
608
643
|
const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
|
|
609
644
|
const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
|
|
@@ -623,8 +658,15 @@ export function formatRunSummary(
|
|
|
623
658
|
restoredRemote: t.restoredRemote,
|
|
624
659
|
saved: outcomes.filter((o) => o.saved === true && !isGroupTask(o.node)).length,
|
|
625
660
|
notSaved: outcomes.filter((o) => !isGroupTask(o.node) && ranUnsaved(o)).length,
|
|
626
|
-
miss:
|
|
661
|
+
miss:
|
|
662
|
+
t.total -
|
|
663
|
+
t.skipped -
|
|
664
|
+
(t.cachedLocal + t.cachedRemote) -
|
|
665
|
+
noCache -
|
|
666
|
+
restoreFailed -
|
|
667
|
+
keyFailed,
|
|
627
668
|
noCache,
|
|
669
|
+
...(restoreFailed > 0 ? { restoreFailed } : {}),
|
|
628
670
|
spread:
|
|
629
671
|
durations.length > 0
|
|
630
672
|
? {
|
|
@@ -643,7 +685,8 @@ export function formatRunSummary(
|
|
|
643
685
|
}
|
|
644
686
|
: {}),
|
|
645
687
|
...(emptyGroups.length > 0 ? { emptyGroups } : {}),
|
|
646
|
-
|
|
688
|
+
// Read only to move a task by its remote traffic.
|
|
689
|
+
...(context?.cache?.remote === true ? { buckets: bucketsOf(outcomes) } : {}),
|
|
647
690
|
},
|
|
648
691
|
totalMs,
|
|
649
692
|
colors,
|
package/src/settings.ts
CHANGED
|
@@ -118,7 +118,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
118
118
|
{
|
|
119
119
|
path: 'cache.dir',
|
|
120
120
|
kind: { type: 'string' },
|
|
121
|
-
default: '.vx/cache, entries in ~/.vx/<
|
|
121
|
+
default: '.vx/cache, entries in ~/.vx/<id>/cache',
|
|
122
122
|
doc: 'Where the cache lives, relative to the workspace root; named, it holds the whole cache.',
|
|
123
123
|
flag: { verb: 'run', name: '--cache-dir' },
|
|
124
124
|
},
|
|
@@ -165,25 +165,25 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
165
165
|
flag: { verb: 'run', name: '--cache-download' },
|
|
166
166
|
},
|
|
167
167
|
{
|
|
168
|
-
path: '
|
|
168
|
+
path: 'inputs.stability.strategy',
|
|
169
169
|
kind: { type: 'enum', values: ['immutable', 'mutable', 'unchecked'] },
|
|
170
170
|
default: 'immutable',
|
|
171
171
|
doc: 'What a write to an input during the command means for the save, for tasks without their own.',
|
|
172
|
-
task: '
|
|
172
|
+
task: 'inputs.stability.strategy',
|
|
173
173
|
},
|
|
174
174
|
{
|
|
175
|
-
path: '
|
|
175
|
+
path: 'inputs.stability.severity',
|
|
176
176
|
kind: { type: 'enum', values: ['warn', 'error'] },
|
|
177
177
|
default: 'warn',
|
|
178
178
|
doc: 'Whether a skipped save warns or fails the task, for tasks without their own.',
|
|
179
|
-
task: '
|
|
179
|
+
task: 'inputs.stability.severity',
|
|
180
180
|
},
|
|
181
181
|
{
|
|
182
|
-
path: '
|
|
182
|
+
path: 'outputs.clean',
|
|
183
183
|
kind: { type: 'bool' },
|
|
184
184
|
default: 'true',
|
|
185
185
|
doc: "Remove a task's declared outputs before it runs on a miss, for tasks without their own.",
|
|
186
|
-
task: '
|
|
186
|
+
task: 'outputs.clean',
|
|
187
187
|
env: false,
|
|
188
188
|
},
|
|
189
189
|
{
|
|
@@ -222,7 +222,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
222
222
|
path: 'config.workerTimeout',
|
|
223
223
|
kind: { type: 'duration', timer: true },
|
|
224
224
|
default: '30s',
|
|
225
|
-
doc: 'How long one config evaluation may
|
|
225
|
+
doc: 'How long one config evaluation may await; at 5 s vx names the config still evaluating. A synchronous loop on the first load holds the thread the deadline needs, so it hangs until killed.',
|
|
226
226
|
config: false,
|
|
227
227
|
},
|
|
228
228
|
{
|
|
@@ -249,7 +249,7 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
249
249
|
]
|
|
250
250
|
|
|
251
251
|
/** The `.vx/workspace` groups that hold settings; `baseBranch` and `plugins` are the other keys. */
|
|
252
|
-
export const SETTING_GROUPS = ['run', 'cache', 'lock', 'watch'] as const
|
|
252
|
+
export const SETTING_GROUPS = ['run', 'cache', 'inputs', 'outputs', 'lock', 'watch'] as const
|
|
253
253
|
|
|
254
254
|
const BY_PATH = new Map(SETTINGS.map((s) => [s.path, s]))
|
|
255
255
|
|
|
@@ -327,7 +327,9 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
|
|
|
327
327
|
}
|
|
328
328
|
case 'size':
|
|
329
329
|
if (kind.off === true && (raw === false || raw === 'false')) return 0
|
|
330
|
-
|
|
330
|
+
// A size carries its unit, as a duration does: a bare `32` read as
|
|
331
|
+
// bytes turns the store off without a word.
|
|
332
|
+
return typeof raw === 'string' && !/^\d+$/.test(raw) ? parseSize(raw) : null
|
|
331
333
|
case 'bool':
|
|
332
334
|
if (typeof raw === 'boolean') return raw
|
|
333
335
|
return raw === 'true' || raw === '1' ? true : raw === 'false' || raw === '0' ? false : null
|
|
@@ -362,7 +364,16 @@ export function settingRefusal(
|
|
|
362
364
|
// A bare number past the ceiling is past it in either unit it could mean.
|
|
363
365
|
const ms = bare ? n : typeof raw === 'string' ? parseDuration(raw) : null
|
|
364
366
|
if (bare && !(kind.timer === true && n > MAX_TIMEOUT_MS)) {
|
|
365
|
-
|
|
367
|
+
// Below a thousand the number most likely meant seconds (`--timeout
|
|
368
|
+
// 5`): that reading leads.
|
|
369
|
+
const like = n < 1000 ? `'${n}s' or '${n}ms'` : `'${n}ms' or '${Math.round(n / 1000)}s'`
|
|
370
|
+
return `${where} takes a duration with its unit, like ${like} (got ${got})`
|
|
371
|
+
}
|
|
372
|
+
// `'0s'` looks like the examples the generic refusal gives.
|
|
373
|
+
const negative = typeof raw === 'string' && raw.startsWith('-') && parseDuration(raw.slice(1))
|
|
374
|
+
if (ms === 0 || (negative !== false && negative !== null)) {
|
|
375
|
+
const none = kind.timer === true ? ' — omit it for no limit' : ''
|
|
376
|
+
return `${where} must be more than 0${none} (got ${got})`
|
|
366
377
|
}
|
|
367
378
|
if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
|
|
368
379
|
return (
|
|
@@ -372,6 +383,18 @@ export function settingRefusal(
|
|
|
372
383
|
)
|
|
373
384
|
}
|
|
374
385
|
}
|
|
386
|
+
const bytes =
|
|
387
|
+
typeof raw === 'number'
|
|
388
|
+
? raw
|
|
389
|
+
: typeof raw === 'string' && /^\d+$/.test(raw)
|
|
390
|
+
? Number(raw)
|
|
391
|
+
: null
|
|
392
|
+
if (kind.type === 'size' && bytes !== null && Number.isSafeInteger(bytes) && bytes >= 0) {
|
|
393
|
+
if (bytes === 0 && kind.off === true) return `${where} is false to keep none (got ${got})`
|
|
394
|
+
// A small number most likely meant KiB (`32`): that reading leads.
|
|
395
|
+
const like = bytes < 1024 ? `'${bytes}KiB' or '${bytes}B'` : `'${bytes}B'`
|
|
396
|
+
return `${where} takes a size with its unit, like ${like} (got ${got})`
|
|
397
|
+
}
|
|
375
398
|
return `${where} must be ${kindHint(kind)} (got ${got})`
|
|
376
399
|
}
|
|
377
400
|
|
|
@@ -387,9 +410,22 @@ export function settingEnv(path: string, env: NodeJS.ProcessEnv = process.env):
|
|
|
387
410
|
return v
|
|
388
411
|
}
|
|
389
412
|
|
|
390
|
-
/**
|
|
391
|
-
|
|
392
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Validate every set setting variable once, so a deep reader can trust what
|
|
415
|
+
* it reads. One whose flag `args` holds (before a `--`) is left alone: the
|
|
416
|
+
* flag beats it, so nothing reads it.
|
|
417
|
+
*/
|
|
418
|
+
export function checkSettingsEnv(
|
|
419
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
420
|
+
args: readonly string[] = [],
|
|
421
|
+
): void {
|
|
422
|
+
const end = args.indexOf('--')
|
|
423
|
+
const given = end === -1 ? args : args.slice(0, end)
|
|
424
|
+
for (const s of SETTINGS) {
|
|
425
|
+
const flag = s.flag?.name
|
|
426
|
+
if (flag !== undefined && given.some((a) => a === flag || a.startsWith(`${flag}=`))) continue
|
|
427
|
+
settingEnv(s.path, env)
|
|
428
|
+
}
|
|
393
429
|
}
|
|
394
430
|
|
|
395
431
|
/** Where a resolved setting came from. */
|
package/src/ui/components.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import {
|
|
7
7
|
barWidth,
|
|
8
8
|
identityHue,
|
|
9
|
+
GUTTER,
|
|
9
10
|
INDENT,
|
|
10
11
|
LABEL_MIN,
|
|
11
12
|
paint,
|
|
@@ -13,6 +14,8 @@ import {
|
|
|
13
14
|
RESET,
|
|
14
15
|
ruleWidth,
|
|
15
16
|
SYMBOL,
|
|
17
|
+
TABLE_WIDTH,
|
|
18
|
+
underlined,
|
|
16
19
|
type ColorSupport,
|
|
17
20
|
type Style,
|
|
18
21
|
type Tone,
|
|
@@ -31,6 +34,8 @@ export type Inline = string | Token | readonly Inline[]
|
|
|
31
34
|
/** A laid-out piece of output; `render` and `print` space blocks apart. */
|
|
32
35
|
export interface Block {
|
|
33
36
|
readonly lines: (colors: ColorSupport) => string[]
|
|
37
|
+
/** A grid's column header: it rides its section's title line, past the gutter. */
|
|
38
|
+
readonly head?: (colors: ColorSupport) => string
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
/** A `project` or `project#task` id, each half in its name's hue, the `#` dim. */
|
|
@@ -120,24 +125,6 @@ function prose(text: string): Inline {
|
|
|
120
125
|
.map((p, i) => (i % 2 === 1 ? cmd(p) : p === '' ? p : { style: 'value' as const, text: p }))
|
|
121
126
|
}
|
|
122
127
|
|
|
123
|
-
/**
|
|
124
|
-
* A panel's top off colour: `─ Title count ──── note ─`, flush with the
|
|
125
|
-
* bars. The same characters a pipe, a test and `grep` read.
|
|
126
|
-
*/
|
|
127
|
-
function ruleLine(
|
|
128
|
-
title: string,
|
|
129
|
-
count: Inline | undefined,
|
|
130
|
-
c: ColorSupport,
|
|
131
|
-
note?: Inline,
|
|
132
|
-
): string {
|
|
133
|
-
const plain = { enabled: false }
|
|
134
|
-
const head = `─ ${title}${count === undefined ? '' : ` ${inline(count, plain)}`} `
|
|
135
|
-
if (note === undefined)
|
|
136
|
-
return `${head}${'─'.repeat(Math.max(8, ruleWidth(c) - Bun.stringWidth(head)))}`
|
|
137
|
-
const tail = ` ${inline(note, plain)} ─`
|
|
138
|
-
return `${head}${'─'.repeat(Math.max(3, ruleWidth(c) - Bun.stringWidth(head) - Bun.stringWidth(tail)))}${tail}`
|
|
139
|
-
}
|
|
140
|
-
|
|
141
128
|
// ── Panels ──────────────────────────────────────────────────────────
|
|
142
129
|
// vx's screens look like a TUI and are not one: each part is a box drawn
|
|
143
130
|
// line by line into the scrollback, so a pipe, a CI log and a terminal all
|
|
@@ -212,33 +199,40 @@ export function wrap(line: string, w: number): string[] {
|
|
|
212
199
|
return rows
|
|
213
200
|
}
|
|
214
201
|
|
|
215
|
-
|
|
202
|
+
/** A title line's width: the terminal's, so a task's name sits over it too; never under the bars'. */
|
|
203
|
+
const titleWidth = (c: ColorSupport): number => Math.max(ruleWidth(c), c.columns ?? TABLE_WIDTH)
|
|
216
204
|
|
|
217
205
|
/**
|
|
218
|
-
* A
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
206
|
+
* A section's title line: the title in the gutter, bold in the brand hue,
|
|
207
|
+
* a grid's column header past it (`head`), a note flush right, the whole
|
|
208
|
+
* line underlined in the rule's grey. No rule characters: off colour it is
|
|
209
|
+
* the same words, unmarked.
|
|
222
210
|
*/
|
|
223
|
-
export function panelTop(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
return (
|
|
233
|
-
border('─ ', c) +
|
|
211
|
+
export function panelTop(
|
|
212
|
+
title: string,
|
|
213
|
+
c: ColorSupport,
|
|
214
|
+
count?: Inline,
|
|
215
|
+
note?: Inline,
|
|
216
|
+
head?: string,
|
|
217
|
+
): string {
|
|
218
|
+
let line =
|
|
219
|
+
INDENT +
|
|
234
220
|
paint('heading', title, c) +
|
|
235
|
-
(count === undefined ? '' : ` ${inline(typeof count === 'string' ? dim(count) : count, c)}`)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
221
|
+
(count === undefined ? '' : ` ${inline(typeof count === 'string' ? dim(count) : count, c)}`)
|
|
222
|
+
if (head !== undefined) line += ' '.repeat(Math.max(1, GUTTER - Bun.stringWidth(line))) + head
|
|
223
|
+
const tail = note === undefined ? '' : inline(typeof note === 'string' ? dim(note) : note, c)
|
|
224
|
+
const used = Bun.stringWidth(line) + Bun.stringWidth(tail)
|
|
225
|
+
line += ' '.repeat(Math.max(tail === '' ? 0 : 2, titleWidth(c) - used)) + tail
|
|
226
|
+
return underlined(line, c)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A list's divider: the underline alone, from the grid's first column to the
|
|
231
|
+
* title lines' end (off colour, a blank line).
|
|
232
|
+
*/
|
|
233
|
+
export function divider(c: ColorSupport): string {
|
|
234
|
+
if (!c.enabled) return ''
|
|
235
|
+
return ' '.repeat(GUTTER) + underlined(' '.repeat(titleWidth(c) - GUTTER), c)
|
|
242
236
|
}
|
|
243
237
|
|
|
244
238
|
/**
|
|
@@ -253,7 +247,7 @@ export function section(title: string, ...rest: (Block | Inline)[]): Block {
|
|
|
253
247
|
const body = (counted ? rest.slice(1) : rest) as Block[]
|
|
254
248
|
return block((c) => {
|
|
255
249
|
const inner = body.flatMap((b, i) => (i === 0 ? b.lines(c) : ['', ...b.lines(c)]))
|
|
256
|
-
return [panelTop(title, c, count), ...inner]
|
|
250
|
+
return [panelTop(title, c, count, undefined, body[0]?.head?.(c)), ...inner]
|
|
257
251
|
})
|
|
258
252
|
}
|
|
259
253
|
|
package/src/ui/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { ui }
|
|
|
9
9
|
|
|
10
10
|
export {
|
|
11
11
|
bar,
|
|
12
|
+
divider,
|
|
12
13
|
barWidth,
|
|
13
14
|
cmd,
|
|
14
15
|
code,
|
|
@@ -50,6 +51,7 @@ export {
|
|
|
50
51
|
export {
|
|
51
52
|
detectColors,
|
|
52
53
|
dumbTerminal,
|
|
54
|
+
GUTTER,
|
|
53
55
|
INDENT,
|
|
54
56
|
paint,
|
|
55
57
|
ruleWidth,
|
|
@@ -58,7 +60,7 @@ export {
|
|
|
58
60
|
type Style,
|
|
59
61
|
type Tone,
|
|
60
62
|
} from './theme.js'
|
|
61
|
-
export { colorsOf, data, print, say, transient, type StreamName } from './terminal.js'
|
|
63
|
+
export { colorsOf, data, dataPieces, print, say, transient, type StreamName } from './terminal.js'
|
|
62
64
|
export { endStyle, hasEscape, lastRedraw, leavesStyle, STYLE_END } from './ansi.js'
|
|
63
65
|
export {
|
|
64
66
|
createOutputWriter,
|
package/src/ui/live-region.ts
CHANGED
|
@@ -75,11 +75,38 @@ export interface OutputWriterOptions {
|
|
|
75
75
|
now?: () => number
|
|
76
76
|
/**
|
|
77
77
|
* Off a TTY, hold writes and hand them to the stream once per turn of
|
|
78
|
-
* the event loop until `settle()`. A warm
|
|
79
|
-
* its 952 task lines as its own syscall
|
|
80
|
-
* caller reading its stream right after a
|
|
78
|
+
* the event loop, or once 64 KiB are held, until `settle()`. A warm
|
|
79
|
+
* 476-package run wrote each of its 952 task lines as its own syscall
|
|
80
|
+
* (item 753). Off by default: a caller reading its stream right after a
|
|
81
|
+
* write sees it.
|
|
81
82
|
*/
|
|
82
83
|
coalesce?: boolean
|
|
84
|
+
/** Floor between two coalesced writes; 0 writes once per turn. */
|
|
85
|
+
coalesceMs?: number
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** What `coalesce` holds before it writes without waiting for the turn's end. */
|
|
89
|
+
const HELD_MAX_CHARS = 64 * 1024
|
|
90
|
+
|
|
91
|
+
/** The longest piece of a string handed to the stream in one write. */
|
|
92
|
+
const WRITE_SLICE_CHARS = 1024 * 1024
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `text` in slices: a stream encodes a string whole before it writes, and
|
|
96
|
+
* a frame's furniture makes its text two bytes a character, so one 100 MB
|
|
97
|
+
* frame took ~300 MB more to write than to build.
|
|
98
|
+
*/
|
|
99
|
+
function writeSliced(stream: StatusStream, text: string): void {
|
|
100
|
+
let at = 0
|
|
101
|
+
while (text.length - at > WRITE_SLICE_CHARS) {
|
|
102
|
+
let end = at + WRITE_SLICE_CHARS
|
|
103
|
+
// Never between the halves of a surrogate pair.
|
|
104
|
+
const c = text.charCodeAt(end - 1)
|
|
105
|
+
if (c >= 0xd800 && c <= 0xdbff) end--
|
|
106
|
+
stream.write(text.slice(at, end))
|
|
107
|
+
at = end
|
|
108
|
+
}
|
|
109
|
+
stream.write(at === 0 ? text : text.slice(at))
|
|
83
110
|
}
|
|
84
111
|
|
|
85
112
|
export function createOutputWriter(
|
|
@@ -90,16 +117,29 @@ export function createOutputWriter(
|
|
|
90
117
|
const minRedrawMs = opts.minRedrawMs ?? 100
|
|
91
118
|
const forceFloorMs = opts.forceFloorMs ?? 30
|
|
92
119
|
const now = opts.now ?? Date.now
|
|
120
|
+
const coalesceMs = opts.coalesceMs ?? 25
|
|
93
121
|
// The coalesced writes not yet handed over; null once settled or when
|
|
94
122
|
// not coalescing.
|
|
95
123
|
let held: string[] | null = !enabled && opts.coalesce === true ? [] : null
|
|
124
|
+
let heldChars = 0
|
|
96
125
|
let flushQueued = false
|
|
97
|
-
|
|
98
|
-
|
|
126
|
+
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
127
|
+
let lastFlush = -Infinity
|
|
128
|
+
const writeHeld = (): void => {
|
|
99
129
|
if (held === null || held.length === 0) return
|
|
100
130
|
const text = held.join('')
|
|
101
131
|
held.length = 0
|
|
102
|
-
|
|
132
|
+
heldChars = 0
|
|
133
|
+
lastFlush = now()
|
|
134
|
+
writeSliced(stream, text)
|
|
135
|
+
}
|
|
136
|
+
const flushHeld = (): void => {
|
|
137
|
+
flushQueued = false
|
|
138
|
+
if (flushTimer !== null) {
|
|
139
|
+
clearTimeout(flushTimer)
|
|
140
|
+
flushTimer = null
|
|
141
|
+
}
|
|
142
|
+
writeHeld()
|
|
103
143
|
}
|
|
104
144
|
|
|
105
145
|
let current: Region | null = null
|
|
@@ -276,13 +316,32 @@ export function createOutputWriter(
|
|
|
276
316
|
write(chunk) {
|
|
277
317
|
if (!enabled) {
|
|
278
318
|
if (held === null) {
|
|
279
|
-
stream
|
|
319
|
+
writeSliced(stream, chunk)
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
// A big write goes as it is: joined with what is held, it was copied.
|
|
323
|
+
if (chunk.length >= HELD_MAX_CHARS) {
|
|
324
|
+
writeHeld()
|
|
325
|
+
writeSliced(stream, chunk)
|
|
280
326
|
return
|
|
281
327
|
}
|
|
282
328
|
held.push(chunk)
|
|
329
|
+
heldChars += chunk.length
|
|
330
|
+
// A warm run replays its hits without yielding to the event loop,
|
|
331
|
+
// so the turn's end came only after every hit's log: 500 hits
|
|
332
|
+
// printing 1 MiB each were held at once (2 GB of heap).
|
|
333
|
+
if (heldChars >= HELD_MAX_CHARS) {
|
|
334
|
+
writeHeld()
|
|
335
|
+
return
|
|
336
|
+
}
|
|
283
337
|
if (!flushQueued) {
|
|
284
338
|
flushQueued = true
|
|
285
|
-
|
|
339
|
+
// At most one write per floor: a restore settles its hits over
|
|
340
|
+
// hundreds of turns, and a write per turn cost the 9,603-task
|
|
341
|
+
// restore ~100 ms of its main thread (2026-11-07).
|
|
342
|
+
const wait = lastFlush + coalesceMs - now()
|
|
343
|
+
if (wait > 0) flushTimer = setTimeout(flushHeld, wait)
|
|
344
|
+
else setImmediate(flushHeld)
|
|
286
345
|
}
|
|
287
346
|
return
|
|
288
347
|
}
|
package/src/ui/terminal.ts
CHANGED
|
@@ -31,6 +31,18 @@ export function data(stream: StreamName, bytes: string): void {
|
|
|
31
31
|
streamOf(stream).write(bytes)
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* `data` for bytes too many for one string, written piece by piece. A
|
|
36
|
+
* pipe that reads slower than vx writes is waited on: unwaited, every
|
|
37
|
+
* piece queues in memory until the reader catches up.
|
|
38
|
+
*/
|
|
39
|
+
export async function dataPieces(stream: StreamName, pieces: Iterable<string>): Promise<void> {
|
|
40
|
+
const s = streamOf(stream)
|
|
41
|
+
for (const piece of pieces) {
|
|
42
|
+
if (!s.write(piece)) await new Promise((resolve) => s.once('drain', resolve))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
34
46
|
/** Whether a stream paints. */
|
|
35
47
|
export function colorsOf(stream: StreamName): ReturnType<typeof detectColors> {
|
|
36
48
|
return detectColors(streamOf(stream))
|
package/src/ui/theme.ts
CHANGED
|
@@ -77,7 +77,11 @@ export const INDENT = ' '
|
|
|
77
77
|
export const LABEL_MIN = 8
|
|
78
78
|
/** A meter's widest, and the label column beside it (2 indent + 8 label + 2 gap). */
|
|
79
79
|
const BAR_MAX = 50
|
|
80
|
-
|
|
80
|
+
/** The gutter a section's title sits in: a field's label column, a grid row's lead. */
|
|
81
|
+
export const GUTTER = 12
|
|
82
|
+
/** A title line's width when the terminal's is unknown (a pipe, a CI log): past a task's name. */
|
|
83
|
+
export const TABLE_WIDTH = 120
|
|
84
|
+
const BAR_LABEL = GUTTER
|
|
81
85
|
|
|
82
86
|
/**
|
|
83
87
|
* Identity hues for the two halves of a `project#task` id, never a status
|
|
@@ -194,6 +198,17 @@ export function paintHex(
|
|
|
194
198
|
return prefix === '' ? text : `${prefix}${text}${RESET}`
|
|
195
199
|
}
|
|
196
200
|
|
|
201
|
+
// The underline in the rule's grey (SGR 58): a terminal without it underlines in the text's colour.
|
|
202
|
+
const UNDER_RULE = `${UNDERLINE}\x1b[58;2;110;118;129m`
|
|
203
|
+
|
|
204
|
+
/** A title line underlined end to end, gaps included, each hue kept. */
|
|
205
|
+
export function underlined(line: string, c: ColorSupport): string {
|
|
206
|
+
if (!c.enabled) return line.trimEnd()
|
|
207
|
+
// A trailing reset is the line's own end: re-opening the underline there left an empty pair.
|
|
208
|
+
const body = line.endsWith(RESET) ? line.slice(0, -RESET.length) : line
|
|
209
|
+
return `${UNDER_RULE}${body.replaceAll(RESET, RESET + UNDER_RULE)}${RESET}`
|
|
210
|
+
}
|
|
211
|
+
|
|
197
212
|
/** `text` in a style; `bold` adds weight to a hue. */
|
|
198
213
|
export function paint(style: Style, text: string, c: ColorSupport, bold = false): string {
|
|
199
214
|
switch (style) {
|