@vzn/vx 0.0.16 → 0.0.18
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 +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -24,11 +24,12 @@ import { UserError } from '../util/index.js'
|
|
|
24
24
|
* 'name' same-project task `name`
|
|
25
25
|
* '^name' `name` task in every dep workspace
|
|
26
26
|
* 'pkg#name' specific package's `name` task
|
|
27
|
-
* 'name.*'
|
|
28
|
-
*
|
|
29
|
-
* that matched literally here
|
|
30
|
-
*
|
|
31
|
-
* a stale-hit trap, so
|
|
27
|
+
* 'name.*' patterns — EITHER half of any form above may contain `*`
|
|
28
|
+
* (same glob as dependsOn patterns), including the project
|
|
29
|
+
* half of `pkg#name`. A filter that matched literally here
|
|
30
|
+
* would silently select ZERO upstream hashes and decouple the
|
|
31
|
+
* task from its dependencies — a stale-hit trap, so every
|
|
32
|
+
* name is matched through the shared glob.
|
|
32
33
|
* '!<form>' exclude — any of the above with a leading `!`
|
|
33
34
|
*
|
|
34
35
|
* Patterns are applied in order; last write wins, so
|
|
@@ -69,19 +70,19 @@ export function filterUpstreamHashes(
|
|
|
69
70
|
}
|
|
70
71
|
})
|
|
71
72
|
|
|
72
|
-
// Per-spec
|
|
73
|
-
const matchers = specs.map((spec) =>
|
|
73
|
+
// Per-spec predicate, compiled once (exact compares + patterns).
|
|
74
|
+
const matchers = specs.map((spec) => specMatcher(spec))
|
|
74
75
|
|
|
75
76
|
// Dedup by hash (the key fold's unit), but remember the first task id
|
|
76
77
|
// seen for each hash so the diff row can name the upstream.
|
|
77
78
|
const selected = new Map<string, string>()
|
|
78
79
|
for (let i = 0; i < specs.length; i++) {
|
|
79
80
|
const spec = specs[i]!
|
|
80
|
-
const
|
|
81
|
+
const matches = matchers[i]!
|
|
81
82
|
for (const u of upstream) {
|
|
82
83
|
if (!u.hash) continue
|
|
83
84
|
const isSelf = u.node.projectName === selfProjectName
|
|
84
|
-
if (!matches(
|
|
85
|
+
if (!matches(u, isSelf)) continue
|
|
85
86
|
if (spec.negated) selected.delete(u.hash)
|
|
86
87
|
else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
|
|
87
88
|
}
|
|
@@ -89,33 +90,73 @@ export function filterUpstreamHashes(
|
|
|
89
90
|
return [...selected].map(([hash, id]) => [id, hash])
|
|
90
91
|
}
|
|
91
92
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
/**
|
|
94
|
+
* Expand any GROUP among these outcomes into the real tasks it stands for,
|
|
95
|
+
* transitively, preserving order and de-duplicating by task id.
|
|
96
|
+
*
|
|
97
|
+
* A group has no `exec`, so it has no outputs and no cache entry — its hash
|
|
98
|
+
* is a synthetic roll-up. A dependent that asked the local index what its
|
|
99
|
+
* upstream produced therefore got an EMPTY output list for a group, which is
|
|
100
|
+
* invisible locally (the members' outputs are already on disk, put there by
|
|
101
|
+
* their own tasks) and fatal for an input-shipping executor, where that list
|
|
102
|
+
* IS the input root: `dependsOn: ['install']` shipped a worker an action with
|
|
103
|
+
* none of what `install` chains.
|
|
104
|
+
*
|
|
105
|
+
* Groups may nest, so the walk recurses. Only what the FILTER already
|
|
106
|
+
* selected is passed in, so a group excluded from a task's
|
|
107
|
+
* `cache.inputs.tasks` brings no members with it.
|
|
108
|
+
*/
|
|
109
|
+
export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
|
|
110
|
+
const out: TaskOutcome[] = []
|
|
111
|
+
const seen = new Set<string>()
|
|
112
|
+
const walk = (list: readonly TaskOutcome[]): void => {
|
|
113
|
+
for (const u of list) {
|
|
114
|
+
if (u.groupUpstream !== undefined) {
|
|
115
|
+
walk(u.groupUpstream)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (seen.has(u.node.id)) continue
|
|
119
|
+
seen.add(u.node.id)
|
|
120
|
+
out.push(u)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
walk(upstream)
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Exact-name compare, or the shared `*`-glob when the name is a pattern. */
|
|
128
|
+
function nameMatcher(name: string): (candidate: string) => boolean {
|
|
129
|
+
if (isTaskPattern(name)) {
|
|
130
|
+
const re = compileTaskPattern(name)
|
|
131
|
+
return (candidate) => re.test(candidate)
|
|
98
132
|
}
|
|
99
|
-
|
|
100
|
-
return (name) => name === exact
|
|
133
|
+
return (candidate) => candidate === name
|
|
101
134
|
}
|
|
102
135
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Compile one spec into an upstream predicate. BOTH halves of a `pkg#task`
|
|
138
|
+
* form glob: a filter only ever selects from upstreams that already exist,
|
|
139
|
+
* so a package pattern is unambiguous here — unlike dependsOn, which must
|
|
140
|
+
* materialize concrete edges and therefore rejects it.
|
|
141
|
+
*/
|
|
142
|
+
function specMatcher(spec: DependencySpec): (u: TaskOutcome, isSelf: boolean) => boolean {
|
|
109
143
|
switch (spec.kind) {
|
|
110
144
|
case 'wildcardSelf':
|
|
111
|
-
return isSelf
|
|
145
|
+
return (_u, isSelf) => isSelf
|
|
112
146
|
case 'wildcardDeps':
|
|
113
|
-
return !isSelf
|
|
114
|
-
case 'self':
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
case '
|
|
119
|
-
|
|
147
|
+
return (_u, isSelf) => !isSelf
|
|
148
|
+
case 'self': {
|
|
149
|
+
const task = nameMatcher(spec.task)
|
|
150
|
+
return (u, isSelf) => isSelf && task(u.node.taskName)
|
|
151
|
+
}
|
|
152
|
+
case 'deps': {
|
|
153
|
+
const task = nameMatcher(spec.task)
|
|
154
|
+
return (u, isSelf) => !isSelf && task(u.node.taskName)
|
|
155
|
+
}
|
|
156
|
+
case 'cross': {
|
|
157
|
+
const project = nameMatcher(spec.project)
|
|
158
|
+
const task = nameMatcher(spec.task)
|
|
159
|
+
return (u) => project(u.node.projectName) && task(u.node.taskName)
|
|
160
|
+
}
|
|
120
161
|
}
|
|
121
162
|
}
|
|
@@ -52,7 +52,7 @@ export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<st
|
|
|
52
52
|
|
|
53
53
|
/** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
|
|
54
54
|
* digest always folds ALL entries, so detection never depends on the map. */
|
|
55
|
-
|
|
55
|
+
const FP_MAX_FILES = 500
|
|
56
56
|
|
|
57
57
|
/** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
|
|
58
58
|
* payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
|
|
@@ -101,7 +101,12 @@ export function undeclaredInputPaths(
|
|
|
101
101
|
): string[] {
|
|
102
102
|
const rels = new Set<string>()
|
|
103
103
|
for (const v of violations) {
|
|
104
|
-
|
|
104
|
+
// Two wire shapes: Linux strace lines carry `[path]`; macOS
|
|
105
|
+
// sandbox-exec lines end in a bare absolute path
|
|
106
|
+
// (`node(123) deny(1) file-read-data /abs/path`). Fall back to the
|
|
107
|
+
// raw line only when neither parses — a path the user can act on
|
|
108
|
+
// beats a log line every time.
|
|
109
|
+
const m = /\[([^\]]+)\]\s*$/.exec(v.line) ?? /\s(\/\S+)\s*$/.exec(v.line)
|
|
105
110
|
const raw = m ? m[1]! : v.line
|
|
106
111
|
rels.add(
|
|
107
112
|
path.isAbsolute(raw) ? path.relative(workspaceRoot, raw).split(path.sep).join('/') : raw,
|
|
@@ -138,6 +143,9 @@ export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[]
|
|
|
138
143
|
case 'not-verified':
|
|
139
144
|
notVerified++
|
|
140
145
|
break
|
|
146
|
+
case 'unverifiable-remote-only':
|
|
147
|
+
na++
|
|
148
|
+
break
|
|
141
149
|
}
|
|
142
150
|
}
|
|
143
151
|
const lines = [
|
|
@@ -156,6 +164,8 @@ export function formatVerifySection(outcomes: readonly TaskOutcome[]): string[]
|
|
|
156
164
|
lines.push(' add them to cache.inputs.files / workspaceFiles')
|
|
157
165
|
} else if (v.kind === 'rerun-failed') {
|
|
158
166
|
lines.push(` ✗ ${o.node.id} — verify re-run failed (exit ${v.exitCode})`)
|
|
167
|
+
} else if (v.kind === 'unverifiable-remote-only') {
|
|
168
|
+
lines.push(` ⚠ ${o.node.id} — remote-only: no local execution to prove (unverified)`)
|
|
159
169
|
} else if (v.kind === 'allowed-nondeterministic') {
|
|
160
170
|
lines.push(` ⚠ ${o.node.id} — nondeterministic (allowed)`)
|
|
161
171
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Core's cache as a plugin: the `.vx/cache` handle the host opened (the run
|
|
2
|
+
// index + the local artifact store). Declared like any other cache layer;
|
|
3
|
+
// put a remote layer BEFORE it to look there first.
|
|
4
|
+
|
|
5
|
+
import type { VxPlugin } from '@vzn/vx'
|
|
6
|
+
|
|
7
|
+
export const LOCAL_CACHE_PLUGIN = 'vx/local-cache'
|
|
8
|
+
|
|
9
|
+
export function localCachePlugin(): VxPlugin {
|
|
10
|
+
return { name: LOCAL_CACHE_PLUGIN, cache: (ctx) => ctx.localCache }
|
|
11
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Core's executor as a plugin: spawn the task's command in-process, exactly
|
|
2
|
+
// the `runCommand` / `runSandboxed` call the orchestrator used to make
|
|
3
|
+
// directly. Imports core only through the public `@vzn/vx` surface so this
|
|
4
|
+
// directory can become its own package unchanged.
|
|
5
|
+
|
|
6
|
+
import { runCommand, runSandboxed, type TaskExecutor, type VxPlugin } from '@vzn/vx'
|
|
7
|
+
|
|
8
|
+
export const LOCAL_EXECUTOR_PLUGIN = 'vx/local-executor'
|
|
9
|
+
|
|
10
|
+
/** The executor itself — for tests and for plugins that wrap local execution. */
|
|
11
|
+
export function localExecutor(): TaskExecutor {
|
|
12
|
+
return {
|
|
13
|
+
name: 'local',
|
|
14
|
+
async execute(req) {
|
|
15
|
+
const common = {
|
|
16
|
+
command: req.command,
|
|
17
|
+
cwd: req.cwd,
|
|
18
|
+
env: req.env,
|
|
19
|
+
forwardArgs: req.forwardArgs,
|
|
20
|
+
onStdout: req.onStdout,
|
|
21
|
+
onStderr: req.onStderr,
|
|
22
|
+
capture: req.capture,
|
|
23
|
+
...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
|
|
24
|
+
...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
|
|
25
|
+
}
|
|
26
|
+
if (req.sandbox === undefined) {
|
|
27
|
+
const res = await runCommand(common)
|
|
28
|
+
return { ...res, violations: [] }
|
|
29
|
+
}
|
|
30
|
+
return await runSandboxed({
|
|
31
|
+
...common,
|
|
32
|
+
baseAllowRead: req.sandbox.baseAllowRead,
|
|
33
|
+
baseAllowWrite: req.sandbox.baseAllowWrite,
|
|
34
|
+
baseDenyRead: req.sandbox.baseDenyRead,
|
|
35
|
+
config: req.sandbox.config,
|
|
36
|
+
settleOnCleanExit: req.sandbox.settleOnCleanExit,
|
|
37
|
+
})
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Declare in vx.workspace.ts: `plugins: [localExecutorPlugin(), …]`. Accepts every task. */
|
|
43
|
+
export function localExecutorPlugin(): VxPlugin {
|
|
44
|
+
return { name: LOCAL_EXECUTOR_PLUGIN, executor: () => localExecutor() }
|
|
45
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// History-based scheduling as a plugin: order ready tasks by their expected
|
|
2
|
+
// REMAINING critical-path duration (own p50 + the longest chain of
|
|
3
|
+
// dependents), learned from the local run history. This was core's opt-in
|
|
4
|
+
// `predictive` mode until 2026-09-02; it is the reference `schedule` plugin
|
|
5
|
+
// now — declared like any other, and paying its history read only in
|
|
6
|
+
// workspaces that ask for it.
|
|
7
|
+
//
|
|
8
|
+
// Cache hits are NOT modelled as zero-cost: predicting cache state needs
|
|
9
|
+
// the key and a probe, which the scheduler handles at run time (a confirmed
|
|
10
|
+
// hit is backfill, never a critical-path task). The estimate here is "what
|
|
11
|
+
// if everything ran", which is exactly the case where order matters.
|
|
12
|
+
|
|
13
|
+
import { LocalHistoryProvider, type HistoryTable, type TaskNode, type VxPlugin } from '@vzn/vx'
|
|
14
|
+
|
|
15
|
+
const SCHEDULE_HISTORY_PLUGIN = 'vx/schedule-history'
|
|
16
|
+
|
|
17
|
+
/** Default duration when neither task history nor a workspace median exists. */
|
|
18
|
+
const DEFAULT_DURATION_MS = 1000
|
|
19
|
+
|
|
20
|
+
export interface ScheduleHistoryOptions {
|
|
21
|
+
/** How many recent runs per task to learn from. Default 20. */
|
|
22
|
+
readonly window?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function scheduleHistoryPlugin(options: ScheduleHistoryOptions = {}): VxPlugin {
|
|
26
|
+
return {
|
|
27
|
+
name: SCHEDULE_HISTORY_PLUGIN,
|
|
28
|
+
async schedule(nodes, ctx) {
|
|
29
|
+
const provider =
|
|
30
|
+
options.window === undefined
|
|
31
|
+
? new LocalHistoryProvider(ctx.localCache.dbHandle())
|
|
32
|
+
: new LocalHistoryProvider(ctx.localCache.dbHandle(), options.window)
|
|
33
|
+
let table: HistoryTable
|
|
34
|
+
try {
|
|
35
|
+
table = await provider.loadFor([...nodes.keys()])
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// Failing open: a broken history read costs the ordering, never the run.
|
|
38
|
+
ctx.warn(
|
|
39
|
+
`[vx] schedule-history: falling back to the baseline order: ${err instanceof Error ? err.message : String(err)}`,
|
|
40
|
+
)
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
return criticalPathPriorities([...nodes.values()], table)
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* For each node, the expected remaining critical-path duration: its own p50
|
|
50
|
+
* plus the maximum over its dependents. A node with no history takes the
|
|
51
|
+
* workspace median; an empty history takes a flat default.
|
|
52
|
+
*/
|
|
53
|
+
export function criticalPathPriorities(
|
|
54
|
+
nodes: readonly TaskNode[],
|
|
55
|
+
history: HistoryTable,
|
|
56
|
+
): ReadonlyMap<string, number> {
|
|
57
|
+
const p50s: number[] = []
|
|
58
|
+
for (const h of history.values()) {
|
|
59
|
+
if (h.p50DurationMs !== undefined) p50s.push(h.p50DurationMs)
|
|
60
|
+
}
|
|
61
|
+
p50s.sort((a, b) => a - b)
|
|
62
|
+
const workspaceMedian =
|
|
63
|
+
p50s.length > 0
|
|
64
|
+
? (p50s[Math.floor(p50s.length / 2)] ?? DEFAULT_DURATION_MS)
|
|
65
|
+
: DEFAULT_DURATION_MS
|
|
66
|
+
|
|
67
|
+
const dependentsOf = new Map<string, string[]>()
|
|
68
|
+
for (const n of nodes) {
|
|
69
|
+
for (const upstreamId of n.deps) {
|
|
70
|
+
const list = dependentsOf.get(upstreamId)
|
|
71
|
+
if (list) list.push(n.id)
|
|
72
|
+
else dependentsOf.set(upstreamId, [n.id])
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const ownDuration = (n: TaskNode): number => history.get(n.id)?.p50DurationMs ?? workspaceMedian
|
|
76
|
+
|
|
77
|
+
// Reverse-topological pass: a node's value is final once every dependent's
|
|
78
|
+
// is, so start from the sinks (no dependents) and release each upstream
|
|
79
|
+
// when its last dependent has been scored.
|
|
80
|
+
const memo = new Map<string, number>()
|
|
81
|
+
const nodeById = new Map<string, TaskNode>(nodes.map((n) => [n.id, n]))
|
|
82
|
+
const pending = new Map<string, number>()
|
|
83
|
+
const queue: TaskNode[] = []
|
|
84
|
+
for (const n of nodes) {
|
|
85
|
+
const count = dependentsOf.get(n.id)?.length ?? 0
|
|
86
|
+
pending.set(n.id, count)
|
|
87
|
+
if (count === 0) queue.push(n)
|
|
88
|
+
}
|
|
89
|
+
let head = 0
|
|
90
|
+
while (head < queue.length) {
|
|
91
|
+
const n = queue[head++]!
|
|
92
|
+
let downstream = 0
|
|
93
|
+
for (const dep of dependentsOf.get(n.id) ?? []) {
|
|
94
|
+
const d = memo.get(dep) ?? 0
|
|
95
|
+
if (d > downstream) downstream = d
|
|
96
|
+
}
|
|
97
|
+
memo.set(n.id, ownDuration(n) + downstream)
|
|
98
|
+
for (const up of n.deps) {
|
|
99
|
+
const left = (pending.get(up) ?? 0) - 1
|
|
100
|
+
pending.set(up, left)
|
|
101
|
+
if (left === 0) {
|
|
102
|
+
const upNode = nodeById.get(up)
|
|
103
|
+
if (upNode) queue.push(upNode)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
for (const n of nodes) if (!memo.has(n.id)) memo.set(n.id, ownDuration(n))
|
|
108
|
+
return memo
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Levenshtein distance, capped: anything past two edits reads as 3. Used
|
|
3
|
+
* by the "did you mean" hints for task names and flags, where a hint
|
|
4
|
+
* beyond two edits would guess rather than help.
|
|
5
|
+
*/
|
|
6
|
+
export function editDistance(a: string, b: string): number {
|
|
7
|
+
if (Math.abs(a.length - b.length) > 2) return 3
|
|
8
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
9
|
+
for (let i = 1; i <= a.length; i++) {
|
|
10
|
+
const cur = [i]
|
|
11
|
+
for (let j = 1; j <= b.length; j++) {
|
|
12
|
+
cur[j] = Math.min(
|
|
13
|
+
prev[j]! + 1,
|
|
14
|
+
cur[j - 1]! + 1,
|
|
15
|
+
prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
prev = cur
|
|
19
|
+
}
|
|
20
|
+
return Math.min(prev[b.length]!, 3)
|
|
21
|
+
}
|
package/src/util/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Shared error types used across modules to distinguish user-input
|
|
2
2
|
// failures (clean message only) from internal bugs (full stack trace).
|
|
3
3
|
//
|
|
4
|
-
// `bin.ts`
|
|
4
|
+
// `bin.ts` and the scheduler consult `isUserError` to decide what to print.
|
|
5
5
|
|
|
6
6
|
export class UserError extends Error {
|
|
7
7
|
constructor(message: string) {
|
|
@@ -9,3 +9,17 @@ export class UserError extends Error {
|
|
|
9
9
|
this.name = 'UserError'
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
|
|
15
|
+
* core. A compiled `vx` binary carries core inside it while a plugin in the
|
|
16
|
+
* workspace imports `@vzn/vx` from node_modules, so a plugin's `UserError`
|
|
17
|
+
* is a different class object and `instanceof` is false — a plugin verb's
|
|
18
|
+
* "bad flag --x" printed as `UserError: bad flag --x` with a stack, and a
|
|
19
|
+
* REAPI refusal would have read as an "internal error" (reproduced through
|
|
20
|
+
* the real binary, 2026-09-03). The name is the contract that survives the
|
|
21
|
+
* copy boundary.
|
|
22
|
+
*/
|
|
23
|
+
export function isUserError(err: unknown): err is UserError {
|
|
24
|
+
return err instanceof UserError || (err instanceof Error && err.name === 'UserError')
|
|
25
|
+
}
|
package/src/util/index.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// Module contract. Cross-module imports must come through here; see
|
|
2
2
|
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
3
|
|
|
4
|
-
export { UserError } from './errors.js'
|
|
4
|
+
export { isUserError, UserError } from './errors.js'
|
|
5
5
|
export { xxh3, xxh3hex } from './hash.js'
|
|
6
|
-
export {
|
|
6
|
+
export { mark, printTimings, span } from './timing.js'
|
|
7
|
+
export { clampInt, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
|
|
8
|
+
export { relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.js'
|
|
9
|
+
export { settleWithin, teardownTimeoutMs } from './settle.js'
|
|
7
10
|
export { parseSize } from './size.js'
|
|
11
|
+
export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
|
|
8
12
|
export { ulid } from './ulid.js'
|
|
13
|
+
export { editDistance } from './edit-distance.js'
|
package/src/util/num.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The largest delay `setTimeout` actually honours: 2^31 - 1 ms (~24.8 days).
|
|
3
|
+
*
|
|
4
|
+
* A larger delay does NOT saturate and does NOT throw — it silently becomes
|
|
5
|
+
* **1 ms**, which is the exact INVERSE of what the caller asked for. A task
|
|
6
|
+
* declaring a 317-year timeout is SIGTERMed 4 ms after it spawns and reported
|
|
7
|
+
* `failed`, and the only clue is a `TimeoutOverflowWarning` on stderr that a CI
|
|
8
|
+
* log swallows. Every surface that accepts a millisecond delay bounds it
|
|
9
|
+
* against this, so "effectively no limit" can never mean "kill immediately".
|
|
10
|
+
*/
|
|
11
|
+
export const MAX_TIMEOUT_MS = 2 ** 31 - 1
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Clamp to an INTEGER in `[min, max]`; a non-finite value collapses to `min`.
|
|
15
|
+
*
|
|
16
|
+
* The floor is load-bearing wherever the result reaches SQL: a fractional
|
|
17
|
+
* `LIMIT` is a `datatype mismatch` error, not a smaller page.
|
|
18
|
+
*/
|
|
19
|
+
export function clampInt(n: number, min: number, max: number): number {
|
|
20
|
+
if (!Number.isFinite(n)) return min
|
|
21
|
+
return Math.min(max, Math.max(min, Math.floor(n)))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse a PLAIN decimal integer from user input; `null` for anything else.
|
|
26
|
+
*
|
|
27
|
+
* `Number()` is the wrong tool at an argument boundary: it silently accepts
|
|
28
|
+
* hex (`0x10` → 16), exponents (`1e3` → 1000), fractions, a leading `+`, and
|
|
29
|
+
* surrounding whitespace — so a typo becomes a different number instead of an
|
|
30
|
+
* error. Values past `Number.MAX_SAFE_INTEGER` are rejected too: they parse to
|
|
31
|
+
* a number the user did not type (`9007199254740993` → `…92`).
|
|
32
|
+
*/
|
|
33
|
+
export function parseDecimalInt(input: string): number | null {
|
|
34
|
+
if (!/^\d+$/.test(input)) return null
|
|
35
|
+
const n = Number(input)
|
|
36
|
+
return Number.isSafeInteger(n) ? n : null
|
|
37
|
+
}
|
package/src/util/paths.ts
CHANGED
|
@@ -14,3 +14,46 @@ export function toPosix(p: string): string {
|
|
|
14
14
|
export function relPosix(from: string, to: string): string {
|
|
15
15
|
return toPosix(path.relative(from, to))
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The leading, wildcard-free part of a glob, trimmed back to whole path
|
|
20
|
+
* components (`dist/sub-**` → `dist`). `.` when the first component is
|
|
21
|
+
* already a wildcard, i.e. the glob can reach anything in its anchor dir.
|
|
22
|
+
*
|
|
23
|
+
* Two callers with different needs share it deliberately: the sandbox
|
|
24
|
+
* baseline joins it onto a dir to get a write prefix, and the deferral
|
|
25
|
+
* eligibility gate compares two of them for overlap. A second copy is
|
|
26
|
+
* how the two would disagree about what a prefix is.
|
|
27
|
+
*/
|
|
28
|
+
export function staticPrefix(glob: string): string {
|
|
29
|
+
const wildcardIdx = glob.search(/[*?[\]]/)
|
|
30
|
+
if (wildcardIdx === -1) return glob
|
|
31
|
+
const head = glob.slice(0, wildcardIdx)
|
|
32
|
+
const lastSep = head.lastIndexOf('/')
|
|
33
|
+
if (lastSep === -1) return '.'
|
|
34
|
+
return head.slice(0, lastSep) || '/'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The directories a task's declared outputs cover WHOLE — every glob is
|
|
39
|
+
* `<dir>/**` with a plain, non-root, non-escaping `<dir>` — or `null` when
|
|
40
|
+
* any glob is shaped otherwise. The directory-mtime short-circuit on a warm
|
|
41
|
+
* hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees: with
|
|
42
|
+
* every directory under `<dir>` recorded, a file added or removed anywhere
|
|
43
|
+
* the glob could see bumps a recorded directory's mtime (its parent, or a
|
|
44
|
+
* new directory whose creation bumped a recorded ancestor). A root-anchored
|
|
45
|
+
* `**\/*.js` has no such closed set, so it keeps the walk.
|
|
46
|
+
*/
|
|
47
|
+
export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
|
|
48
|
+
if (globs.length === 0) return null
|
|
49
|
+
const out: string[] = []
|
|
50
|
+
for (const g of globs) {
|
|
51
|
+
const m = /^([^*?[\]{}!]+?)\/\*\*$/.exec(g)
|
|
52
|
+
if (m === null) return null
|
|
53
|
+
const dir = m[1]!.replace(/\/+$/, '')
|
|
54
|
+
if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
|
|
55
|
+
return null
|
|
56
|
+
out.push(dir)
|
|
57
|
+
}
|
|
58
|
+
return [...new Set(out)]
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// End-of-run settle bound. A plugin's flush/teardown is I/O a third party
|
|
2
|
+
// wrote; it must not hold the run's exit hostage.
|
|
3
|
+
|
|
4
|
+
import { MAX_TIMEOUT_MS } from './num.js'
|
|
5
|
+
|
|
6
|
+
/** Default upper bound on one end-of-run flush/teardown await. */
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 3000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The bound to apply, read per call so a test can drive the deadline
|
|
11
|
+
* instead of waiting it out.
|
|
12
|
+
*/
|
|
13
|
+
export function teardownTimeoutMs(): number {
|
|
14
|
+
const raw = process.env['VX_TEARDOWN_TIMEOUT_MS']
|
|
15
|
+
// Out-of-range falls back to the DEFAULT rather than clamping to
|
|
16
|
+
// MAX_TIMEOUT_MS, and the distinction matters. This value is a BOUND, not a
|
|
17
|
+
// duration: there is no "no limit" option here, because the whole reason the
|
|
18
|
+
// deadline exists is that a plugin's flush must not hold the run's exit
|
|
19
|
+
// hostage. Clamping would honour "wait ~24.8 days", which defeats it — the
|
|
20
|
+
// run would hang. Unbounded is worse still (the delay becomes 1 ms, so every
|
|
21
|
+
// flush times out and every buffered record is dropped). Falling back keeps
|
|
22
|
+
// the bound real in both directions.
|
|
23
|
+
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
24
|
+
const n = Number(raw)
|
|
25
|
+
if (n <= MAX_TIMEOUT_MS) return n
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_TIMEOUT_MS
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Await `p`, giving up after `ms`. Returns true when `p` settled first,
|
|
32
|
+
* false when the deadline won — the caller decides whether a lost result
|
|
33
|
+
* is worth reporting.
|
|
34
|
+
*/
|
|
35
|
+
export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
|
|
36
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
37
|
+
const deadline = new Promise<false>((resolve) => {
|
|
38
|
+
timer = setTimeout(() => resolve(false), ms)
|
|
39
|
+
})
|
|
40
|
+
try {
|
|
41
|
+
return (await Promise.race([p.then(() => true), deadline])) !== false
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer)
|
|
44
|
+
// A rejection landing after the deadline won already resolved the
|
|
45
|
+
// race must not surface as an unhandled-rejection crash.
|
|
46
|
+
void p.catch(() => {})
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/util/size.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseDecimalInt } from './num.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Parse a byte-size string: a bare integer (`"1048576"`) or an integer
|
|
3
5
|
* with a K/M/G/T suffix, powers of 1024, optional trailing `B`
|
|
@@ -8,7 +10,9 @@
|
|
|
8
10
|
export function parseSize(input: string): number | null {
|
|
9
11
|
const m = input.match(/^(\d+)([KMGT])?B?$/i)
|
|
10
12
|
if (!m) return null
|
|
11
|
-
|
|
13
|
+
// Digits past 2^53 parse to a different number than the user typed.
|
|
14
|
+
const n = parseDecimalInt(m[1]!)
|
|
15
|
+
if (n === null) return null
|
|
12
16
|
const u = (m[2] ?? '').toUpperCase()
|
|
13
17
|
const mult =
|
|
14
18
|
u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
|
package/src/util/tail.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Bounded head-evicting tail for one stream of one still-running task.
|
|
2
|
+
//
|
|
3
|
+
// A PERSISTENT task (dev server, watcher, daemon) is unbounded by
|
|
4
|
+
// construction: unlike a one-shot command, nothing about it terminating
|
|
5
|
+
// bounds how much it writes. Anything that accumulates its output therefore
|
|
6
|
+
// needs a cap, and the cap has to be honest — a truncated log that reads as
|
|
7
|
+
// complete is worse than one that says what it lost.
|
|
8
|
+
//
|
|
9
|
+
// `orchestrator/logger.ts` is the sole holder: it registers a tail per
|
|
10
|
+
// persistent task at taskStart and keeps one for the rest of the run, so a
|
|
11
|
+
// single mechanism covers both the pre-ready window and everything after.
|
|
12
|
+
// `exec/runner.ts` used to keep a SECOND pre-ready copy fed from the same
|
|
13
|
+
// chunks that nothing ever read; it was deleted rather than kept in sync,
|
|
14
|
+
// since two copies of this rule is how they drift — one grew a bound and
|
|
15
|
+
// the other did not, which is the defect this file was extracted to close
|
|
16
|
+
// (a never-ready `readyWhen` grew vx's heap ~100 MiB/s with the 64 KiB cap
|
|
17
|
+
// sitting unused, because it was only ever engaged once the task became
|
|
18
|
+
// ready). It stays in `util` because `exec` cannot import `orchestrator`,
|
|
19
|
+
// so a future second holder on that side has somewhere to import from.
|
|
20
|
+
|
|
21
|
+
/** Per-stream cap on retained output for a task that may never end. */
|
|
22
|
+
export const PERSISTENT_TAIL_CHARS = 64 * 1024
|
|
23
|
+
|
|
24
|
+
export interface Tail {
|
|
25
|
+
chunks: string[]
|
|
26
|
+
chars: number
|
|
27
|
+
/** Characters evicted from the head. Non-zero means the tail is partial. */
|
|
28
|
+
dropped: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createTail(): Tail {
|
|
32
|
+
return { chunks: [], chars: 0, dropped: 0 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS): void {
|
|
36
|
+
if (chunk.length === 0) return
|
|
37
|
+
t.chunks.push(chunk)
|
|
38
|
+
t.chars += chunk.length
|
|
39
|
+
// Evict WHOLE chunks from the head — no concatenation until the flush
|
|
40
|
+
// joins once, so a chatty server costs one array push per chunk.
|
|
41
|
+
while (t.chars > limit && t.chunks.length > 1) {
|
|
42
|
+
const gone = t.chunks.shift()!
|
|
43
|
+
t.chars -= gone.length
|
|
44
|
+
t.dropped += gone.length
|
|
45
|
+
}
|
|
46
|
+
// A single chunk over the cap is the one place we copy.
|
|
47
|
+
if (t.chars > limit) {
|
|
48
|
+
const only = t.chunks[0]!
|
|
49
|
+
const keep = only.slice(only.length - limit)
|
|
50
|
+
t.dropped += only.length - keep.length
|
|
51
|
+
t.chunks[0] = keep
|
|
52
|
+
t.chars = keep.length
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Join the retained text. The tail keeps its chunks — see `resetTail`. */
|
|
57
|
+
export function tailText(t: Tail): string {
|
|
58
|
+
return t.chunks.length === 1 ? t.chunks[0]! : t.chunks.join('')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop everything retained, keeping the tail usable for a new phase. */
|
|
62
|
+
export function resetTail(t: Tail): void {
|
|
63
|
+
t.chunks.length = 0
|
|
64
|
+
t.chars = 0
|
|
65
|
+
t.dropped = 0
|
|
66
|
+
}
|