@vzn/vx 0.0.636 → 0.0.637
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 +39 -11
- package/docs/caching.md +213 -207
- package/docs/cli.md +699 -655
- package/docs/execution.md +67 -75
- package/docs/features.md +44 -40
- package/docs/patterns.md +26 -25
- package/docs/schema.md +376 -198
- package/docs/security.md +76 -39
- package/launcher.sh +43 -0
- package/package.json +5 -5
- package/schemas/cache.json +41 -19
- package/schemas/info.json +23 -11
- package/schemas/last.json +48 -2
- package/schemas/lock.json +1 -1
- package/schemas/plan.json +2 -2
- package/schemas/show.json +5 -3
- package/schemas/why.json +15 -1
- package/skills/vx/SKILL.md +16 -12
- package/src/bin.ts +17 -2
- package/src/cache/archive.ts +11 -0
- package/src/cache/cache.ts +105 -53
- package/src/cache/git-inputs.ts +277 -63
- package/src/cache/index.ts +7 -1
- package/src/cache/inputs.ts +125 -35
- package/src/cache/key-fold.ts +8 -7
- package/src/cache/layer.ts +8 -17
- package/src/cache/layered-cache.ts +16 -102
- package/src/cache/policy.ts +25 -29
- package/src/cache/run-history.ts +36 -14
- package/src/cache/schema.ts +21 -23
- package/src/cache/scoped-cache.ts +224 -0
- package/src/cli/cache.ts +150 -28
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +1 -1
- package/src/cli/foreign-flags.ts +13 -12
- package/src/cli/help.ts +94 -63
- package/src/cli/index.ts +42 -13
- package/src/cli/info.ts +38 -23
- package/src/cli/init.ts +115 -406
- package/src/cli/last.ts +145 -4
- package/src/cli/lock.ts +333 -111
- package/src/cli/plan-format.ts +2 -2
- package/src/cli/plugin-commands.ts +5 -9
- package/src/cli/plugin-templates.ts +10 -10
- package/src/cli/run.ts +103 -149
- package/src/cli/select.ts +33 -39
- package/src/cli/show.ts +17 -19
- package/src/cli/task-verb.ts +2 -9
- package/src/cli/upgrade.ts +402 -145
- package/src/cli/watch-cycle.ts +9 -2
- package/src/cli/watch-filter.ts +33 -60
- package/src/cli/watch-fs.ts +29 -16
- package/src/cli/watch-judge.ts +12 -3
- package/src/cli/watch-set.ts +16 -42
- package/src/cli/watch.ts +188 -215
- package/src/cli/why.ts +28 -17
- package/src/cli/workspace-config.ts +31 -68
- package/src/config.ts +169 -70
- package/src/exec/env.ts +1 -31
- package/src/exec/executor.ts +1 -5
- package/src/exec/kill-tree.ts +2 -1
- package/src/exec/sandbox-runtime.ts +12 -1
- package/src/exec/sandbox-violations.ts +9 -1
- package/src/graph/dependency-spec.ts +1 -1
- package/src/graph/index.ts +1 -0
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +19 -7
- package/src/graph/task-graph.ts +44 -20
- package/src/handoff.ts +169 -0
- package/src/index.ts +24 -57
- package/src/orchestrator/affected-tasks.ts +7 -5
- package/src/orchestrator/command-windows.ts +44 -0
- package/src/orchestrator/deferred-outputs.ts +12 -10
- package/src/orchestrator/doctor.ts +98 -110
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/events.ts +19 -3
- package/src/orchestrator/excluded-keys.ts +0 -2
- package/src/orchestrator/execute-task.ts +158 -63
- package/src/orchestrator/fingerprint-watch.ts +18 -15
- package/src/orchestrator/forecast.ts +10 -2
- package/src/orchestrator/framed-output.ts +8 -3
- package/src/orchestrator/graph-stage.ts +5 -5
- package/src/orchestrator/history.ts +70 -30
- package/src/orchestrator/hit-restore.ts +15 -3
- package/src/orchestrator/index.ts +18 -4
- package/src/orchestrator/local-shortcircuit.ts +2 -1
- package/src/orchestrator/lockfile-claim.ts +79 -20
- package/src/orchestrator/logger.ts +71 -39
- package/src/orchestrator/metrics.ts +71 -6
- package/src/orchestrator/miss-save.ts +3 -0
- package/src/orchestrator/options.ts +42 -29
- package/src/orchestrator/output-host.ts +72 -0
- package/src/orchestrator/plan.ts +6 -8
- package/src/orchestrator/plugin-host.ts +87 -63
- package/src/orchestrator/plugin.ts +94 -60
- package/src/orchestrator/prepare.ts +113 -72
- package/src/orchestrator/projects.ts +102 -99
- package/src/orchestrator/remote-prefetch.ts +2 -1
- package/src/orchestrator/run-context.ts +25 -6
- package/src/orchestrator/run-failures.ts +17 -8
- package/src/orchestrator/run-lock.ts +1 -1
- package/src/orchestrator/run-report.ts +9 -9
- package/src/orchestrator/run-settings.ts +48 -0
- package/src/orchestrator/run.ts +121 -95
- package/src/orchestrator/sandbox-fix.ts +39 -20
- package/src/orchestrator/sandbox-request.ts +84 -13
- package/src/orchestrator/signals.ts +1 -1
- package/src/orchestrator/stable-keys.ts +13 -11
- package/src/orchestrator/status-line.ts +48 -6
- package/src/orchestrator/summary.ts +46 -62
- package/src/orchestrator/task-hash.ts +12 -83
- package/src/orchestrator/telemetry.ts +3 -3
- package/src/settings.ts +417 -0
- package/src/util/errors.ts +1 -1
- package/src/util/essential-env.ts +31 -0
- package/src/util/hide-env.ts +107 -0
- package/src/util/index.ts +8 -2
- package/src/util/paths.ts +10 -0
- package/src/util/settle.ts +49 -30
- package/src/util/size.ts +23 -4
- package/src/util/timing.ts +3 -2
- package/src/util/ulid.ts +1 -1
- package/src/util/verbs.ts +0 -15
- package/src/workspace/affected.ts +77 -222
- package/src/workspace/config-eval.ts +15 -20
- package/src/workspace/config-imports.ts +78 -3
- package/src/workspace/config-schema.ts +209 -268
- package/src/workspace/dependency-rules.ts +57 -0
- package/src/workspace/filter.ts +2 -2
- package/src/workspace/fingerprint.ts +11 -121
- package/src/workspace/index.ts +20 -27
- package/src/workspace/load-reads.ts +4 -4
- package/src/workspace/lockfile.ts +96 -140
- package/src/workspace/migration.ts +242 -146
- package/src/workspace/package-graph.ts +30 -216
- package/src/workspace/project-loader.ts +80 -285
- package/src/workspace/repo-id.ts +2 -1
- package/src/workspace/workspace.ts +362 -831
- package/src/cache/config-evals.ts +0 -130
- package/src/workspace/config-cache.ts +0 -661
- package/src/workspace/migrate-scripts.ts +0 -1121
|
@@ -7,7 +7,7 @@ import { existsSync } from 'node:fs'
|
|
|
7
7
|
import { lstat, mkdir, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import path from 'node:path'
|
|
10
|
-
import type { ExecConfig } from '../config.js'
|
|
10
|
+
import type { ExecConfig, SandboxConfig } from '../config.js'
|
|
11
11
|
import {
|
|
12
12
|
bindableWrites,
|
|
13
13
|
initSandbox,
|
|
@@ -25,7 +25,6 @@ import {
|
|
|
25
25
|
} from '../exec/index.js'
|
|
26
26
|
import type { TaskNode } from '../graph/index.js'
|
|
27
27
|
import { grantPrefix, relPosix, UserError } from '../util/index.js'
|
|
28
|
-
import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
|
|
29
28
|
|
|
30
29
|
/**
|
|
31
30
|
* Arm the sandbox runtime for a run, lazily: only when at least one task
|
|
@@ -188,7 +187,7 @@ export async function sandboxRequestFor(
|
|
|
188
187
|
/** The projects nested in this one (their dirs): a wall its grants do not reach. */
|
|
189
188
|
nested: readonly string[] = [],
|
|
190
189
|
/**
|
|
191
|
-
* The run's cache directory. `.vx` is walled wherever it is; a
|
|
190
|
+
* The run's cache directory. `.vx/cache` is walled wherever it is; a
|
|
192
191
|
* `cacheDir` configured inside a project was not, and a task whose write
|
|
193
192
|
* grant covered it could plant artifacts and index rows (L-24).
|
|
194
193
|
*/
|
|
@@ -234,7 +233,7 @@ export async function sandboxRequestFor(
|
|
|
234
233
|
const walls = [
|
|
235
234
|
...nested,
|
|
236
235
|
path.join(workspaceRoot, '.git'),
|
|
237
|
-
path.join(workspaceRoot, '.vx'),
|
|
236
|
+
path.join(workspaceRoot, '.vx', 'cache'),
|
|
238
237
|
...(cacheDir !== undefined &&
|
|
239
238
|
toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
|
|
240
239
|
? [cacheDir]
|
|
@@ -290,6 +289,53 @@ export async function sandboxRequestFor(
|
|
|
290
289
|
return { sandbox: request, placeholders, withheld: links.withheld }
|
|
291
290
|
}
|
|
292
291
|
|
|
292
|
+
/**
|
|
293
|
+
* Whether a sandboxed task could read `real` (a canonical path inside its
|
|
294
|
+
* project): under one of its read or write grants or its `node_modules`,
|
|
295
|
+
* and not behind a wall. vx packs a symlinked output as its target's
|
|
296
|
+
* bytes, outside the sandbox, so a link the task planted to a file it was
|
|
297
|
+
* denied (`dist/x -> ../.env` under `read: ['src/']`) packed that file
|
|
298
|
+
* into the cache wherever creating the link went unseen: seatbelt, or
|
|
299
|
+
* Linux untraced. The grants are matched as written, not expanded: a
|
|
300
|
+
* glob's hit made by the task itself counts.
|
|
301
|
+
*/
|
|
302
|
+
export function sandboxReadable(
|
|
303
|
+
node: TaskNode,
|
|
304
|
+
sandbox: SandboxConfig,
|
|
305
|
+
workspaceRoot: string,
|
|
306
|
+
nested: readonly string[] = [],
|
|
307
|
+
): (real: string) => boolean {
|
|
308
|
+
const projectReal = toRealPath(node.projectDir)
|
|
309
|
+
const at = (p: string): string =>
|
|
310
|
+
p.startsWith('~')
|
|
311
|
+
? path.join(homedir(), p.slice(1))
|
|
312
|
+
: path.isAbsolute(p)
|
|
313
|
+
? p
|
|
314
|
+
: path.join(projectReal, p)
|
|
315
|
+
const grants = [...(sandbox.allow?.read ?? []), ...(sandbox.allow?.write ?? [])].map(at)
|
|
316
|
+
const literals = [
|
|
317
|
+
...grants.filter(isMountableLiteral).map((g) => toRealPath(g.replace(/\/+$/, '') || '/')),
|
|
318
|
+
toRealPath(path.join(node.projectDir, 'node_modules')),
|
|
319
|
+
toRealPath(path.join(workspaceRoot, 'node_modules')),
|
|
320
|
+
]
|
|
321
|
+
const globs = grants.filter((g) => !isMountableLiteral(g)).map((g) => new Bun.Glob(g))
|
|
322
|
+
const walls = [
|
|
323
|
+
...nested,
|
|
324
|
+
path.join(workspaceRoot, '.git'),
|
|
325
|
+
path.join(workspaceRoot, '.vx', 'cache'),
|
|
326
|
+
].map(toRealPath)
|
|
327
|
+
return (real) => {
|
|
328
|
+
if (walls.some((w) => atOrUnder(real, w))) return false
|
|
329
|
+
if (literals.some((l) => atOrUnder(real, l))) return true
|
|
330
|
+
// A directory a glob matches is granted whole.
|
|
331
|
+
for (let p = real; atOrUnder(p, projectReal); p = path.dirname(p)) {
|
|
332
|
+
if (globs.some((g) => g.match(p))) return true
|
|
333
|
+
if (p === projectReal) break
|
|
334
|
+
}
|
|
335
|
+
return false
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
293
339
|
/**
|
|
294
340
|
* The sandbox half of the request, plus the empty files vx created so a
|
|
295
341
|
* literal write grant had something to bind (`placeholders`). They are
|
|
@@ -619,9 +665,9 @@ export function untouchedPlaceholderLine(projectDir: string, placeholder: string
|
|
|
619
665
|
/**
|
|
620
666
|
* Where tools keep credentials under the user's home. A sandboxed task
|
|
621
667
|
* reads none of them unless its `allow.read` names one (a publish task's
|
|
622
|
-
* `~/.npmrc`).
|
|
668
|
+
* `~/.npmrc`), and `sandboxFix` never suggests one as a grant.
|
|
623
669
|
*/
|
|
624
|
-
const CREDENTIAL_STORES = [
|
|
670
|
+
export const CREDENTIAL_STORES = [
|
|
625
671
|
'.ssh',
|
|
626
672
|
'.gnupg',
|
|
627
673
|
'.aws',
|
|
@@ -647,6 +693,26 @@ const CREDENTIAL_STORES = [
|
|
|
647
693
|
'.terraform.d/credentials.tfrc.json',
|
|
648
694
|
'.vault-token',
|
|
649
695
|
'.pgpass',
|
|
696
|
+
// Deploy and service CLIs a JS build runs beside, each at its home on
|
|
697
|
+
// Linux and macOS, and the agent CLIs that may run vx itself.
|
|
698
|
+
'.config/configstore/firebase-tools.json',
|
|
699
|
+
'.local/share/com.vercel.cli/auth.json',
|
|
700
|
+
'Library/Application Support/com.vercel.cli/auth.json',
|
|
701
|
+
'.netlify/config.json',
|
|
702
|
+
'.config/netlify/config.json',
|
|
703
|
+
'Library/Preferences/netlify/config.json',
|
|
704
|
+
'.wrangler/config',
|
|
705
|
+
'.config/.wrangler/config',
|
|
706
|
+
'Library/Preferences/.wrangler/config',
|
|
707
|
+
'.fly/config.yml',
|
|
708
|
+
'.supabase/access-token',
|
|
709
|
+
'.config/doctl',
|
|
710
|
+
'Library/Application Support/doctl',
|
|
711
|
+
'.config/stripe/config.toml',
|
|
712
|
+
'.config/op',
|
|
713
|
+
'.expo/state.json',
|
|
714
|
+
'.claude/.credentials.json',
|
|
715
|
+
'.codex/auth.json',
|
|
650
716
|
]
|
|
651
717
|
|
|
652
718
|
/** The stores present on this host, learned once per home. */
|
|
@@ -697,7 +763,7 @@ export type WriteReach = 'none' | 'project' | 'workspace'
|
|
|
697
763
|
/**
|
|
698
764
|
* Where a task that ran a command may have written files no declaration
|
|
699
765
|
* names — the files whose run-start facts (the git snapshot, its index
|
|
700
|
-
* OIDs
|
|
766
|
+
* OIDs) a later key must not reuse (item 743).
|
|
701
767
|
*
|
|
702
768
|
* A cached task declares its outputs and is held to them. A task with no
|
|
703
769
|
* `cache` block can declare none, so it may have written anywhere
|
|
@@ -732,20 +798,25 @@ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteR
|
|
|
732
798
|
}
|
|
733
799
|
|
|
734
800
|
/**
|
|
735
|
-
* May this task's command rewrite a file
|
|
736
|
-
*
|
|
801
|
+
* May this task's command rewrite a root file a plugin claims (`claimed`:
|
|
802
|
+
* a lockfile, `pnpm-workspace.yaml`)? Those sit at the workspace root, so
|
|
737
803
|
* an unsandboxed task may only when the root is its own project (`pnpm
|
|
738
804
|
* install` without `--frozen-lockfile` in a root task); a sandboxed one
|
|
739
805
|
* when a write grant covers one. Anyone else writing there crosses a
|
|
740
|
-
* project boundary, out of contract.
|
|
806
|
+
* project boundary, out of contract. With nothing claimed, no key folds a
|
|
807
|
+
* root file this way.
|
|
741
808
|
*/
|
|
742
|
-
export function mayWriteFingerprint(
|
|
809
|
+
export function mayWriteFingerprint(
|
|
810
|
+
node: TaskNode,
|
|
811
|
+
workspaceRoot: string,
|
|
812
|
+
claimed: readonly string[],
|
|
813
|
+
): boolean {
|
|
743
814
|
const exec = node.config.exec
|
|
744
|
-
if (exec === undefined) return false
|
|
815
|
+
if (exec === undefined || claimed.length === 0) return false
|
|
745
816
|
if (exec.sandbox === undefined) return node.projectDir === workspaceRoot
|
|
746
817
|
for (const grant of exec.sandbox.allow?.write ?? []) {
|
|
747
818
|
const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
|
|
748
|
-
for (const f of
|
|
819
|
+
for (const f of claimed) {
|
|
749
820
|
if (atOrUnder(path.join(workspaceRoot, f), abs)) return true
|
|
750
821
|
}
|
|
751
822
|
}
|
|
@@ -21,7 +21,7 @@ import type { Logger } from './logger.js'
|
|
|
21
21
|
/**
|
|
22
22
|
* How long a SIGTERMed child gets before SIGKILL on the way out. The
|
|
23
23
|
* end-of-run persistent shutdown grants the same two seconds; both read
|
|
24
|
-
* `
|
|
24
|
+
* `VX_RUN_KILL_GRACE` (util/settle.ts) so the suites that prove the
|
|
25
25
|
* escalation do not wait the full grace.
|
|
26
26
|
*/
|
|
27
27
|
export const SIGNAL_SHUTDOWN_GRACE_MS = 2000
|
|
@@ -28,7 +28,8 @@ export interface DeriveStableKeysArgs {
|
|
|
28
28
|
nodes: Map<string, TaskNode>
|
|
29
29
|
cache: CacheLayer
|
|
30
30
|
workspaceRoot: string
|
|
31
|
-
|
|
31
|
+
/** The root files the plugins claim (`VxPlugin.fingerprint`), for `mayWriteFingerprint`. */
|
|
32
|
+
claimedFiles: readonly string[]
|
|
32
33
|
forwardArgs?: readonly string[] | undefined
|
|
33
34
|
nestedDirsByProject: Map<string, string[]>
|
|
34
35
|
gitFilesCache: GitFilesCache
|
|
@@ -58,7 +59,7 @@ export interface StableKey {
|
|
|
58
59
|
* cache-enabled, non-group tasks paired with their keys.
|
|
59
60
|
*
|
|
60
61
|
* Reuses the run's `hashCache`, so execute-task's later computeTaskHash
|
|
61
|
-
* for the same node hits the memo (
|
|
62
|
+
* for the same node hits the memo (the task config hash, file lists) —
|
|
62
63
|
* the key bytes are derived here, the heavy per-project hashing is
|
|
63
64
|
* shared. No cache layer is touched: keys only.
|
|
64
65
|
*/
|
|
@@ -82,8 +83,8 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
82
83
|
// - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
|
|
83
84
|
// (root-anchored, boundary-ignoring outputs), or has no cache block
|
|
84
85
|
// and a sandbox write grant elsewhere in the workspace, or may rewrite
|
|
85
|
-
// a file
|
|
86
|
-
//
|
|
86
|
+
// a root file a plugin claims (a root `pnpm install`): every key folds
|
|
87
|
+
// what the claimant took from it, once per run.
|
|
87
88
|
// - rewritersById / unfoldedById: the projects of every upstream CACHED
|
|
88
89
|
// task that may rewrite its own inputs in place (a formatter declaring
|
|
89
90
|
// no outputs; `commandWriteReach`), and the subset this task's key does
|
|
@@ -93,7 +94,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
93
94
|
// unfolded dependency covered is still uncovered here: its key is not
|
|
94
95
|
// in this one.
|
|
95
96
|
// - wsRewriters: the same for a cached rewriter whose reach is every
|
|
96
|
-
// project (a write grant elsewhere in the workspace, or a
|
|
97
|
+
// project (a write grant elsewhere in the workspace, or a claimed root
|
|
97
98
|
// file, `mayWriteFingerprint`); one this key does not fold makes it
|
|
98
99
|
// preliminary. A dependency this key folds needs no pass-through here:
|
|
99
100
|
// a keyed task with such a rewriter unfolded is unstable itself, and
|
|
@@ -175,14 +176,17 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
175
176
|
;(wide ??= projects.empty()).add(depNode.projectName)
|
|
176
177
|
} else if (reach === 'workspace') wsOutputUpstream = true
|
|
177
178
|
const cached = depNode.config.cache !== undefined
|
|
178
|
-
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot))
|
|
179
|
+
if (!cached && mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
|
|
180
|
+
wsOutputUpstream = true
|
|
179
181
|
if (!anyUnfolded) continue
|
|
180
182
|
// The cached twin: a formatter rewriting its own input ahead of a
|
|
181
183
|
// reader that folds no key of it replayed seed B's build under seed A
|
|
182
184
|
// on the fourth run, the same way (item 750).
|
|
183
185
|
const rewrites = cached ? commandWriteReach(depNode, args.workspaceRoot) : 'none'
|
|
184
186
|
const rewritesWs =
|
|
185
|
-
cached &&
|
|
187
|
+
cached &&
|
|
188
|
+
(rewrites === 'workspace' ||
|
|
189
|
+
mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles))
|
|
186
190
|
const rewritersOfDep = rewritersById.get(dep)
|
|
187
191
|
const wsRewriterOfDep = wsRewriters.has(dep)
|
|
188
192
|
if (
|
|
@@ -239,7 +243,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
239
243
|
node,
|
|
240
244
|
upstream,
|
|
241
245
|
workspaceRoot: args.workspaceRoot,
|
|
242
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
243
246
|
cache: args.cache,
|
|
244
247
|
forwardArgs: args.forwardArgs,
|
|
245
248
|
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
@@ -256,7 +259,6 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
256
259
|
node,
|
|
257
260
|
upstream,
|
|
258
261
|
workspaceRoot: args.workspaceRoot,
|
|
259
|
-
workspaceFingerprint: args.workspaceFingerprint,
|
|
260
262
|
cache: args.cache,
|
|
261
263
|
forwardArgs: args.forwardArgs,
|
|
262
264
|
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
@@ -339,7 +341,7 @@ export function probesAfterWrites(
|
|
|
339
341
|
}
|
|
340
342
|
if (!anyProbe) return late
|
|
341
343
|
// A sandbox with no write grant still writes the declared outputs. A
|
|
342
|
-
//
|
|
344
|
+
// claimed root file is only writable where `commandWriteReach` is not
|
|
343
345
|
// 'none' already (`mayWriteFingerprint`).
|
|
344
346
|
const writers = new Set<string>()
|
|
345
347
|
for (const n of nodes.values()) {
|
|
@@ -469,7 +471,7 @@ export function dependsOnSiblingOutputs(
|
|
|
469
471
|
if (inputs === undefined || outputs.length === 0) return true
|
|
470
472
|
// An output the reader's own `!` inputs take back whole is not in its
|
|
471
473
|
// key: `['**/*', '!dist/**']` beside a `dist/**` producer keys the same
|
|
472
|
-
// before and after it, which is what `rules.upfrontKeys` asks a config
|
|
474
|
+
// before and after it, which is what `run.rules.upfrontKeys` asks a config
|
|
473
475
|
// to write (X-54).
|
|
474
476
|
const back = inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
475
477
|
const reach = back.length === 0 ? outputs : outputs.filter((o) => !outputTakenBack(o, back))
|
|
@@ -26,6 +26,8 @@ export interface StatusStream {
|
|
|
26
26
|
* TTY) means the region is never drawn anyway.
|
|
27
27
|
*/
|
|
28
28
|
columns?: number
|
|
29
|
+
/** Terminal height in rows, read per draw as `columns` is. */
|
|
30
|
+
rows?: number
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export interface OutputWriter {
|
|
@@ -137,14 +139,37 @@ export function createOutputWriter(
|
|
|
137
139
|
* it. Reachable only on a TTY whose winsize ioctl fails, since a non-TTY
|
|
138
140
|
* disables the region outright.
|
|
139
141
|
*/
|
|
140
|
-
const
|
|
142
|
+
const lineRows = (line: string): number => {
|
|
141
143
|
const cols = stream.columns
|
|
142
|
-
if (cols === undefined || cols <= 0) return
|
|
144
|
+
if (cols === undefined || cols <= 0) return 1
|
|
145
|
+
return Math.max(1, Math.ceil(Bun.stringWidth(line) / cols))
|
|
146
|
+
}
|
|
147
|
+
const regionRows = (lines: readonly string[]): number => {
|
|
143
148
|
let rows = 0
|
|
144
|
-
for (const line of lines) rows +=
|
|
149
|
+
for (const line of lines) rows += lineRows(line)
|
|
145
150
|
return rows
|
|
146
151
|
}
|
|
147
152
|
|
|
153
|
+
/**
|
|
154
|
+
* The leading lines that fit the terminal's height, one row spared.
|
|
155
|
+
* Cursor-up stops at the top of the screen, and a row that scrolled into
|
|
156
|
+
* the scrollback is out of the erase's reach: a region taller than the
|
|
157
|
+
* terminal left its top behind on every redraw, one stacked block per tick
|
|
158
|
+
* (VS Code's short panel, 2026-10-14). The first line always shows.
|
|
159
|
+
*/
|
|
160
|
+
const fitted = (lines: readonly string[]): readonly string[] => {
|
|
161
|
+
const height = stream.rows
|
|
162
|
+
if (height === undefined || height <= 0) return lines
|
|
163
|
+
let rows = 0
|
|
164
|
+
let n = 0
|
|
165
|
+
for (const line of lines) {
|
|
166
|
+
rows += lineRows(line)
|
|
167
|
+
if (n > 0 && rows > height - 1) break
|
|
168
|
+
n++
|
|
169
|
+
}
|
|
170
|
+
return n === lines.length ? lines : lines.slice(0, n)
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
// Erase sequence for whatever is currently shown. A single row keeps
|
|
149
174
|
// the exact legacy bytes (ESC[2K\r); anything taller moves to its
|
|
150
175
|
// top row and clears to end of screen.
|
|
@@ -155,9 +180,10 @@ export function createOutputWriter(
|
|
|
155
180
|
// only repeat these bytes.
|
|
156
181
|
cancelTrailing()
|
|
157
182
|
const erase = shown ? eraseSeq() : CLEAR
|
|
158
|
-
|
|
183
|
+
const lines = fitted(current!)
|
|
184
|
+
stream.write(erase + lines.join('\n'))
|
|
159
185
|
shown = true
|
|
160
|
-
shownRows = regionRows(
|
|
186
|
+
shownRows = regionRows(lines)
|
|
161
187
|
lastDraw = now()
|
|
162
188
|
}
|
|
163
189
|
const set = (lines: readonly string[], o: { force?: boolean }): void => {
|
|
@@ -246,6 +272,8 @@ export interface StatusRegionState {
|
|
|
246
272
|
blocks?: ReadonlyMap<string, number>
|
|
247
273
|
/** The running task at the head of the longest unfinished chain. */
|
|
248
274
|
critical?: string
|
|
275
|
+
/** A running task's usual duration from history; its row shows it. */
|
|
276
|
+
expected?: ReadonlyMap<string, number>
|
|
249
277
|
/**
|
|
250
278
|
* Ready persistent tasks (dev servers …) whose children keep
|
|
251
279
|
* running after their outcome lands. Pinned until runEnd — the
|
|
@@ -349,13 +377,14 @@ export function formatStatusRegion(
|
|
|
349
377
|
const notes: string[] = []
|
|
350
378
|
if (s.critical === slot.id && busy > 1) notes.push(paint(WARN, 'critical path', colors))
|
|
351
379
|
if (waiting > 0) notes.push(dim(`blocks ${waiting}`))
|
|
380
|
+
const usual = s.expected?.get(slot.id)
|
|
352
381
|
lines.push(
|
|
353
382
|
formatTaskRow(
|
|
354
383
|
' ',
|
|
355
384
|
Math.max(0, s.nowMs - slot.startedMs),
|
|
356
385
|
'running',
|
|
357
386
|
ACCENT,
|
|
358
|
-
'',
|
|
387
|
+
usual === undefined ? '' : formatExpected(usual),
|
|
359
388
|
'',
|
|
360
389
|
paintPinnedId(slot.id, colors),
|
|
361
390
|
colors,
|
|
@@ -370,3 +399,16 @@ export function formatStatusRegion(
|
|
|
370
399
|
lines.push(...s.summaryLines)
|
|
371
400
|
return lines
|
|
372
401
|
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A running task's usual duration, fixed while the elapsed time counts up
|
|
405
|
+
* toward it (M, 2026-10-14): `~5.2s`, `~42s`, `~2m 05s`. Fits the cache
|
|
406
|
+
* column below 100 minutes.
|
|
407
|
+
*/
|
|
408
|
+
function formatExpected(ms: number): string {
|
|
409
|
+
if (ms < 1000) return `~${Math.round(ms)}ms`
|
|
410
|
+
if (ms < 9950) return `~${(ms / 1000).toFixed(1)}s`
|
|
411
|
+
const s = Math.round(ms / 1000)
|
|
412
|
+
if (s < 60) return `~${s}s`
|
|
413
|
+
return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
414
|
+
}
|
|
@@ -21,6 +21,7 @@ const LOCAL = '#38bdf8' // sky-400
|
|
|
21
21
|
const REMOTE = '#2563eb' // blue-600
|
|
22
22
|
const ERROR = '#ef4444'
|
|
23
23
|
const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
|
|
24
|
+
const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
|
|
24
25
|
const ACCENT = '#06b6d4' // cyan-500: the live region's running word
|
|
25
26
|
|
|
26
27
|
const BAR_WIDTH = 50
|
|
@@ -137,7 +138,7 @@ export interface SummaryStats {
|
|
|
137
138
|
*/
|
|
138
139
|
export interface RunContext {
|
|
139
140
|
version: string
|
|
140
|
-
/** Projects covered by the graph — the "
|
|
141
|
+
/** Projects covered by the graph — the "affected" half of the bar. */
|
|
141
142
|
packageCount: number
|
|
142
143
|
/** Worker-pool size for this run; shown on the `info` row. */
|
|
143
144
|
concurrency?: number
|
|
@@ -164,25 +165,25 @@ export function formatSummarySection(
|
|
|
164
165
|
const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
|
|
165
166
|
const join = (parts: string[]): string => parts.join(` ${dim('\u00b7')} `)
|
|
166
167
|
|
|
167
|
-
// Tasks meter + numbers, fixed order: failed · success · skipped
|
|
168
|
-
// then a dim total
|
|
168
|
+
// Tasks meter + numbers, fixed order: failed · success · skipped ·
|
|
169
|
+
// aborted · not run, then a dim total, last on its line. Every task in
|
|
170
|
+
// the run counts, each state its own segment (M, 2026-10-14).
|
|
171
|
+
const runTotal = stats.total + aborted + notRun
|
|
169
172
|
const taskParts: string[] = []
|
|
170
173
|
if (stats.failed > 0)
|
|
171
174
|
taskParts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
|
|
172
175
|
if (stats.successful > 0) taskParts.push(paint(SUCCESS, `${stats.successful} success`, colors))
|
|
173
176
|
if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const uncounted: string[] = []
|
|
178
|
-
if (aborted > 0) uncounted.push(paint(ABORTED, `${aborted} aborted`, colors))
|
|
179
|
-
if (notRun > 0) uncounted.push(dim(`${notRun} not run`))
|
|
180
|
-
if (uncounted.length > 0) taskParts.push(`${dim('not counted:')} ${uncounted.join(dim(', '))}`)
|
|
177
|
+
if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
|
|
178
|
+
if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
|
|
179
|
+
if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
|
|
181
180
|
const taskBar = segmentBar(
|
|
182
181
|
[
|
|
183
182
|
{ n: stats.failed, color: ERROR },
|
|
184
183
|
{ n: stats.successful, color: SUCCESS },
|
|
185
184
|
{ n: stats.skipped, color: WARN },
|
|
185
|
+
{ n: aborted, color: ABORTED },
|
|
186
|
+
{ n: notRun, color: NOT_RUN },
|
|
186
187
|
remainder,
|
|
187
188
|
],
|
|
188
189
|
colors,
|
|
@@ -211,7 +212,7 @@ export function formatSummarySection(
|
|
|
211
212
|
paint('', '▱'.repeat(BAR_WIDTH - cells), colors, { dim: true })
|
|
212
213
|
lines.push(
|
|
213
214
|
row('projects', bar),
|
|
214
|
-
legend([paint(WARN, `${context.packageCount}
|
|
215
|
+
legend([paint(WARN, `${context.packageCount} affected`, colors), dim(`${wsTotal} total`)]),
|
|
215
216
|
)
|
|
216
217
|
}
|
|
217
218
|
}
|
|
@@ -256,27 +257,48 @@ export function formatSummarySection(
|
|
|
256
257
|
cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
|
|
257
258
|
if (stats.restoredRemote > 0)
|
|
258
259
|
cacheParts.push(paint(REMOTE, `${stats.restoredRemote} remote`, colors))
|
|
259
|
-
// Skipped tasks
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// tasks meter.
|
|
263
|
-
if (stats.skipped > 0) cacheParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
|
|
260
|
+
// Skipped tasks ride the bar, yellow like the tasks meter, so the two
|
|
261
|
+
// bars fill alike; their count is the tasks legend's alone (the footer
|
|
262
|
+
// never says a fact twice, M, 2026-10-14).
|
|
264
263
|
lines.push(row('cache', cacheBar))
|
|
265
264
|
if (cacheParts.length > 0) lines.push(legend(cacheParts))
|
|
266
265
|
}
|
|
267
266
|
|
|
268
267
|
// Cache-miss duration spread (owner: hits' restore times polluted
|
|
269
268
|
// it — only tasks that actually executed count). Dim: context.
|
|
270
|
-
|
|
271
|
-
|
|
269
|
+
// A value already on the row is not said again: one executed task read
|
|
270
|
+
// as its own max, avg and min (M, 2026-10-14). Tasks that all took the
|
|
271
|
+
// same time have no spread to show.
|
|
272
|
+
const total = formatElapsed(totalMs)
|
|
273
|
+
const said = new Set([total])
|
|
274
|
+
const spreadParts: string[] = []
|
|
275
|
+
if (
|
|
276
|
+
stats.spread !== null &&
|
|
277
|
+
formatElapsed(stats.spread.maxMs) !== formatElapsed(stats.spread.minMs)
|
|
278
|
+
) {
|
|
272
279
|
const { maxMs, minMs, sumMs, count } = stats.spread
|
|
273
|
-
|
|
280
|
+
for (const [label, ms] of [
|
|
281
|
+
['max', maxMs],
|
|
282
|
+
['avg', sumMs / count],
|
|
283
|
+
['min', minMs],
|
|
284
|
+
] as const) {
|
|
285
|
+
const v = formatElapsed(ms)
|
|
286
|
+
if (said.has(v)) continue
|
|
287
|
+
said.add(v)
|
|
288
|
+
spreadParts.push(`${label} ${v}`)
|
|
289
|
+
}
|
|
274
290
|
}
|
|
291
|
+
const timeParts = [total]
|
|
292
|
+
// What the cache paid back, beside what the run cost: the stored exec
|
|
293
|
+
// time of every hit, never the restore this run spent on it.
|
|
294
|
+
if ((stats.savedMs ?? 0) >= 1)
|
|
295
|
+
timeParts.push(paint(SUCCESS, `${formatElapsed(stats.savedMs!)} saved`, colors))
|
|
275
296
|
// The live run's forecast leads the spread: the one number on the row
|
|
276
297
|
// that is about the future.
|
|
277
|
-
if (stats.etaMs !== undefined)
|
|
278
|
-
|
|
279
|
-
|
|
298
|
+
if (stats.etaMs !== undefined)
|
|
299
|
+
timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
|
|
300
|
+
for (const p of spreadParts) timeParts.push(dim(p))
|
|
301
|
+
const timeRow = row('time', join(timeParts))
|
|
280
302
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
281
303
|
// grouped with time under a blank line below the meters.
|
|
282
304
|
if (context !== undefined) {
|
|
@@ -294,12 +316,9 @@ export function formatSummarySection(
|
|
|
294
316
|
`admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
|
|
295
317
|
)
|
|
296
318
|
}
|
|
297
|
-
lines.push('', row('info', join(info)),
|
|
298
|
-
// The run in one line, last, where the eye lands: what a Turbo user
|
|
299
|
-
// reads first in its own summary (tasks, cached, time), in vx's words.
|
|
300
|
-
if (stats.total > 0) lines.push(row('result', resultLine(stats, hits, totalMs, colors)))
|
|
319
|
+
lines.push('', row('info', join(info)), timeRow)
|
|
301
320
|
} else {
|
|
302
|
-
lines.push('',
|
|
321
|
+
lines.push('', timeRow)
|
|
303
322
|
}
|
|
304
323
|
return lines
|
|
305
324
|
}
|
|
@@ -315,41 +334,6 @@ function formatEta(ms: number): string {
|
|
|
315
334
|
return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
316
335
|
}
|
|
317
336
|
|
|
318
|
-
/**
|
|
319
|
-
* `42 tasks · 38 cached (90%) · 3.2s`, `2 tasks · all cached · 23ms`,
|
|
320
|
-
* with the failures first when there are any. Cached is every hit (up to
|
|
321
|
-
* date, restored locally or remotely) over every task that consulted a
|
|
322
|
-
* cache: a skipped or not-yet-run task never did.
|
|
323
|
-
*/
|
|
324
|
-
function resultLine(
|
|
325
|
-
stats: SummaryStats,
|
|
326
|
-
hits: number,
|
|
327
|
-
totalMs: number,
|
|
328
|
-
colors: ColorSupport,
|
|
329
|
-
): string {
|
|
330
|
-
const parts = [`${stats.total} task${stats.total === 1 ? '' : 's'}`]
|
|
331
|
-
if (stats.failed > 0) parts.push(paint(ERROR, `${stats.failed} failed`, colors, { bold: true }))
|
|
332
|
-
// A task with no `cache` block could never hit: it is counted apart, not
|
|
333
|
-
// as a miss, or `vx run dev` would read "0 cached (0%)". A skipped task
|
|
334
|
-
// never asked either, and counted as a miss it put "0 cached (0%)" on a
|
|
335
|
-
// run in which no task has a cache.
|
|
336
|
-
const noCache = stats.noCache ?? 0
|
|
337
|
-
const cacheable = stats.total - noCache - stats.skipped - (stats.left ?? 0)
|
|
338
|
-
if (cacheable > 0)
|
|
339
|
-
parts.push(
|
|
340
|
-
hits === cacheable
|
|
341
|
-
? paint(SUCCESS, 'all cached', colors, { bold: true })
|
|
342
|
-
: `${hits} cached (${Math.floor((hits / cacheable) * 100)}%)`,
|
|
343
|
-
)
|
|
344
|
-
if (noCache > 0) parts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
|
|
345
|
-
// What the cache paid back, beside what the run cost: the stored exec
|
|
346
|
-
// time of every hit, never the restore this run spent on it.
|
|
347
|
-
if ((stats.savedMs ?? 0) >= 1)
|
|
348
|
-
parts.push(paint(SUCCESS, `${formatElapsed(stats.savedMs!)} saved`, colors))
|
|
349
|
-
parts.push(formatElapsed(totalMs))
|
|
350
|
-
return parts.join(` ${paint('', '\u00b7', colors, { dim: true })} `)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
337
|
/**
|
|
354
338
|
* The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
|
|
355
339
|
* left out as the footer and `--report` leave them: a group ran no command.
|