@vzn/vx 0.0.86 → 0.0.88
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/package.json +5 -5
- package/src/cache/cache.ts +28 -3
- package/src/exec/executor.ts +5 -3
- package/src/exec/index.ts +1 -1
- package/src/exec/local-executor.ts +14 -1
- package/src/exec/sandbox-runtime.ts +59 -2
- package/src/orchestrator/execute-task.ts +48 -3
- package/src/orchestrator/local-shortcircuit.ts +0 -1
- package/src/orchestrator/metrics.ts +7 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.88",
|
|
4
4
|
"description": "An open, extensible monorepo task runner.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
"node": ">=18"
|
|
18
18
|
},
|
|
19
19
|
"optionalDependencies": {
|
|
20
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.88",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.88",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.88",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.88"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -1127,10 +1127,35 @@ export class Cache implements CacheLayer {
|
|
|
1127
1127
|
*/
|
|
1128
1128
|
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
1129
1129
|
const tmpPath = this.tempPath(hash)
|
|
1130
|
+
// The compressed bytes are bounded too, before the decode's ceiling ever
|
|
1131
|
+
// sees them: a remote body that never ends (a hostile or broken server)
|
|
1132
|
+
// was written to the temp until the disk was full (L-5). No artifact
|
|
1133
|
+
// under the ceiling compresses past zstd's own bound on it.
|
|
1134
|
+
const cap = this.artifactCeiling + (this.artifactCeiling >> 8) + 64 * 1024
|
|
1135
|
+
const past = (): CorruptArtifactError =>
|
|
1136
|
+
new CorruptArtifactError(
|
|
1137
|
+
hash,
|
|
1138
|
+
`remote body runs past ${cap} bytes (the artifact ceiling's bound)`,
|
|
1139
|
+
)
|
|
1130
1140
|
try {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1141
|
+
if (body instanceof Blob) {
|
|
1142
|
+
if (body.size > cap) throw past()
|
|
1143
|
+
await Bun.write(tmpPath, body)
|
|
1144
|
+
} else if (Number(body.headers.get('content-length') ?? 0) > cap) {
|
|
1145
|
+
throw past()
|
|
1146
|
+
} else {
|
|
1147
|
+
let n = 0
|
|
1148
|
+
const counted = body.body?.pipeThrough(
|
|
1149
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
1150
|
+
transform(chunk, controller) {
|
|
1151
|
+
n += chunk.byteLength
|
|
1152
|
+
if (n > cap) controller.error(past())
|
|
1153
|
+
else controller.enqueue(chunk)
|
|
1154
|
+
},
|
|
1155
|
+
}),
|
|
1156
|
+
)
|
|
1157
|
+
await Bun.write(tmpPath, new Response(counted ?? null))
|
|
1158
|
+
}
|
|
1134
1159
|
} catch (err) {
|
|
1135
1160
|
await unlink(tmpPath).catch(() => undefined)
|
|
1136
1161
|
throw err
|
package/src/exec/executor.ts
CHANGED
|
@@ -142,9 +142,11 @@ export interface ExecuteRequest {
|
|
|
142
142
|
/**
|
|
143
143
|
* Aborted when the run stops (Ctrl-C, SIGTERM, SIGHUP or an embedder's
|
|
144
144
|
* `RunOptions.signal`) or when `timeoutMs` elapses. An executor ends its
|
|
145
|
-
* work and returns
|
|
146
|
-
*
|
|
147
|
-
*
|
|
145
|
+
* work and returns: a child an executor spawned is not one core can
|
|
146
|
+
* reach (H-10, H-12). A non-zero exit after the timeout's abort is
|
|
147
|
+
* recorded `timedOut`. Core waits the kill grace (`VX_KILL_GRACE_MS`,
|
|
148
|
+
* 2 s) after the abort, then settles the attempt without `execute` and
|
|
149
|
+
* abandons it (H-14).
|
|
148
150
|
*/
|
|
149
151
|
readonly signal?: AbortSignal
|
|
150
152
|
/** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
|
package/src/exec/index.ts
CHANGED
|
@@ -30,7 +30,7 @@ export {
|
|
|
30
30
|
export { type DeniedCall } from './sandbox-violations.js'
|
|
31
31
|
export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
|
|
32
32
|
export { bindableWrites, punchWalls } from './sandbox-binds.js'
|
|
33
|
-
export { localExecutor } from './local-executor.js'
|
|
33
|
+
export { isLocalExecutor, localExecutor } from './local-executor.js'
|
|
34
34
|
export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
|
|
35
35
|
export {
|
|
36
36
|
assertExecuteResult,
|
|
@@ -8,9 +8,20 @@ import { runCommand } from './runner.js'
|
|
|
8
8
|
import { runSandboxed } from './sandbox-runtime.js'
|
|
9
9
|
import type { TaskExecutor } from './executor.js'
|
|
10
10
|
|
|
11
|
+
const locals = new WeakSet<TaskExecutor>()
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether `executor` is core's own. By identity: a plugin may name its
|
|
15
|
+
* executor 'local', and core bounds a plugin's `execute` after an abort,
|
|
16
|
+
* which the local one (it SIGKILLs its own group) never needs.
|
|
17
|
+
*/
|
|
18
|
+
export function isLocalExecutor(executor: TaskExecutor): boolean {
|
|
19
|
+
return locals.has(executor)
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
/** The local executor. Accepts every task. */
|
|
12
23
|
export function localExecutor(): TaskExecutor {
|
|
13
|
-
|
|
24
|
+
const executor: TaskExecutor = {
|
|
14
25
|
name: 'local',
|
|
15
26
|
async execute(req) {
|
|
16
27
|
const common = {
|
|
@@ -38,4 +49,6 @@ export function localExecutor(): TaskExecutor {
|
|
|
38
49
|
})
|
|
39
50
|
},
|
|
40
51
|
}
|
|
52
|
+
locals.add(executor)
|
|
53
|
+
return executor
|
|
41
54
|
}
|
|
@@ -538,6 +538,8 @@ export interface ResolvedSandboxConfig {
|
|
|
538
538
|
gitConfig?: boolean
|
|
539
539
|
weakerWhenNested?: boolean
|
|
540
540
|
weakerNetworkIsolation?: boolean
|
|
541
|
+
/** macOS: the walls a glob grant reaches, by kind (`darwinWallRules`). */
|
|
542
|
+
wallsReached?: { read: readonly string[]; write: readonly string[] }
|
|
541
543
|
ignore?: {
|
|
542
544
|
read?: readonly string[]
|
|
543
545
|
write?: readonly string[]
|
|
@@ -645,6 +647,11 @@ export function resolveSandboxConfig(
|
|
|
645
647
|
walls,
|
|
646
648
|
),
|
|
647
649
|
}
|
|
650
|
+
if (process.platform === 'darwin') {
|
|
651
|
+
const read = wallsGlobsReach(r.allowRead, walls)
|
|
652
|
+
const write = wallsGlobsReach(r.allowWrite, walls)
|
|
653
|
+
if (read.length + write.length > 0) r.wallsReached = { read, write }
|
|
654
|
+
}
|
|
648
655
|
if (a.network !== undefined) r.network = a.network
|
|
649
656
|
if (cfg.deny?.network !== undefined) r.denyNetwork = cfg.deny.network
|
|
650
657
|
if (a.systemInfo !== undefined) r.systemInfo = a.systemInfo
|
|
@@ -788,7 +795,10 @@ export async function wrapSandboxedCommand(
|
|
|
788
795
|
ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
|
|
789
796
|
let wrapped = await SandboxManager.wrapWithSandbox(inner, undefined, customConfig)
|
|
790
797
|
if (process.platform === 'darwin') {
|
|
791
|
-
const rules =
|
|
798
|
+
const rules = [
|
|
799
|
+
...macProfileRules(args.config),
|
|
800
|
+
...darwinWallRules(args.config, baselines.allowRead),
|
|
801
|
+
]
|
|
792
802
|
if (rules.length > 0) wrapped = injectProfileRules(wrapped, rules)
|
|
793
803
|
}
|
|
794
804
|
// Linux: the shell execs bwrap, so bwrap is the spawn itself and its
|
|
@@ -1528,7 +1538,54 @@ function injectProfileRules(wrapped: string, rules: readonly string[]): string {
|
|
|
1528
1538
|
* that broke it learned so from its OWN tool. Measured, one task per
|
|
1529
1539
|
* spelling, each writing files it declares:
|
|
1530
1540
|
*
|
|
1531
|
-
* write: ['g/**
|
|
1541
|
+
* write: ['g/**
|
|
1542
|
+
* The walls a glob grant can reach: each one under (or at) a glob's
|
|
1543
|
+
* literal head. On Linux `expandGrants` drops such a hit before the bind
|
|
1544
|
+
* (B-1); seatbelt matches a glob as a path regex, with no hit to drop.
|
|
1545
|
+
*/
|
|
1546
|
+
function wallsGlobsReach(grants: readonly string[], walls: readonly string[]): string[] {
|
|
1547
|
+
const heads = grants
|
|
1548
|
+
.filter((g) => !isMountableLiteral(g))
|
|
1549
|
+
.map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
|
|
1550
|
+
return walls.filter((w) =>
|
|
1551
|
+
heads.some((h) => w === h || w.startsWith(h === path.sep ? h : h + path.sep)),
|
|
1552
|
+
)
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
/**
|
|
1556
|
+
* macOS: the walls a glob grant reaches, denied at the profile's tail
|
|
1557
|
+
* (SBPL is last-match-wins). SRT re-emits a wall's deny after the allows
|
|
1558
|
+
* only under a LITERAL allow, so a root project's `read: ['**\/*.ts']`
|
|
1559
|
+
* read nested projects' sources its key excludes — item 1010's stale hit,
|
|
1560
|
+
* by glob, on darwin (B-12). A literal grant at or inside the wall, the
|
|
1561
|
+
* task's or a baseline's (a linked dependency), is carved out: on Linux
|
|
1562
|
+
* too it binds. Reads deny the data, not the metadata, as SRT's own wall
|
|
1563
|
+
* denies do: a walk may still stat the directory.
|
|
1564
|
+
*/
|
|
1565
|
+
export function darwinWallRules(
|
|
1566
|
+
c: Pick<ResolvedSandboxConfig, 'wallsReached' | 'allowRead' | 'allowWrite'>,
|
|
1567
|
+
baseAllowRead: readonly string[],
|
|
1568
|
+
): string[] {
|
|
1569
|
+
const rules: string[] = []
|
|
1570
|
+
const deny = (op: string, walls: readonly string[], literals: readonly string[]): void => {
|
|
1571
|
+
for (const w of walls) {
|
|
1572
|
+
const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
|
|
1573
|
+
const kept = literals
|
|
1574
|
+
.filter((l) => isMountableLiteral(l) && (l === w || l.startsWith(w + path.sep)))
|
|
1575
|
+
.map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
|
|
1576
|
+
rules.push(
|
|
1577
|
+
kept.length === 0
|
|
1578
|
+
? `(deny ${op} ${at})`
|
|
1579
|
+
: `(deny ${op} (require-all ${at} ${kept.join(' ')}))`,
|
|
1580
|
+
)
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
deny('file-read-data', c.wallsReached?.read ?? [], [...c.allowRead, ...baseAllowRead])
|
|
1584
|
+
deny('file-write*', c.wallsReached?.write ?? [], c.allowWrite)
|
|
1585
|
+
return rules
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**'] ok — collapsed to the directory
|
|
1532
1589
|
* write: ['g/a.txt'] ok — a literal is widened to its directory
|
|
1533
1590
|
* write: ['g/*'] FAILED: `bash: g/a.txt: Read-only file system`
|
|
1534
1591
|
* write: ['g/*.txt'] FAILED, same
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
releaseBridges,
|
|
23
23
|
wrapSandboxedCommand,
|
|
24
24
|
signalExitCode,
|
|
25
|
+
isLocalExecutor,
|
|
25
26
|
type CaptureConfig,
|
|
26
27
|
type ExecuteRequest,
|
|
27
28
|
assertExecuteResult,
|
|
@@ -32,7 +33,9 @@ import {
|
|
|
32
33
|
PersistentReadyError,
|
|
33
34
|
} from '../exec/index.js'
|
|
34
35
|
import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
35
|
-
import { relPosix, span } from '../util/index.js'
|
|
36
|
+
import { killGraceMs, relPosix, span } from '../util/index.js'
|
|
37
|
+
import { SIGNAL_SHUTDOWN_GRACE_MS } from './signals.js'
|
|
38
|
+
import { executorLabel } from './plugin-host.js'
|
|
36
39
|
import {
|
|
37
40
|
mayWriteFingerprint,
|
|
38
41
|
type Placeholder,
|
|
@@ -493,6 +496,49 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
493
496
|
}
|
|
494
497
|
return stop.signal
|
|
495
498
|
}
|
|
499
|
+
// An executor that ignores `signal` held its task, and so the run, past
|
|
500
|
+
// the stop and past `exec.timeout` (H-14). Once the signal aborts it has
|
|
501
|
+
// the kill grace the local executor gives a process group; then core
|
|
502
|
+
// settles the attempt without it and says so. Its work is abandoned: core
|
|
503
|
+
// cannot reach what it started.
|
|
504
|
+
function boundAfterAbort(
|
|
505
|
+
running: Promise<unknown>,
|
|
506
|
+
signal: AbortSignal | undefined,
|
|
507
|
+
): Promise<unknown> {
|
|
508
|
+
if (signal === undefined || isLocalExecutor(args.executor)) return running
|
|
509
|
+
const started = Date.now()
|
|
510
|
+
let graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
511
|
+
let onAbort: (() => void) | undefined
|
|
512
|
+
const abandoned = new Promise<ExecuteResult>((resolve) => {
|
|
513
|
+
onAbort = (): void => {
|
|
514
|
+
const graceMs = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS)
|
|
515
|
+
graceTimer = setTimeout(() => {
|
|
516
|
+
const why = timeoutFired ? `timeout (${effectiveTimeout}ms)` : 'stop'
|
|
517
|
+
log.taskStderr(
|
|
518
|
+
node,
|
|
519
|
+
`vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
|
|
520
|
+
)
|
|
521
|
+
const reason: unknown = signal.reason
|
|
522
|
+
resolve({
|
|
523
|
+
exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
|
|
524
|
+
durationMs: Date.now() - started,
|
|
525
|
+
stdout: '',
|
|
526
|
+
stderr: '',
|
|
527
|
+
violations: [],
|
|
528
|
+
...(timeoutFired ? { timedOut: true } : {}),
|
|
529
|
+
})
|
|
530
|
+
}, graceMs)
|
|
531
|
+
}
|
|
532
|
+
if (signal.aborted) onAbort()
|
|
533
|
+
else signal.addEventListener('abort', onAbort, { once: true })
|
|
534
|
+
})
|
|
535
|
+
// The abandoned call may still settle, or reject, later: nobody awaits it.
|
|
536
|
+
running.catch(() => {})
|
|
537
|
+
return Promise.race([running, abandoned]).finally(() => {
|
|
538
|
+
clearTimeout(graceTimer)
|
|
539
|
+
if (onAbort !== undefined) signal.removeEventListener('abort', onAbort)
|
|
540
|
+
})
|
|
541
|
+
}
|
|
496
542
|
|
|
497
543
|
// When the task started, as a ns offset from run start — captured for
|
|
498
544
|
// EVERY outcome (hits included) so the run-detail timeline reflects when
|
|
@@ -722,8 +768,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
722
768
|
// no other trace. Rethrown unchanged — the scheduler still classifies it,
|
|
723
769
|
// and still prints it plainly for a UserError.
|
|
724
770
|
const endExec = span('miss: execute')
|
|
725
|
-
let res = await args.executor
|
|
726
|
-
.execute(req)
|
|
771
|
+
let res = await boundAfterAbort(args.executor.execute(req), req.signal)
|
|
727
772
|
.then((r: unknown) => {
|
|
728
773
|
assertExecuteResult(args.executor.name, node.id, r)
|
|
729
774
|
return r
|
|
@@ -199,7 +199,6 @@ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: stri
|
|
|
199
199
|
if (prefixes.length === 0) return out
|
|
200
200
|
const reaches = (dir: string): boolean =>
|
|
201
201
|
dir === '' ||
|
|
202
|
-
dir === '.' ||
|
|
203
202
|
prefixes.some((p) => p === dir || p.startsWith(`${dir}/`) || dir.startsWith(`${p}/`))
|
|
204
203
|
const dependants = new Map<string, string[]>()
|
|
205
204
|
for (const node of nodes.values()) {
|
|
@@ -241,58 +241,24 @@ export function getInvocation(db: Database, runId: string): InvocationDetail | n
|
|
|
241
241
|
|
|
242
242
|
export interface ListInvocationsArgs {
|
|
243
243
|
limit?: number
|
|
244
|
-
branch?: string
|
|
245
|
-
ci?: boolean
|
|
246
|
-
tagKey?: string
|
|
247
|
-
tagValue?: string
|
|
248
244
|
}
|
|
249
245
|
|
|
250
246
|
/**
|
|
251
|
-
* List `vx run` invocations newest-first from the `invocations` header table
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* survives. Accepts a bare `number` for the limit (back-compat with the old
|
|
255
|
-
* `listInvocations(db, 50)` signature).
|
|
247
|
+
* List `vx run` invocations newest-first from the `invocations` header table.
|
|
248
|
+
* Reading the dedicated header table (vs the old `GROUP BY run_id` over
|
|
249
|
+
* `runs`) is lossless — git/ci/tag context survives.
|
|
256
250
|
*/
|
|
257
|
-
export function listInvocations(
|
|
258
|
-
|
|
259
|
-
args: ListInvocationsArgs | number = {},
|
|
260
|
-
): InvocationDetail[] {
|
|
261
|
-
const opts: ListInvocationsArgs = typeof args === 'number' ? { limit: args } : args
|
|
262
|
-
const limit = clampInt(opts.limit ?? 50, 1, 500)
|
|
263
|
-
const where: string[] = []
|
|
264
|
-
const params: (string | number)[] = []
|
|
265
|
-
if (opts.branch !== undefined) {
|
|
266
|
-
where.push('branch = ?')
|
|
267
|
-
params.push(opts.branch)
|
|
268
|
-
}
|
|
269
|
-
if (opts.ci !== undefined) {
|
|
270
|
-
where.push('ci = ?')
|
|
271
|
-
params.push(opts.ci ? 1 : 0)
|
|
272
|
-
}
|
|
273
|
-
if (opts.tagKey !== undefined && opts.tagValue !== undefined) {
|
|
274
|
-
// The tags column is a JSON object {"k":"v"}; a LIKE over the serialized
|
|
275
|
-
// pair is adequate at this table's scale (see the design doc).
|
|
276
|
-
where.push('tags LIKE ?')
|
|
277
|
-
params.push(`%${jsonPairFragment(opts.tagKey, opts.tagValue)}%`)
|
|
278
|
-
}
|
|
279
|
-
const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
|
|
251
|
+
export function listInvocations(db: Database, args: ListInvocationsArgs = {}): InvocationDetail[] {
|
|
252
|
+
const limit = clampInt(args.limit ?? 50, 1, 500)
|
|
280
253
|
const rows = db
|
|
281
254
|
.query(
|
|
282
|
-
`SELECT ${INVOCATION_COLUMNS} FROM invocations
|
|
255
|
+
`SELECT ${INVOCATION_COLUMNS} FROM invocations
|
|
283
256
|
ORDER BY rowid DESC LIMIT ?`,
|
|
284
257
|
)
|
|
285
|
-
.all(
|
|
258
|
+
.all(limit) as InvocationRawRow[]
|
|
286
259
|
return rows.map(mapInvocation)
|
|
287
260
|
}
|
|
288
261
|
|
|
289
|
-
/** The `"key":"value"` fragment as it appears in `JSON.stringify({k:v})`. */
|
|
290
|
-
function jsonPairFragment(key: string, value: string): string {
|
|
291
|
-
const k = JSON.stringify(key).slice(1, -1)
|
|
292
|
-
const v = JSON.stringify(value).slice(1, -1)
|
|
293
|
-
return `"${k}":"${v}"`
|
|
294
|
-
}
|
|
295
|
-
|
|
296
262
|
export interface RunDetail {
|
|
297
263
|
runId: string
|
|
298
264
|
startedAt: number
|