@vzn/vx 0.0.12 → 0.0.14
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 +106 -89
- package/launcher.mjs +36 -28
- package/package.json +5 -5
- package/src/cache/cas-backend.ts +2 -2
- package/src/cache/index.ts +1 -2
- package/src/cache/inputs.ts +6 -0
- package/src/cache/layered-cache.ts +22 -2
- package/src/cli/cache.ts +5 -10
- package/src/cli/help.ts +8 -2
- package/src/cli/info.ts +0 -3
- package/src/cli/run.ts +32 -10
- package/src/config.ts +40 -1
- package/src/graph/dependency-spec.ts +16 -0
- package/src/graph/index.ts +10 -1
- package/src/graph/scheduler.ts +230 -35
- package/src/graph/task-graph.ts +69 -17
- package/src/index.ts +29 -4
- package/src/orchestrator/execute-task.ts +66 -10
- package/src/orchestrator/index.ts +11 -0
- package/src/orchestrator/metrics.ts +351 -0
- package/src/orchestrator/options.ts +26 -5
- package/src/orchestrator/plugin-host.ts +5 -10
- package/src/orchestrator/prepare.ts +20 -11
- package/src/orchestrator/protocol.ts +16 -2
- package/src/orchestrator/resources.ts +59 -0
- package/src/orchestrator/run.ts +45 -0
- package/src/orchestrator/summary.ts +24 -0
- package/src/orchestrator/task-hash.ts +17 -2
- package/src/orchestrator/telemetry.ts +9 -1
- package/src/orchestrator/upstream.ts +34 -6
- package/src/orchestrator/verify.ts +34 -11
- package/src/util/index.ts +1 -0
- package/src/util/size.ts +16 -0
- package/src/workspace/project-loader.ts +43 -1
- package/src/cache/remote-cache.ts +0 -251
- package/src/orchestrator/remote-cache-setup.ts +0 -60
package/src/orchestrator/run.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// run with caching. Each step delegates to a single-purpose sibling file
|
|
3
3
|
// so the layers can be swapped without touching the others.
|
|
4
4
|
|
|
5
|
+
import os from 'node:os'
|
|
5
6
|
import {
|
|
6
7
|
type CacheLayer,
|
|
7
8
|
type CachePolicy,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from '../graph/index.js'
|
|
22
23
|
import { ulid, UserError } from '../util/index.js'
|
|
23
24
|
import { executeTask } from './execute-task.js'
|
|
25
|
+
import { resolveResourceCosts } from './resources.js'
|
|
24
26
|
import { computeTaskHash } from './task-hash.js'
|
|
25
27
|
import { busLogger, createEventBus, terminalSubscriber } from './events.js'
|
|
26
28
|
import { installPlugins } from './plugin.js'
|
|
@@ -248,6 +250,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
248
250
|
const remoteCacheEnabled = cache instanceof LayeredCache
|
|
249
251
|
const policy: CachePolicy = options.cache ?? FULL_CACHE_POLICY
|
|
250
252
|
|
|
253
|
+
// `--verify` observes the miss-then-save path, so a policy with NO write
|
|
254
|
+
// axis (`--no-cache`, `--cache=local:,remote:`) verifies nothing — every
|
|
255
|
+
// task would come back green with zero verdicts. The user asked for a
|
|
256
|
+
// proof; silently skipping it is the one failure mode verification must
|
|
257
|
+
// never have (same platform-honesty rule as the sandbox-unavailable
|
|
258
|
+
// error). `--force --verify` is the supported re-verify-warm recipe.
|
|
259
|
+
if (options.verify !== undefined && !policy.localWrite && !policy.remoteWrite) {
|
|
260
|
+
prepared.cache.close()
|
|
261
|
+
throw new UserError(
|
|
262
|
+
'--verify needs cache writes to prove anything (it verifies the save path); ' +
|
|
263
|
+
'drop --no-cache, or use --force --verify to re-execute and verify a warm graph',
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
251
267
|
// Per-run context for the Tier-3 `invocations` header row. Captured
|
|
252
268
|
// ONCE (git is ONE spawn for commit+branch, behind try/catch; never
|
|
253
269
|
// fails a run). `dirty` reuses the `git status --porcelain` the
|
|
@@ -345,6 +361,17 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
345
361
|
if (node.requested || node.surfaced === true) requestedCount++
|
|
346
362
|
}
|
|
347
363
|
}
|
|
364
|
+
// Resource-aware admission: resolve every task's `exec.resources`
|
|
365
|
+
// into absolute costs ONCE, up front (percent forms against the
|
|
366
|
+
// budgets), so the scheduler's inner loop is a plain Map.get. The
|
|
367
|
+
// CPU budget is the run's concurrency; the memory budget is
|
|
368
|
+
// os.totalmem() unless `--memory` overrides it (pass `--memory` in
|
|
369
|
+
// cgroup-limited containers — totalmem() reports the HOST's RAM).
|
|
370
|
+
// Nothing declared → empty map → fields omitted from the scheduler
|
|
371
|
+
// AND the footer → byte-identical legacy path.
|
|
372
|
+
const memBudget = options.memory ?? os.totalmem()
|
|
373
|
+
const resourceCosts = resolveResourceCosts(nodes, concurrency, memBudget)
|
|
374
|
+
|
|
348
375
|
// Run context for the footer. The top-of-run header is gone — the
|
|
349
376
|
// banner now lives in the summary, where the eye lands at the end.
|
|
350
377
|
const runContext = {
|
|
@@ -353,6 +380,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
353
380
|
remoteCacheEnabled,
|
|
354
381
|
concurrency,
|
|
355
382
|
workspaceProjectCount,
|
|
383
|
+
...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
|
|
356
384
|
}
|
|
357
385
|
|
|
358
386
|
// Lifecycle hooks drive the default logger's dynamic status line
|
|
@@ -501,6 +529,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
501
529
|
const outcomes = await runGraph({
|
|
502
530
|
nodes,
|
|
503
531
|
concurrency,
|
|
532
|
+
...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
|
|
504
533
|
...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
|
|
505
534
|
onStart: (node) => {
|
|
506
535
|
log.taskStart?.(node)
|
|
@@ -586,6 +615,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
586
615
|
for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
|
|
587
616
|
if (options.verify !== undefined) {
|
|
588
617
|
for (const line of formatVerifySection(list)) log.status(line)
|
|
618
|
+
// A fingerprint-only run attaches no verdicts, so the verdict-driven
|
|
619
|
+
// section above prints nothing — report what actually happened.
|
|
620
|
+
if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
|
|
621
|
+
const n = list.filter((o) => o.outputFp !== undefined).length
|
|
622
|
+
log.status('')
|
|
623
|
+
log.status(
|
|
624
|
+
` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
|
|
625
|
+
)
|
|
626
|
+
// Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
|
|
627
|
+
// A per-platform matrix wired without `--force` produces nothing
|
|
628
|
+
// forever, so name the cause instead of a bare 0.
|
|
629
|
+
if (n === 0) {
|
|
630
|
+
log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
|
|
631
|
+
}
|
|
632
|
+
}
|
|
589
633
|
}
|
|
590
634
|
|
|
591
635
|
// Optional artifacts. Errors are surfaced to the user but don't
|
|
@@ -658,6 +702,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
|
|
|
658
702
|
if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
|
|
659
703
|
if (o.attempts !== undefined) t.attempts = o.attempts
|
|
660
704
|
if (o.verify !== undefined) t.verify = o.verify
|
|
705
|
+
if (o.outputFp !== undefined) t.outputFp = o.outputFp
|
|
661
706
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
662
707
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
663
708
|
summaryTasks.push(t)
|
|
@@ -125,6 +125,14 @@ export interface RunContext {
|
|
|
125
125
|
remoteCacheEnabled: boolean
|
|
126
126
|
/** Total projects discovery found — the bar's denominator. */
|
|
127
127
|
workspaceProjectCount?: number
|
|
128
|
+
/**
|
|
129
|
+
* Resource-admission budgets (`exec.resources`), set ONLY when at
|
|
130
|
+
* least one task declared a reservation — a plain run's footer is
|
|
131
|
+
* byte-identical. Shown on the `info` row so a packed run says what
|
|
132
|
+
* it packed against. memBudget in bytes.
|
|
133
|
+
*/
|
|
134
|
+
cpuBudget?: number
|
|
135
|
+
memBudget?: number
|
|
128
136
|
}
|
|
129
137
|
|
|
130
138
|
export function formatSummarySection(
|
|
@@ -240,6 +248,9 @@ export function formatSummarySection(
|
|
|
240
248
|
if (context.concurrency !== undefined)
|
|
241
249
|
info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
|
|
242
250
|
info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
|
|
251
|
+
if (context.cpuBudget !== undefined) info.push(`cpu budget ${context.cpuBudget}`)
|
|
252
|
+
if (context.memBudget !== undefined && Number.isFinite(context.memBudget))
|
|
253
|
+
info.push(`mem budget ${formatBudgetBytes(context.memBudget)}`)
|
|
243
254
|
lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
|
|
244
255
|
} else {
|
|
245
256
|
lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
|
|
@@ -247,6 +258,19 @@ export function formatSummarySection(
|
|
|
247
258
|
return lines
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
262
|
+
// (module boundary), and the footer only needs one compact form.
|
|
263
|
+
function formatBudgetBytes(bytes: number): string {
|
|
264
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
265
|
+
let v = bytes
|
|
266
|
+
let u = 0
|
|
267
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
268
|
+
v /= 1024
|
|
269
|
+
u++
|
|
270
|
+
}
|
|
271
|
+
return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`
|
|
272
|
+
}
|
|
273
|
+
|
|
250
274
|
export function formatRunSummary(
|
|
251
275
|
outcomes: readonly TaskOutcome[],
|
|
252
276
|
totalMs: number,
|
|
@@ -170,11 +170,26 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
170
170
|
if (hashCache) {
|
|
171
171
|
const cached = hashCache.taskConfig.get(cfg)
|
|
172
172
|
if (cached !== undefined) return cached
|
|
173
|
-
const hash = xxh3hex(JSON.stringify(cfg))
|
|
173
|
+
const hash = xxh3hex(JSON.stringify(hashableConfig(cfg)))
|
|
174
174
|
hashCache.taskConfig.set(cfg, hash)
|
|
175
175
|
return hash
|
|
176
176
|
}
|
|
177
|
-
return xxh3hex(JSON.stringify(cfg))
|
|
177
|
+
return xxh3hex(JSON.stringify(hashableConfig(cfg)))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Project the config for hashing: `exec.resources` is a pure scheduling
|
|
182
|
+
* hint with zero effect on outputs, so it's stripped — tuning a
|
|
183
|
+
* reservation never busts a cache. A config that declares none takes the
|
|
184
|
+
* fast path and stringifies byte-identically to before the field existed
|
|
185
|
+
* (why this needs no CACHE_VERSION bump). `timeout`/`retries` stay folded
|
|
186
|
+
* — their keys are distinct by design (see the decision log); stripping
|
|
187
|
+
* them retroactively would bump CACHE_VERSION.
|
|
188
|
+
*/
|
|
189
|
+
function hashableConfig(cfg: TaskConfig): unknown {
|
|
190
|
+
if (cfg.exec?.resources === undefined) return cfg
|
|
191
|
+
const { resources: _resources, ...execRest } = cfg.exec
|
|
192
|
+
return { ...cfg, exec: execRest }
|
|
178
193
|
}
|
|
179
194
|
|
|
180
195
|
/**
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// what or how tasks run. Contrast `backend`/`cache`, which return objects
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
|
-
import type { TaskStatus, VerifyVerdict } from '../graph/index.js'
|
|
19
|
+
import type { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
|
|
20
20
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
21
21
|
|
|
22
22
|
/** Bumped when the record shape changes. Readers MUST check `v`. */
|
|
@@ -103,6 +103,13 @@ export interface TaskTelemetry {
|
|
|
103
103
|
* unsound (its outputs aren't a pure function of its declared inputs); this
|
|
104
104
|
* is the telemetry-side hermeticity signal a dashboard surfaces. */
|
|
105
105
|
verify?: VerifyVerdict
|
|
106
|
+
/** Output-tree fingerprint — set ONLY under a fingerprinting `--verify*`
|
|
107
|
+
* mode (`--verify` / `=all` / `=fingerprint`), executed tasks only. A
|
|
108
|
+
* connected serve pairs fingerprints for the SAME `hash` across platforms
|
|
109
|
+
* and names diverging outputs (the cross-machine diff). Absent on plain
|
|
110
|
+
* runs — additive-optional, no schema bump (the `attempts`/`verify`
|
|
111
|
+
* precedent). */
|
|
112
|
+
outputFp?: OutputFingerprint
|
|
106
113
|
/** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
|
|
107
114
|
wallclockStartNs?: string
|
|
108
115
|
wallclockEndNs?: string
|
|
@@ -304,6 +311,7 @@ export function createTelemetrySource(args: {
|
|
|
304
311
|
if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
|
|
305
312
|
if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
|
|
306
313
|
if (outcome.verify !== undefined) rec.verify = outcome.verify
|
|
314
|
+
if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
|
|
307
315
|
if (outcome.wallclockStartNs !== undefined)
|
|
308
316
|
rec.wallclockStartNs = outcome.wallclockStartNs.toString()
|
|
309
317
|
if (outcome.wallclockEndNs !== undefined)
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
DependencySpecError,
|
|
3
|
+
compileTaskPattern,
|
|
4
|
+
isTaskPattern,
|
|
3
5
|
parseDependencySpec,
|
|
4
6
|
type DependencySpec,
|
|
5
7
|
type TaskOutcome,
|
|
@@ -22,6 +24,11 @@ import { UserError } from '../util/index.js'
|
|
|
22
24
|
* 'name' same-project task `name`
|
|
23
25
|
* '^name' `name` task in every dep workspace
|
|
24
26
|
* 'pkg#name' specific package's `name` task
|
|
27
|
+
* 'name.*' task-NAME patterns — the task half of any form above may
|
|
28
|
+
* contain `*` (same glob as dependsOn patterns). A filter
|
|
29
|
+
* that matched literally here while dependsOn expanded the
|
|
30
|
+
* same string would silently select ZERO upstream hashes —
|
|
31
|
+
* a stale-hit trap, so the two surfaces share the matcher.
|
|
25
32
|
* '!<form>' exclude — any of the above with a leading `!`
|
|
26
33
|
*
|
|
27
34
|
* Patterns are applied in order; last write wins, so
|
|
@@ -62,14 +69,19 @@ export function filterUpstreamHashes(
|
|
|
62
69
|
}
|
|
63
70
|
})
|
|
64
71
|
|
|
72
|
+
// Per-spec task-name matcher, compiled once (exact compare or pattern).
|
|
73
|
+
const matchers = specs.map((spec) => taskMatcher(spec))
|
|
74
|
+
|
|
65
75
|
// Dedup by hash (the key fold's unit), but remember the first task id
|
|
66
76
|
// seen for each hash so the diff row can name the upstream.
|
|
67
77
|
const selected = new Map<string, string>()
|
|
68
|
-
for (
|
|
78
|
+
for (let i = 0; i < specs.length; i++) {
|
|
79
|
+
const spec = specs[i]!
|
|
80
|
+
const taskMatches = matchers[i]!
|
|
69
81
|
for (const u of upstream) {
|
|
70
82
|
if (!u.hash) continue
|
|
71
83
|
const isSelf = u.node.projectName === selfProjectName
|
|
72
|
-
if (!matches(spec, u, isSelf)) continue
|
|
84
|
+
if (!matches(spec, u, isSelf, taskMatches)) continue
|
|
73
85
|
if (spec.negated) selected.delete(u.hash)
|
|
74
86
|
else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
|
|
75
87
|
}
|
|
@@ -77,17 +89,33 @@ export function filterUpstreamHashes(
|
|
|
77
89
|
return [...selected].map(([hash, id]) => [id, hash])
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
|
|
92
|
+
/** Exact-name compare, or the shared `*`-glob when the form is a pattern. */
|
|
93
|
+
function taskMatcher(spec: DependencySpec): (name: string) => boolean {
|
|
94
|
+
if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') return () => true
|
|
95
|
+
if (isTaskPattern(spec.task)) {
|
|
96
|
+
const re = compileTaskPattern(spec.task)
|
|
97
|
+
return (name) => re.test(name)
|
|
98
|
+
}
|
|
99
|
+
const exact = spec.task
|
|
100
|
+
return (name) => name === exact
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function matches(
|
|
104
|
+
spec: DependencySpec,
|
|
105
|
+
u: TaskOutcome,
|
|
106
|
+
isSelf: boolean,
|
|
107
|
+
taskMatches: (name: string) => boolean,
|
|
108
|
+
): boolean {
|
|
81
109
|
switch (spec.kind) {
|
|
82
110
|
case 'wildcardSelf':
|
|
83
111
|
return isSelf
|
|
84
112
|
case 'wildcardDeps':
|
|
85
113
|
return !isSelf
|
|
86
114
|
case 'self':
|
|
87
|
-
return isSelf && u.node.taskName
|
|
115
|
+
return isSelf && taskMatches(u.node.taskName)
|
|
88
116
|
case 'deps':
|
|
89
|
-
return !isSelf && u.node.taskName
|
|
117
|
+
return !isSelf && taskMatches(u.node.taskName)
|
|
90
118
|
case 'cross':
|
|
91
|
-
return u.node.projectName === spec.project && u.node.taskName
|
|
119
|
+
return u.node.projectName === spec.project && taskMatches(u.node.taskName)
|
|
92
120
|
}
|
|
93
121
|
}
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// See docs/design/cache-correctness-2026-07.md.
|
|
7
7
|
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import type {
|
|
10
|
-
import
|
|
9
|
+
import type { OutputFingerprint, TaskOutcome, VerifyVerdict } from '../graph/index.js'
|
|
10
|
+
import { xxh3hex } from '../util/index.js'
|
|
11
11
|
|
|
12
12
|
/** An output file to fingerprint: absolute path + a stable, run-independent
|
|
13
13
|
* key (project-relative, or `workspace-outputs/<rel-to-root>` for workspace
|
|
@@ -35,19 +35,42 @@ export function outputRefs(
|
|
|
35
35
|
]
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/** Content-fingerprint an output tree: key →
|
|
39
|
-
*
|
|
40
|
-
* mtime
|
|
41
|
-
*
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
): Promise<Map<string, string>> {
|
|
38
|
+
/** Content-fingerprint an output tree: key → xxh3 of the file's BYTES, read
|
|
39
|
+
* unconditionally from disk. Deliberately NOT `Cache.hashFile`: its
|
|
40
|
+
* mtime+size memo would return attempt 1's digest for a re-run output with
|
|
41
|
+
* equal size and mtime (e.g. a build that normalizes mtimes — standard
|
|
42
|
+
* reproducible-build practice), silently proving a divergent task
|
|
43
|
+
* deterministic. fp1/fp2 are only ever compared to each other, so any
|
|
44
|
+
* content hash works — and the proof must not trust a cache. */
|
|
45
|
+
export async function hashOutputTree(refs: readonly OutputRef[]): Promise<Map<string, string>> {
|
|
46
46
|
const out = new Map<string, string>()
|
|
47
|
-
for (const r of refs)
|
|
47
|
+
for (const r of refs) {
|
|
48
|
+
out.set(r.key, xxh3hex(new Uint8Array(await Bun.file(r.abs).arrayBuffer())))
|
|
49
|
+
}
|
|
48
50
|
return out
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
/** Per-file map cap on a shipped fingerprint (~40 KB at the cap). The tree
|
|
54
|
+
* digest always folds ALL entries, so detection never depends on the map. */
|
|
55
|
+
export const FP_MAX_FILES = 500
|
|
56
|
+
|
|
57
|
+
/** Roll an output-tree fingerprint map into the shippable `OutputFingerprint`
|
|
58
|
+
* payload (Phase 4, cross-machine diff): a tree digest over ALL sorted
|
|
59
|
+
* entries folded `key\0hash\n` (\0 boundaries so parts can't alias), plus
|
|
60
|
+
* the first-`cap` pairs — deterministic truncation, so two machines'
|
|
61
|
+
* truncated maps cover the same key subset and stay diffable. */
|
|
62
|
+
export function foldFingerprint(fp: Map<string, string>, cap = FP_MAX_FILES): OutputFingerprint {
|
|
63
|
+
const keys = [...fp.keys()].sort()
|
|
64
|
+
let folded = ''
|
|
65
|
+
for (const k of keys) folded += `${k}\0${fp.get(k)!}\n`
|
|
66
|
+
return {
|
|
67
|
+
tree: xxh3hex(folded),
|
|
68
|
+
fileCount: keys.length,
|
|
69
|
+
files: keys.slice(0, cap).map((k) => [k, fp.get(k)!] as const),
|
|
70
|
+
...(keys.length > cap ? { truncated: true } : {}),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
51
74
|
/** The output keys whose content OID differs between two fingerprints
|
|
52
75
|
* (added, removed, or changed), sorted. */
|
|
53
76
|
export function diffOutputTrees(a: Map<string, string>, b: Map<string, string>): string[] {
|
package/src/util/index.ts
CHANGED
package/src/util/size.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a byte-size string: a bare integer (`"1048576"`) or an integer
|
|
3
|
+
* with a K/M/G/T suffix, powers of 1024, optional trailing `B`
|
|
4
|
+
* (`"512MB"`, `"8G"`). Returns `null` for anything else — including
|
|
5
|
+
* fractional sizes (`"1.5GB"`); callers wanting fractions must express
|
|
6
|
+
* them in a smaller unit.
|
|
7
|
+
*/
|
|
8
|
+
export function parseSize(input: string): number | null {
|
|
9
|
+
const m = input.match(/^(\d+)([KMGT])?B?$/i)
|
|
10
|
+
if (!m) return null
|
|
11
|
+
const n = Number(m[1])
|
|
12
|
+
const u = (m[2] ?? '').toUpperCase()
|
|
13
|
+
const mult =
|
|
14
|
+
u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
|
|
15
|
+
return n * mult
|
|
16
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import type { ProjectConfig, WorkspaceConfig } from '../config.js'
|
|
3
|
-
import { UserError, xxh3hex } from '../util/index.js'
|
|
3
|
+
import { parseSize, UserError, xxh3hex } from '../util/index.js'
|
|
4
4
|
|
|
5
5
|
const WORKSPACE_CONFIG_FILENAMES = [
|
|
6
6
|
'vx.workspace.ts',
|
|
@@ -187,6 +187,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
|
|
|
187
187
|
throw new UserError(`${where}.exec.retries must be a non-negative integer`)
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
|
+
const resources = (exec as { resources?: unknown }).resources
|
|
191
|
+
if (resources !== undefined) {
|
|
192
|
+
validateResources(resources, `${where}.exec.resources`)
|
|
193
|
+
}
|
|
190
194
|
const persistent = (exec as { persistent?: unknown }).persistent
|
|
191
195
|
if (persistent !== undefined) {
|
|
192
196
|
if (typeof persistent !== 'object' || persistent === null) {
|
|
@@ -454,6 +458,44 @@ function validateSandbox(sandbox: unknown, where: string, hasExec: boolean): voi
|
|
|
454
458
|
}
|
|
455
459
|
}
|
|
456
460
|
|
|
461
|
+
const RESOURCES_FIELDS = new Set(['cpus', 'memory'])
|
|
462
|
+
const PERCENT_RE = /^\d+(\.\d+)?%$/
|
|
463
|
+
|
|
464
|
+
function validateResources(resources: unknown, where: string): void {
|
|
465
|
+
if (typeof resources !== 'object' || resources === null || Array.isArray(resources)) {
|
|
466
|
+
throw new UserError(`${where} must be an object (e.g. \`{ cpus: 2, memory: '2GB' }\`)`)
|
|
467
|
+
}
|
|
468
|
+
for (const key of Object.keys(resources as object)) {
|
|
469
|
+
if (!RESOURCES_FIELDS.has(key)) {
|
|
470
|
+
throw new UserError(
|
|
471
|
+
`${where} has unknown field "${key}". Allowed: ${[...RESOURCES_FIELDS].sort().join(', ')}`,
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const { cpus, memory } = resources as { cpus?: unknown; memory?: unknown }
|
|
476
|
+
if (cpus !== undefined) {
|
|
477
|
+
const ok =
|
|
478
|
+
typeof cpus === 'number'
|
|
479
|
+
? Number.isFinite(cpus) && cpus >= 0
|
|
480
|
+
: typeof cpus === 'string' && PERCENT_RE.test(cpus)
|
|
481
|
+
if (!ok) {
|
|
482
|
+
throw new UserError(`${where}.cpus must be a non-negative number or a "<n>%" string`)
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (memory !== undefined) {
|
|
486
|
+
const ok =
|
|
487
|
+
typeof memory === 'number'
|
|
488
|
+
? Number.isInteger(memory) && memory >= 0
|
|
489
|
+
: typeof memory === 'string' && (PERCENT_RE.test(memory) || parseSize(memory) !== null)
|
|
490
|
+
if (!ok) {
|
|
491
|
+
throw new UserError(
|
|
492
|
+
`${where}.memory must be a non-negative integer (bytes), ` +
|
|
493
|
+
`a size string like "512MB", or a "<n>%" string`,
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
457
499
|
function assertStringArray(v: unknown, where: string): void {
|
|
458
500
|
if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
|
|
459
501
|
throw new UserError(`${where} must be an array of non-empty strings`)
|