@vzn/vx 0.0.646 → 0.0.649
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 +29 -22
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +270 -60
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +61 -3
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +17 -11
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -9,7 +9,7 @@ import path from 'node:path'
|
|
|
9
9
|
import type { SandboxViolation } from '../exec/index.js'
|
|
10
10
|
import type { TaskOutcome } from '../graph/index.js'
|
|
11
11
|
import { isLiteralPattern } from '../util/index.js'
|
|
12
|
-
import {
|
|
12
|
+
import { credentialStorePaths } from './sandbox-request.js'
|
|
13
13
|
|
|
14
14
|
/** Grants to add under `exec.sandbox.allow`, each list sorted and unique. */
|
|
15
15
|
type SandboxFix = NonNullable<TaskOutcome['sandboxFix']>
|
|
@@ -22,9 +22,7 @@ type Kind = Exclude<keyof SandboxFix, 'withheld' | 'inputs'>
|
|
|
22
22
|
function isCredential(abs: string): boolean {
|
|
23
23
|
const base = path.basename(abs)
|
|
24
24
|
if (base === '.env' || base.startsWith('.env.')) return true
|
|
25
|
-
|
|
26
|
-
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return false
|
|
27
|
-
return CREDENTIAL_STORES.some((d) => rel === d || rel.startsWith(d + path.sep))
|
|
25
|
+
return credentialStorePaths(os.homedir()).some((d) => abs === d || abs.startsWith(d + path.sep))
|
|
28
26
|
}
|
|
29
27
|
|
|
30
28
|
/**
|
|
@@ -33,7 +31,8 @@ function isCredential(abs: string): boolean {
|
|
|
33
31
|
*/
|
|
34
32
|
function grantPath(abs: string, projectDir: string): string {
|
|
35
33
|
const rel = path.relative(projectDir, abs)
|
|
36
|
-
if (rel
|
|
34
|
+
if (rel === '') return '.'
|
|
35
|
+
if (!rel.startsWith('..') && !path.isAbsolute(rel)) return rel
|
|
37
36
|
const home = os.homedir()
|
|
38
37
|
return abs.startsWith(home + path.sep) ? `~/${abs.slice(home.length + 1)}` : abs
|
|
39
38
|
}
|
|
@@ -111,6 +110,9 @@ export function sandboxFix(
|
|
|
111
110
|
else if (kind === 'read') {
|
|
112
111
|
const grant = grantPath(v.path, projectDir)
|
|
113
112
|
fix.read.add(grant)
|
|
113
|
+
// The link the task opened lies in a directory the sandbox lacks: the
|
|
114
|
+
// target alone stays unreachable, and the fix named it again forever.
|
|
115
|
+
if (v.via !== undefined) fix.read.add(grantPath(v.via, projectDir))
|
|
114
116
|
if (keyed !== undefined && inProject(grant) && !keyed(grant)) fix.inputs.add(grant)
|
|
115
117
|
} else {
|
|
116
118
|
const isDir =
|
|
@@ -45,7 +45,7 @@ export interface SandboxArmer {
|
|
|
45
45
|
readonly armed: boolean
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
/** What the run's sandbox union reads of a task: a `TaskNode`, or
|
|
48
|
+
/** What the run's sandbox union reads of a task: a `TaskNode`, or a worker's order. */
|
|
49
49
|
export interface SandboxedNode {
|
|
50
50
|
readonly config: { readonly exec?: { readonly sandbox?: ExecConfig['sandbox'] } }
|
|
51
51
|
}
|
|
@@ -167,7 +167,7 @@ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | n
|
|
|
167
167
|
* The sandbox half of an `ExecuteRequest` for one task, shared by the
|
|
168
168
|
* cached path (through the executor) and the persistent path (spawned here).
|
|
169
169
|
*
|
|
170
|
-
* The user's grants derive NOTHING from `cache` (
|
|
170
|
+
* The user's grants derive NOTHING from `cache` (2026-09-05). Those
|
|
171
171
|
* are two different questions: `inputs` says what INVALIDATES the
|
|
172
172
|
* task, `sandbox.allow` says what it may TOUCH. Deriving one from the other
|
|
173
173
|
* coupled them in both directions — a declaration added for caching
|
|
@@ -209,6 +209,15 @@ export async function sandboxRequestFor(
|
|
|
209
209
|
`pattern, not a name — rename the directory, or run the task without exec.sandbox`,
|
|
210
210
|
)
|
|
211
211
|
}
|
|
212
|
+
// The runtime reports a refused touch one line per record: a path holding
|
|
213
|
+
// a line break arrived split, and an undeclared write to the project's own
|
|
214
|
+
// file was judged as outside it, with the wrong hint.
|
|
215
|
+
if (/[\n\r]/.test(home)) {
|
|
216
|
+
throw new UserError(
|
|
217
|
+
`exec.sandbox: ${home} holds a line break, which the sandbox runtime's reports cannot ` +
|
|
218
|
+
`carry, so a refused touch is misreported — rename the directory, or run the task without exec.sandbox`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
212
221
|
// Bun's realpath throws ENOENT on a path holding a backslash, and SRT
|
|
213
222
|
// mounts no path it cannot resolve: the task saw no project and ran in
|
|
214
223
|
// $HOME.
|
|
@@ -226,7 +235,7 @@ export async function sandboxRequestFor(
|
|
|
226
235
|
// sibling project, so granting `node_modules` grants a link whose
|
|
227
236
|
// target is outside it. That target is a dependency, not a reach-out:
|
|
228
237
|
// no project config should have to name a sibling to import what its
|
|
229
|
-
// own `package.json` depends on (
|
|
238
|
+
// own `package.json` depends on (2026-09-05) — when the key
|
|
230
239
|
// moves with it.
|
|
231
240
|
const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
|
|
232
241
|
depDirs.push(...links.granted)
|
|
@@ -235,8 +244,10 @@ export async function sandboxRequestFor(
|
|
|
235
244
|
// Pre-create what the task said it will write — after the grants are
|
|
236
245
|
// resolved, which refuses one that leaves the project through a link
|
|
237
246
|
// before anything is created along it (item 1003).
|
|
238
|
-
|
|
239
|
-
|
|
247
|
+
// The repository's and vx's own walls are sealed: no write grant may
|
|
248
|
+
// reach inside them either. A nested project is not: a grant into it is
|
|
249
|
+
// as explicit as one into a sibling.
|
|
250
|
+
const sealed = [
|
|
240
251
|
path.join(workspaceRoot, '.git'),
|
|
241
252
|
path.join(workspaceRoot, '.vx', 'cache'),
|
|
242
253
|
...(cacheDir !== undefined &&
|
|
@@ -244,6 +255,7 @@ export async function sandboxRequestFor(
|
|
|
244
255
|
? [cacheDir]
|
|
245
256
|
: []),
|
|
246
257
|
].map(toRealPath)
|
|
258
|
+
const walls = [...nested.map(toRealPath), ...sealed]
|
|
247
259
|
const resolved = resolveSandboxConfig(sandbox, node.projectDir, walls)
|
|
248
260
|
// Made before the walls judge the binds: a directory grant naming nothing
|
|
249
261
|
// yet (`dist/`, `dist/**`) was judged as a file, its bind its parent, and
|
|
@@ -251,7 +263,7 @@ export async function sandboxRequestFor(
|
|
|
251
263
|
const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
|
|
252
264
|
let config: ResolvedSandboxConfig
|
|
253
265
|
try {
|
|
254
|
-
config = wallOff(resolved, workspaceRoot, walls)
|
|
266
|
+
config = wallOff(resolved, workspaceRoot, walls, sealed)
|
|
255
267
|
} catch (err) {
|
|
256
268
|
await sweepPlaceholders(placeholders)
|
|
257
269
|
throw err
|
|
@@ -276,6 +288,7 @@ export async function sandboxRequestFor(
|
|
|
276
288
|
baseDenyRead: [
|
|
277
289
|
...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
|
|
278
290
|
...(await credentialStores(workspaceRoot, config.allowWrite)),
|
|
291
|
+
...(await vxStores(workspaceRoot, config.allowWrite, cacheDir)),
|
|
279
292
|
],
|
|
280
293
|
// …but only denials INSIDE the project are worth reporting. A task
|
|
281
294
|
// bumping into the wall is the sandbox working, not a finding: the
|
|
@@ -431,7 +444,7 @@ async function linkedDeps(
|
|
|
431
444
|
*/
|
|
432
445
|
export function withheldLinkLine(taskId: string, w: WithheldLink): string {
|
|
433
446
|
return (
|
|
434
|
-
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key
|
|
447
|
+
`vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
|
|
435
448
|
`${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
|
|
436
449
|
`reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
|
|
437
450
|
`\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
|
|
@@ -540,20 +553,23 @@ async function fileOnPath(from: string, to: string): Promise<string> {
|
|
|
540
553
|
* punched (`punchWalls`); a write grant whose bind would hold one — a file
|
|
541
554
|
* grant binds its directory on Linux, so `write: ['out.txt']` in a root
|
|
542
555
|
* project bound the whole workspace writable, `.git` included — is
|
|
543
|
-
* refused, as there is no writable bind that leaves a wall out.
|
|
556
|
+
* refused, as there is no writable bind that leaves a wall out. One
|
|
557
|
+
* inside a sealed wall (`.git/hooks/`, vx's store) is refused too: a
|
|
558
|
+
* hook planted there runs unsandboxed at the next commit. A glob's
|
|
544
559
|
* hits already stop at the walls (`resolveSandboxConfig` takes them).
|
|
545
560
|
*/
|
|
546
561
|
function wallOff(
|
|
547
562
|
config: ResolvedSandboxConfig,
|
|
548
563
|
workspaceRoot: string,
|
|
549
564
|
walls: readonly string[],
|
|
565
|
+
sealed: readonly string[],
|
|
550
566
|
): ResolvedSandboxConfig {
|
|
551
567
|
const home = toRealPath(workspaceRoot)
|
|
552
568
|
for (const bind of bindableWrites(config.allowWrite)) {
|
|
553
569
|
// A bind outside the workspace is the user's own path (`/tmp/x`,
|
|
554
570
|
// `~/.cache/y`), spelled there on purpose; only one inside is judged.
|
|
555
571
|
if (!atOrUnder(bind, home)) continue
|
|
556
|
-
const wall = walls.find((w) => atOrUnder(w, bind))
|
|
572
|
+
const wall = walls.find((w) => atOrUnder(w, bind)) ?? sealed.find((w) => atOrUnder(bind, w))
|
|
557
573
|
if (wall === undefined) continue
|
|
558
574
|
throw new UserError(
|
|
559
575
|
`exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
|
|
@@ -674,8 +690,23 @@ export const CREDENTIAL_STORES = [
|
|
|
674
690
|
'.codex/auth.json',
|
|
675
691
|
]
|
|
676
692
|
|
|
677
|
-
/**
|
|
678
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Each store's path: under `home`, and again where `$XDG_CONFIG_HOME` or
|
|
695
|
+
* `$XDG_DATA_HOME` moves a `.config` or `.local/share` one. A task sees
|
|
696
|
+
* both, so a CLI reads its login there. A relative value is no XDG path.
|
|
697
|
+
*/
|
|
698
|
+
export function credentialStorePaths(home: string): string[] {
|
|
699
|
+
const moved = [
|
|
700
|
+
['.config/', process.env['XDG_CONFIG_HOME']],
|
|
701
|
+
['.local/share/', process.env['XDG_DATA_HOME']],
|
|
702
|
+
] as const
|
|
703
|
+
const out = new Set(CREDENTIAL_STORES.map((rel) => path.join(home, rel)))
|
|
704
|
+
for (const rel of CREDENTIAL_STORES)
|
|
705
|
+
for (const [prefix, dir] of moved)
|
|
706
|
+
if (dir !== undefined && path.isAbsolute(dir) && rel.startsWith(prefix))
|
|
707
|
+
out.add(path.join(dir, rel.slice(prefix.length)))
|
|
708
|
+
return [...out]
|
|
709
|
+
}
|
|
679
710
|
|
|
680
711
|
/**
|
|
681
712
|
* The credential stores to deny, less any that holds the workspace, and
|
|
@@ -689,19 +720,38 @@ async function credentialStores(
|
|
|
689
720
|
workspaceRoot: string,
|
|
690
721
|
writes: readonly string[],
|
|
691
722
|
): Promise<string[]> {
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
const stores = presentStores.paths.filter(
|
|
700
|
-
(p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
723
|
+
// Asked per task, never memoized: an unsandboxed task earlier in the run
|
|
724
|
+
// (`npm config set` writing a token) makes a store the first sandboxed
|
|
725
|
+
// task did not see, and a refusal is never decided from a memo.
|
|
726
|
+
const stores = credentialStorePaths(homedir()).filter(
|
|
727
|
+
(p) => existsSync(p) && workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
|
|
701
728
|
)
|
|
702
729
|
return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
|
|
703
730
|
}
|
|
704
731
|
|
|
732
|
+
/**
|
|
733
|
+
* vx's own stores outside the workspace: `~/.vx`, which holds every
|
|
734
|
+
* repository's shared store (`resolveStoreRoot`), and a cache directory
|
|
735
|
+
* configured outside it. The rest of home stays readable, so without this
|
|
736
|
+
* a task with no grant read every project's cached outputs, around the
|
|
737
|
+
* cross-project wall and its own key. One inside the workspace is denied
|
|
738
|
+
* with it already.
|
|
739
|
+
*/
|
|
740
|
+
async function vxStores(
|
|
741
|
+
workspaceRoot: string,
|
|
742
|
+
writes: readonly string[],
|
|
743
|
+
cacheDir: string | undefined,
|
|
744
|
+
): Promise<string[]> {
|
|
745
|
+
const root = toRealPath(workspaceRoot)
|
|
746
|
+
// HOME first, as `resolveStoreRoot` reads it.
|
|
747
|
+
const home = process.env['HOME'] || homedir()
|
|
748
|
+
const stores = [path.join(home, '.vx'), ...(cacheDir === undefined ? [] : [cacheDir])]
|
|
749
|
+
.filter((p) => existsSync(p))
|
|
750
|
+
.map(toRealPath)
|
|
751
|
+
.filter((p) => !atOrUnder(root, p) && !atOrUnder(p, root))
|
|
752
|
+
return (await Promise.all(stores.map((p) => denyAround(p, writes)))).flat()
|
|
753
|
+
}
|
|
754
|
+
|
|
705
755
|
/** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
|
|
706
756
|
async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
|
|
707
757
|
const inside = writes.filter((w) => atOrUnder(w, dir))
|
|
@@ -22,7 +22,7 @@ export function shellVerdict(args: {
|
|
|
22
22
|
cwd: string
|
|
23
23
|
bins: string[]
|
|
24
24
|
/** The signal that killed the child when the runner saw one (`RunResult.signal`). */
|
|
25
|
-
signal?: string | undefined
|
|
25
|
+
signal?: string | number | undefined
|
|
26
26
|
/** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
|
|
27
27
|
hidden?: ((file: string) => boolean) | undefined
|
|
28
28
|
}): string | undefined {
|
|
@@ -84,7 +84,10 @@ function fileVerdict(file: string): string {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/** The signal behind an exit above 128, from the runner's own report or the code alone. */
|
|
87
|
-
function signalVerdict(code: number, signal: string | undefined): string | undefined {
|
|
87
|
+
function signalVerdict(code: number, signal: string | number | undefined): string | undefined {
|
|
88
|
+
if (typeof signal === 'number') {
|
|
89
|
+
return `[vx] exit ${code} is how the shell reports a death by signal ${signal}: the process was killed by that signal`
|
|
90
|
+
}
|
|
88
91
|
const signals = osConstants.signals as Partial<Record<string, number>>
|
|
89
92
|
let name: string | undefined
|
|
90
93
|
let num: number | undefined
|
|
@@ -96,9 +99,6 @@ function signalVerdict(code: number, signal: string | undefined): string | undef
|
|
|
96
99
|
num = code - 128
|
|
97
100
|
}
|
|
98
101
|
if (name === undefined || num === undefined) return undefined
|
|
99
|
-
// A SIGINT/SIGTERM the runner saw is a shutdown or an outside stop, and
|
|
100
|
-
// the orchestrator reverts the task to aborted — that path has its own line.
|
|
101
|
-
if (signal !== undefined && (signal === 'SIGINT' || signal === 'SIGTERM')) return undefined
|
|
102
102
|
const why = SIGNAL_WHY[name] ?? 'the process was killed by that signal'
|
|
103
103
|
const how =
|
|
104
104
|
signal !== undefined
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
import { signalExitCode } from '../exec/index.js'
|
|
17
17
|
import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
holdGroups,
|
|
20
|
+
killTree,
|
|
21
|
+
pauseTree,
|
|
22
|
+
restoreTerminalModes,
|
|
23
|
+
untilGroupsGone,
|
|
24
|
+
} from '../exec/index.js'
|
|
19
25
|
import type { Logger } from '../report/index.js'
|
|
20
26
|
|
|
21
27
|
/**
|
|
@@ -135,6 +141,9 @@ export function forwardSignals(args: {
|
|
|
135
141
|
// teardown must not throw on the way out
|
|
136
142
|
}
|
|
137
143
|
for (const child of everyChild()) killTree(child, 'SIGKILL')
|
|
144
|
+
// The runner restores a task's terminal modes when the task exits,
|
|
145
|
+
// which this exit does not wait for.
|
|
146
|
+
restoreTerminalModes()
|
|
138
147
|
try {
|
|
139
148
|
args.cache.close()
|
|
140
149
|
} catch {
|
|
@@ -177,17 +186,33 @@ export function forwardSignals(args: {
|
|
|
177
186
|
const onSigint = (): void => onSignal('SIGINT')
|
|
178
187
|
const onSigterm = (): void => onSignal('SIGTERM')
|
|
179
188
|
const onSighup = (): void => onSignal('SIGHUP')
|
|
189
|
+
// Ctrl-Z stops the terminal's foreground group, which is vx's alone: a
|
|
190
|
+
// task runs in its own session. vx stopped and its tasks ran on, an
|
|
191
|
+
// interactive one still reading the terminal the shell had taken back.
|
|
192
|
+
// So vx stops each task's group, then itself; `fg` or `bg` continues vx,
|
|
193
|
+
// and vx continues them.
|
|
194
|
+
const onSigtstp = (): void => {
|
|
195
|
+
for (const child of everyChild()) pauseTree(child, 'SIGSTOP')
|
|
196
|
+
process.kill(process.pid, 'SIGSTOP')
|
|
197
|
+
}
|
|
198
|
+
const onSigcont = (): void => {
|
|
199
|
+
for (const child of everyChild()) pauseTree(child, 'SIGCONT')
|
|
200
|
+
}
|
|
180
201
|
if (args.enabled) {
|
|
181
202
|
process.on('SIGINT', onSigint)
|
|
182
203
|
process.on('SIGTERM', onSigterm)
|
|
183
204
|
// A hang-up vx was started deaf to (nohup) stays ignored.
|
|
184
205
|
if (!hangupIgnored()) process.on('SIGHUP', onSighup)
|
|
206
|
+
process.on('SIGTSTP', onSigtstp)
|
|
207
|
+
process.on('SIGCONT', onSigcont)
|
|
185
208
|
}
|
|
186
209
|
return {
|
|
187
210
|
remove: () => {
|
|
188
211
|
process.off('SIGINT', onSigint)
|
|
189
212
|
process.off('SIGTERM', onSigterm)
|
|
190
213
|
process.off('SIGHUP', onSighup)
|
|
214
|
+
process.off('SIGTSTP', onSigtstp)
|
|
215
|
+
process.off('SIGCONT', onSigcont)
|
|
191
216
|
},
|
|
192
217
|
}
|
|
193
218
|
}
|
|
@@ -57,6 +57,8 @@ export interface DeriveStableKeysArgs {
|
|
|
57
57
|
* execute-task reuses the key when the live upstream matches.
|
|
58
58
|
*/
|
|
59
59
|
groupKeys?: Map<string, UpfrontGroupKey>
|
|
60
|
+
/** Filled with every key the walk makes, in the order it makes them (`RunDerivation`). */
|
|
61
|
+
keys?: Map<string, string>
|
|
60
62
|
}
|
|
61
63
|
|
|
62
64
|
export interface StableKey {
|
|
@@ -79,7 +81,7 @@ export interface StableKey {
|
|
|
79
81
|
export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<StableKey[]> {
|
|
80
82
|
const order = topoOrder(args.nodes)
|
|
81
83
|
const late = probesAfterWrites(args.nodes, args.workspaceRoot)
|
|
82
|
-
const keyById = new Map<string, string>()
|
|
84
|
+
const keyById = args.keys ?? new Map<string, string>()
|
|
83
85
|
const unstableById = new Set<string>()
|
|
84
86
|
// Transitive-upstream output producers, accumulated in topo order — the
|
|
85
87
|
// inputs to the stability gate. A task's key is preliminary if an upstream
|
|
@@ -338,6 +340,90 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
|
|
|
338
340
|
return stableKeys
|
|
339
341
|
}
|
|
340
342
|
|
|
343
|
+
/**
|
|
344
|
+
* The keys `deriveStableKeys` would make, from a derivation under the same
|
|
345
|
+
* graph, configs and ignored outputs (`readRunDelta`): every task's key
|
|
346
|
+
* is the one remembered unless it is `dirty`, and a dirty task's is derived
|
|
347
|
+
* as the walk derives it, over its deps' keys, in the walk's order. Which
|
|
348
|
+
* tasks are stable and which uncached keys are kept are the remembered
|
|
349
|
+
* verdicts: they read only what the global token names. `keys` is updated
|
|
350
|
+
* in place.
|
|
351
|
+
*/
|
|
352
|
+
export async function rederiveKeys(
|
|
353
|
+
args: Omit<DeriveStableKeysArgs, 'keys' | 'groupKeys'>,
|
|
354
|
+
keys: Map<string, string>,
|
|
355
|
+
dirty: ReadonlySet<string>,
|
|
356
|
+
): Promise<void> {
|
|
357
|
+
for (const [id] of keys) {
|
|
358
|
+
if (!dirty.has(id)) continue
|
|
359
|
+
const node = args.nodes.get(id)!
|
|
360
|
+
const upstream = synthUpstream(node, args.nodes, keys)
|
|
361
|
+
keys.set(
|
|
362
|
+
id,
|
|
363
|
+
isGroupTask(node) && node.config.inputs === undefined
|
|
364
|
+
? computeGroupHash(upstream)
|
|
365
|
+
: await computeTaskHash({
|
|
366
|
+
node,
|
|
367
|
+
upstream,
|
|
368
|
+
workspaceRoot: args.workspaceRoot,
|
|
369
|
+
cache: args.cache,
|
|
370
|
+
forwardArgs: args.forwardArgs,
|
|
371
|
+
nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
|
|
372
|
+
gitFilesCache: args.gitFilesCache,
|
|
373
|
+
hashCache: args.hashCache,
|
|
374
|
+
}),
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* A project's positive outputs indexed by literal head, so a reader finds
|
|
381
|
+
* the ones its inputs reach (`workspaceInputsReach`: a head equal to, above
|
|
382
|
+
* or below an input's) without testing each: one project of 9000 tasks
|
|
383
|
+
* tested 81 million pairs, 6 s of a plan (2026-10-25).
|
|
384
|
+
*/
|
|
385
|
+
class ProjectOutputs {
|
|
386
|
+
private readonly globs: string[] = []
|
|
387
|
+
private readonly byHead = new Map<string, number[]>()
|
|
388
|
+
private sorted: string[] | undefined
|
|
389
|
+
|
|
390
|
+
add(glob: string, head: string): void {
|
|
391
|
+
const key = head === '.' ? '' : head
|
|
392
|
+
let at = this.byHead.get(key)
|
|
393
|
+
if (at === undefined) this.byHead.set(key, (at = []))
|
|
394
|
+
at.push(this.globs.length)
|
|
395
|
+
this.globs.push(glob)
|
|
396
|
+
this.sorted = undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** The globs `inputs` reach, in the order they were added. */
|
|
400
|
+
reachedBy(inputs: readonly string[]): string[] {
|
|
401
|
+
const hit = new Set<number>()
|
|
402
|
+
const take = (head: string) => {
|
|
403
|
+
for (const i of this.byHead.get(head) ?? []) hit.add(i)
|
|
404
|
+
}
|
|
405
|
+
for (const raw of inputs) {
|
|
406
|
+
if (raw.startsWith('!')) continue
|
|
407
|
+
take('')
|
|
408
|
+
const p = literalPrefix(raw)
|
|
409
|
+
if (p === '') return [...this.globs]
|
|
410
|
+
for (let at = p.indexOf('/'); at !== -1; at = p.indexOf('/', at + 1)) take(p.slice(0, at))
|
|
411
|
+
take(p)
|
|
412
|
+
const sorted = (this.sorted ??= [...this.byHead.keys()].sort())
|
|
413
|
+
const from = `${p}/`
|
|
414
|
+
let lo = 0
|
|
415
|
+
let hi = sorted.length
|
|
416
|
+
while (lo < hi) {
|
|
417
|
+
const mid = (lo + hi) >> 1
|
|
418
|
+
if (sorted[mid]! < from) lo = mid + 1
|
|
419
|
+
else hi = mid
|
|
420
|
+
}
|
|
421
|
+
for (let i = lo; i < sorted.length && sorted[i]!.startsWith(from); i++) take(sorted[i]!)
|
|
422
|
+
}
|
|
423
|
+
return [...hit].sort((a, b) => a - b).map((i) => this.globs[i]!)
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
341
427
|
/**
|
|
342
428
|
* The same-project outputs a reader's inputs reach, root-relative, each with
|
|
343
429
|
* its project dir and project-relative glob: the outputs the gate would key
|
|
@@ -350,14 +436,13 @@ export function gatedOutputs(
|
|
|
350
436
|
const out = new Map<string, { projectDir: string; glob: string }>()
|
|
351
437
|
// Each project's positive outputs with their literal heads, read once:
|
|
352
438
|
// 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
|
|
353
|
-
const outputsOf = new Map<string,
|
|
439
|
+
const outputsOf = new Map<string, ProjectOutputs>()
|
|
354
440
|
for (const n of nodes.values()) {
|
|
355
441
|
const files = n.config.cache?.outputs.files
|
|
356
442
|
if (files === undefined || files.length === 0) continue
|
|
357
|
-
let
|
|
358
|
-
if (
|
|
359
|
-
for (const glob of files)
|
|
360
|
-
if (!glob.startsWith('!')) list.push({ glob, head: literalPrefix(glob) })
|
|
443
|
+
let po = outputsOf.get(n.projectName)
|
|
444
|
+
if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
|
|
445
|
+
for (const glob of files) if (!glob.startsWith('!')) po.add(glob, literalPrefix(glob))
|
|
361
446
|
}
|
|
362
447
|
if (outputsOf.size === 0) return out
|
|
363
448
|
for (const n of nodes.values()) {
|
|
@@ -366,8 +451,7 @@ export function gatedOutputs(
|
|
|
366
451
|
if (inputs === undefined || outputs === undefined || isGroupTask(n)) continue
|
|
367
452
|
let back: string[] | undefined
|
|
368
453
|
let rel: string | undefined
|
|
369
|
-
for (const
|
|
370
|
-
if (!workspaceInputsReach(inputs, [head])) continue
|
|
454
|
+
for (const o of outputs.reachedBy(inputs)) {
|
|
371
455
|
rel ??= relPosix(workspaceRoot, n.projectDir)
|
|
372
456
|
back ??= inputs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
|
|
373
457
|
const rooted = rel === '' ? o : `${rel}/${o}`
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// vx's own steps (reading the lock, keying, uploads, teardown) rendered as
|
|
2
|
-
// tasks (
|
|
2
|
+
// tasks (2026-10-15): `vx:<step>`, and `<plugin package>:setup` /
|
|
3
3
|
// `:teardown`. `:` is no task id's separator, so a step never collides with
|
|
4
4
|
// a project named `vx` or a plugin package's own `#setup` task. A step is
|
|
5
5
|
// not a task: no stat, history row or telemetry record counts it.
|
|
6
6
|
//
|
|
7
7
|
// A run shows only the steps it waits on, before its first task or its end
|
|
8
|
-
// (
|
|
8
|
+
// (2026-10-16). One that runs beside the tasks (`vx:prefetch`) is a
|
|
9
9
|
// `background` step: never shown, and kept for `vx last` (run-steps.ts).
|
|
10
10
|
import type { Logger } from '../report/index.js'
|
|
11
11
|
import type { RunStep } from './run-steps.js'
|
|
@@ -8,10 +8,12 @@ import {
|
|
|
8
8
|
type CacheLayer,
|
|
9
9
|
type ResolvedRemote,
|
|
10
10
|
resolveOutputTree,
|
|
11
|
-
|
|
11
|
+
resolveWorkspaceOutputTree,
|
|
12
|
+
sayRemoteFault,
|
|
12
13
|
} from '../cache/index.js'
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
14
|
+
import type { CapturedChunk } from '../exec/index.js'
|
|
15
|
+
import { secretMask, thrownMessage } from '../util/index.js'
|
|
16
|
+
import { decodeOutputLog, encodeOutputLog, maskedOutputLog } from './output-log.js'
|
|
15
17
|
|
|
16
18
|
/** The task a `TaskArtifacts` call names: where its outputs are and what ran. */
|
|
17
19
|
export interface ArtifactTask {
|
|
@@ -27,17 +29,26 @@ export interface ArtifactTask {
|
|
|
27
29
|
}
|
|
28
30
|
/** The projects inside `projectDir` (absolute), whose files are not its outputs. */
|
|
29
31
|
readonly nestedProjectDirs?: readonly string[]
|
|
32
|
+
/**
|
|
33
|
+
* Files under its outputs it found and left as they were (absolute): an
|
|
34
|
+
* upstream's, which an additive task builds on, not its entry's.
|
|
35
|
+
*/
|
|
36
|
+
readonly others?: ReadonlySet<string>
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/** What a task's run left besides its files: its output, and its time and usage. */
|
|
33
40
|
export interface ArtifactRun {
|
|
34
|
-
|
|
35
|
-
readonly
|
|
41
|
+
/** Both streams in the order they were written, as a `BoundedCapture` keeps them. */
|
|
42
|
+
readonly output: readonly CapturedChunk[]
|
|
36
43
|
readonly durationMs: number
|
|
37
44
|
readonly cpuMs?: number
|
|
38
45
|
readonly peakRssBytes?: number
|
|
39
46
|
/** Its environment: a secret value in it is masked out of the stored output. */
|
|
40
47
|
readonly env?: Readonly<Record<string, string | undefined>>
|
|
48
|
+
/** Its `exec.env.secret`: names in `env` whose values are masked whatever the name. */
|
|
49
|
+
readonly secret?: readonly string[]
|
|
50
|
+
/** No remote held the key when the run asked: its upload asks none first. */
|
|
51
|
+
readonly absent?: boolean
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export interface TaskArtifacts {
|
|
@@ -50,41 +61,66 @@ export interface TaskArtifacts {
|
|
|
50
61
|
save(key: string, task: ArtifactTask, run: ArtifactRun): Promise<void>
|
|
51
62
|
/**
|
|
52
63
|
* Lays entry `key`'s files into the workspace, from the local store or
|
|
53
|
-
* else the first remote holding it, and resolves its output
|
|
64
|
+
* else the first remote holding it, and resolves its output, both streams
|
|
65
|
+
* in the order written; null when
|
|
54
66
|
* none holds it. A key restored here and then saved is not uploaded
|
|
55
67
|
* again.
|
|
56
68
|
*/
|
|
57
|
-
restore(key: string, task: ArtifactTask): Promise<
|
|
69
|
+
restore(key: string, task: ArtifactTask): Promise<CapturedChunk[] | null>
|
|
70
|
+
/**
|
|
71
|
+
* Entry `key`'s output alone, laying no file; null when no cache holds
|
|
72
|
+
* it. An `exec.remote: 'only'` task's files stay off this disk.
|
|
73
|
+
*/
|
|
74
|
+
output(key: string): Promise<CapturedChunk[] | null>
|
|
75
|
+
/**
|
|
76
|
+
* True once a remote the run reads holds entry `key`, this machine's
|
|
77
|
+
* entry uploaded to those it writes when none does: another machine of
|
|
78
|
+
* the run reads the remotes alone. False when none holds it and none took it.
|
|
79
|
+
*/
|
|
80
|
+
share(key: string): Promise<boolean>
|
|
81
|
+
/**
|
|
82
|
+
* This machine's entry `key` uploaded over each written remote's copy,
|
|
83
|
+
* which another machine of the run found corrupt. True when one took it.
|
|
84
|
+
*/
|
|
85
|
+
resend(key: string): Promise<boolean>
|
|
86
|
+
/**
|
|
87
|
+
* Whether a remote the workspace writes took entry `key` when `save`
|
|
88
|
+
* uploaded it; undefined when it writes none. Another machine of the
|
|
89
|
+
* run reads the remotes alone, so false there means only this one has it.
|
|
90
|
+
*/
|
|
91
|
+
uploaded?(key: string): Promise<boolean | undefined>
|
|
58
92
|
}
|
|
59
93
|
|
|
60
94
|
export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArtifacts {
|
|
61
95
|
return {
|
|
62
96
|
async save(key, task, run) {
|
|
63
|
-
const [tree,
|
|
97
|
+
const [tree, wsTree] = await Promise.all([
|
|
64
98
|
resolveOutputTree({
|
|
65
99
|
projectDir: task.projectDir,
|
|
66
100
|
outputs: task.outputs.files,
|
|
67
101
|
nestedProjectDirs: [...(task.nestedProjectDirs ?? [])],
|
|
68
102
|
}),
|
|
69
|
-
|
|
70
|
-
])
|
|
71
|
-
const mask = secretMask([process.env, run.env ?? {}])
|
|
72
|
-
const log = encodeOutputLog([
|
|
73
|
-
{ text: run.stdout, err: false },
|
|
74
|
-
{ text: run.stderr, err: true },
|
|
103
|
+
resolveWorkspaceOutputTree({ workspaceRoot, outputs: task.outputs.workspaceFiles }),
|
|
75
104
|
])
|
|
105
|
+
const mine = (f: string) => task.others?.has(f) !== true
|
|
106
|
+
const files = tree.files.filter(mine)
|
|
107
|
+
const ws = wsTree.files.filter(mine)
|
|
108
|
+
const mask = secretMask([process.env, run.env ?? {}], run.secret)
|
|
109
|
+
const log = mask === null ? encodeOutputLog(run.output) : maskedOutputLog(run.output, mask)
|
|
76
110
|
await cache.save({
|
|
77
111
|
hash: key,
|
|
78
112
|
projectDir: task.projectDir,
|
|
79
|
-
outputFiles:
|
|
113
|
+
outputFiles: files,
|
|
80
114
|
...(tree.emptyDirs.length > 0 ? { outputDirs: tree.emptyDirs } : {}),
|
|
81
115
|
workspaceRoot,
|
|
82
|
-
...(
|
|
116
|
+
...(ws.length > 0 ? { workspaceOutputFiles: ws } : {}),
|
|
117
|
+
...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
|
|
118
|
+
...(run.absent === true ? { absent: true } : {}),
|
|
83
119
|
entry: {
|
|
84
120
|
taskId: task.id,
|
|
85
121
|
command: mask?.mask(task.command) ?? task.command,
|
|
86
122
|
durationMs: run.durationMs,
|
|
87
|
-
stdout:
|
|
123
|
+
stdout: log,
|
|
88
124
|
...(run.cpuMs !== undefined ? { cpuMs: run.cpuMs } : {}),
|
|
89
125
|
...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
|
|
90
126
|
},
|
|
@@ -96,14 +132,17 @@ export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArt
|
|
|
96
132
|
const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
|
|
97
133
|
if (entry === null) return null
|
|
98
134
|
await cache.restoreOutputs(key, task.projectDir, workspaceRoot, task.outputs)
|
|
99
|
-
|
|
100
|
-
let stderr = ''
|
|
101
|
-
for (const c of decodeOutputLog(entry.stdout)) {
|
|
102
|
-
if (c.err) stderr += c.text
|
|
103
|
-
else stdout += c.text
|
|
104
|
-
}
|
|
105
|
-
return { stdout, stderr }
|
|
135
|
+
return decodeOutputLog(entry.stdout)
|
|
106
136
|
},
|
|
137
|
+
|
|
138
|
+
async output(key) {
|
|
139
|
+
const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
|
|
140
|
+
return entry === null ? null : decodeOutputLog(entry.stdout)
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
share: (key) => cache.share?.(key) ?? Promise.resolve(false),
|
|
144
|
+
resend: (key) => cache.resend?.(key) ?? Promise.resolve(false),
|
|
145
|
+
uploaded: (key) => cache.uploaded?.(key) ?? Promise.resolve(undefined),
|
|
107
146
|
}
|
|
108
147
|
}
|
|
109
148
|
|
|
@@ -122,7 +161,7 @@ export interface CacheFacts {
|
|
|
122
161
|
readonly expiresAt?: number
|
|
123
162
|
}
|
|
124
163
|
|
|
125
|
-
/** The run's `CacheFacts`: each readable remote's `lend`, asked now; one that throws lends nothing. */
|
|
164
|
+
/** The run's `CacheFacts`: each readable remote's `lend`, asked now; one that throws lends nothing, said. */
|
|
126
165
|
export async function cacheFacts(
|
|
127
166
|
remotes: readonly ResolvedRemote[],
|
|
128
167
|
refs: readonly string[],
|
|
@@ -132,7 +171,32 @@ export async function cacheFacts(
|
|
|
132
171
|
await Promise.all(
|
|
133
172
|
remotes.map(async (r) => {
|
|
134
173
|
if (r.plugin === undefined || r.remote.lend === undefined || !r.read) return
|
|
135
|
-
|
|
174
|
+
// Through a promise, and checked: a plugin's synchronous throw skipped
|
|
175
|
+
// the catch and rejected every remote's answer, and a `null` or a
|
|
176
|
+
// numeric token met `.token` as a TypeError.
|
|
177
|
+
const got = await Promise.resolve()
|
|
178
|
+
.then(() => r.remote.lend!())
|
|
179
|
+
.then((answer: unknown) => {
|
|
180
|
+
if (answer === undefined) return undefined
|
|
181
|
+
if (typeof answer !== 'object' || answer === null)
|
|
182
|
+
throw new Error(
|
|
183
|
+
`it answered ${answer === null ? 'null' : typeof answer}, not { token, expiresAt? }`,
|
|
184
|
+
)
|
|
185
|
+
const { token, expiresAt: ends } = answer as { token?: unknown; expiresAt?: unknown }
|
|
186
|
+
if (typeof token !== 'string') throw new Error('it answered a token that is not a string')
|
|
187
|
+
if (ends !== undefined && !(typeof ends === 'number' && Number.isFinite(ends)))
|
|
188
|
+
throw new Error('it answered an expiresAt that is not a time in ms')
|
|
189
|
+
return { token, ...(ends === undefined ? {} : { expiresAt: ends }) }
|
|
190
|
+
})
|
|
191
|
+
.catch((err: unknown) => {
|
|
192
|
+
// Said: unsaid, the run's workers went without this remote and no line said why.
|
|
193
|
+
const message = thrownMessage(err)
|
|
194
|
+
sayRemoteFault(
|
|
195
|
+
r,
|
|
196
|
+
new Error(`lend of ${r.plugin}'s access failed: ${message}`, { cause: err }),
|
|
197
|
+
)
|
|
198
|
+
return undefined
|
|
199
|
+
})
|
|
136
200
|
if (got === undefined) return
|
|
137
201
|
lent[r.plugin] = got.token
|
|
138
202
|
if (got.expiresAt !== undefined) expiresAt = Math.min(expiresAt ?? Infinity, got.expiresAt)
|