@vzn/vx 0.0.645 → 0.0.648
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -50
- 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 +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- 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 +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- 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 +326 -65
- 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 +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- 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 +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- 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 +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- 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 +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- 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 +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- 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 +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- 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 +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- 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 +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -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 +65 -4
- 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 +282 -0
- 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 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Task entries by key for an executor that runs a task on another machine
|
|
2
|
+
// (`ExecutorContext.artifacts`, `CommandContext.artifacts`): the machine
|
|
3
|
+
// that ran it saves its outputs and output under the key the order named,
|
|
4
|
+
// and the one that needs them restores them, through the workspace's own
|
|
5
|
+
// cache (design packages/vx/docs/design/dte-2026-10.md, "Files").
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
type CacheLayer,
|
|
9
|
+
type ResolvedRemote,
|
|
10
|
+
resolveOutputTree,
|
|
11
|
+
resolveWorkspaceOutputTree,
|
|
12
|
+
sayRemoteFault,
|
|
13
|
+
} from '../cache/index.js'
|
|
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'
|
|
17
|
+
|
|
18
|
+
/** The task a `TaskArtifacts` call names: where its outputs are and what ran. */
|
|
19
|
+
export interface ArtifactTask {
|
|
20
|
+
/** `project#task`. */
|
|
21
|
+
readonly id: string
|
|
22
|
+
/** Absolute. */
|
|
23
|
+
readonly projectDir: string
|
|
24
|
+
readonly command: string
|
|
25
|
+
/** Its `cache.outputs`: project-relative `files`, root-relative `workspaceFiles`. */
|
|
26
|
+
readonly outputs: {
|
|
27
|
+
readonly files: readonly string[]
|
|
28
|
+
readonly workspaceFiles: readonly string[]
|
|
29
|
+
}
|
|
30
|
+
/** The projects inside `projectDir` (absolute), whose files are not its outputs. */
|
|
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>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What a task's run left besides its files: its output, and its time and usage. */
|
|
40
|
+
export interface ArtifactRun {
|
|
41
|
+
/** Both streams in the order they were written, as a `BoundedCapture` keeps them. */
|
|
42
|
+
readonly output: readonly CapturedChunk[]
|
|
43
|
+
readonly durationMs: number
|
|
44
|
+
readonly cpuMs?: number
|
|
45
|
+
readonly peakRssBytes?: number
|
|
46
|
+
/** Its environment: a secret value in it is masked out of the stored output. */
|
|
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
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TaskArtifacts {
|
|
55
|
+
/**
|
|
56
|
+
* Keeps the files `task`'s outputs match, and its output, as entry
|
|
57
|
+
* `key`: here, and on each remote the workspace writes. Resolves once
|
|
58
|
+
* every upload has settled; a remote that fails degrades as a run's
|
|
59
|
+
* upload does.
|
|
60
|
+
*/
|
|
61
|
+
save(key: string, task: ArtifactTask, run: ArtifactRun): Promise<void>
|
|
62
|
+
/**
|
|
63
|
+
* Lays entry `key`'s files into the workspace, from the local store or
|
|
64
|
+
* else the first remote holding it, and resolves its output, both streams
|
|
65
|
+
* in the order written; null when
|
|
66
|
+
* none holds it. A key restored here and then saved is not uploaded
|
|
67
|
+
* again.
|
|
68
|
+
*/
|
|
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>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function taskArtifacts(cache: CacheLayer, workspaceRoot: string): TaskArtifacts {
|
|
95
|
+
return {
|
|
96
|
+
async save(key, task, run) {
|
|
97
|
+
const [tree, wsTree] = await Promise.all([
|
|
98
|
+
resolveOutputTree({
|
|
99
|
+
projectDir: task.projectDir,
|
|
100
|
+
outputs: task.outputs.files,
|
|
101
|
+
nestedProjectDirs: [...(task.nestedProjectDirs ?? [])],
|
|
102
|
+
}),
|
|
103
|
+
resolveWorkspaceOutputTree({ workspaceRoot, outputs: task.outputs.workspaceFiles }),
|
|
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)
|
|
110
|
+
await cache.save({
|
|
111
|
+
hash: key,
|
|
112
|
+
projectDir: task.projectDir,
|
|
113
|
+
outputFiles: files,
|
|
114
|
+
...(tree.emptyDirs.length > 0 ? { outputDirs: tree.emptyDirs } : {}),
|
|
115
|
+
workspaceRoot,
|
|
116
|
+
...(ws.length > 0 ? { workspaceOutputFiles: ws } : {}),
|
|
117
|
+
...(wsTree.emptyDirs.length > 0 ? { workspaceOutputDirs: wsTree.emptyDirs } : {}),
|
|
118
|
+
...(run.absent === true ? { absent: true } : {}),
|
|
119
|
+
entry: {
|
|
120
|
+
taskId: task.id,
|
|
121
|
+
command: mask?.mask(task.command) ?? task.command,
|
|
122
|
+
durationMs: run.durationMs,
|
|
123
|
+
stdout: log,
|
|
124
|
+
...(run.cpuMs !== undefined ? { cpuMs: run.cpuMs } : {}),
|
|
125
|
+
...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
await cache.drainUploads?.()
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async restore(key, task) {
|
|
132
|
+
const entry = await (cache.getSaved ? cache.getSaved(key) : cache.get(key))
|
|
133
|
+
if (entry === null) return null
|
|
134
|
+
await cache.restoreOutputs(key, task.projectDir, workspaceRoot, task.outputs)
|
|
135
|
+
return decodeOutputLog(entry.stdout)
|
|
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),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* What a run's cache is read under, for a machine that runs its tasks
|
|
151
|
+
* (`ExecutorContext.cacheFacts`, `CommandContext.artifactsFor`): the
|
|
152
|
+
* refs the run worked out, so that machine reads as the run would, and
|
|
153
|
+
* the access each remote lent, by plugin.
|
|
154
|
+
*/
|
|
155
|
+
export interface CacheFacts {
|
|
156
|
+
/** `CacheContext.refs`. */
|
|
157
|
+
readonly refs: readonly string[]
|
|
158
|
+
/** Plugin name → the token its remote's `lend` gave. */
|
|
159
|
+
readonly lent: Readonly<Record<string, string>>
|
|
160
|
+
/** Epoch ms: the first lent token's end, before which to ask again. */
|
|
161
|
+
readonly expiresAt?: number
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The run's `CacheFacts`: each readable remote's `lend`, asked now; one that throws lends nothing, said. */
|
|
165
|
+
export async function cacheFacts(
|
|
166
|
+
remotes: readonly ResolvedRemote[],
|
|
167
|
+
refs: readonly string[],
|
|
168
|
+
): Promise<CacheFacts> {
|
|
169
|
+
const lent: Record<string, string> = {}
|
|
170
|
+
let expiresAt: number | undefined
|
|
171
|
+
await Promise.all(
|
|
172
|
+
remotes.map(async (r) => {
|
|
173
|
+
if (r.plugin === undefined || r.remote.lend === undefined || !r.read) return
|
|
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
|
+
})
|
|
200
|
+
if (got === undefined) return
|
|
201
|
+
lent[r.plugin] = got.token
|
|
202
|
+
if (got.expiresAt !== undefined) expiresAt = Math.min(expiresAt ?? Infinity, got.expiresAt)
|
|
203
|
+
}),
|
|
204
|
+
)
|
|
205
|
+
return { refs, lent, ...(expiresAt === undefined ? {} : { expiresAt }) }
|
|
206
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
ABSENT_INPUT,
|
|
7
7
|
absentOr,
|
|
8
8
|
addedInput,
|
|
9
|
+
behindLink,
|
|
9
10
|
filesToken,
|
|
10
11
|
foldHead,
|
|
11
12
|
type InputListing,
|
|
@@ -21,7 +22,16 @@ import {
|
|
|
21
22
|
} from '../cache/index.js'
|
|
22
23
|
import type { InputFile, TaskInputs } from '../exec/index.js'
|
|
23
24
|
import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
|
|
24
|
-
import {
|
|
25
|
+
import { GROUP_VIA } from '../history/index.js'
|
|
26
|
+
import {
|
|
27
|
+
isUserError,
|
|
28
|
+
keysSorted,
|
|
29
|
+
relPosix,
|
|
30
|
+
secretMask,
|
|
31
|
+
span,
|
|
32
|
+
UserError,
|
|
33
|
+
xxh3hex,
|
|
34
|
+
} from '../util/index.js'
|
|
25
35
|
import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
|
|
26
36
|
|
|
27
37
|
/**
|
|
@@ -46,6 +56,11 @@ export interface HashCache {
|
|
|
46
56
|
projectFiles: ProjectFilesCache
|
|
47
57
|
/** Each task's last key by its probe, across runs (the local cache's `FoldMemo`). */
|
|
48
58
|
folds?: FoldMemo | undefined
|
|
59
|
+
/**
|
|
60
|
+
* The token of the remembered graph the run's nodes are (graph-memo.ts),
|
|
61
|
+
* while no plugin or flag has edited them since.
|
|
62
|
+
*/
|
|
63
|
+
graph?: string | undefined
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
export function createHashCache(folds?: FoldMemo): HashCache {
|
|
@@ -131,15 +146,15 @@ const FOLD_MEMO_VERSION = 'fold-memo-v1'
|
|
|
131
146
|
/**
|
|
132
147
|
* A task that runs a command and declares no `inputs`: nothing says what
|
|
133
148
|
* it read, so no key can stand for its outputs, and a key that folds it
|
|
134
|
-
* would replay a stale entry (
|
|
149
|
+
* would replay a stale entry (2026-10-16).
|
|
135
150
|
*/
|
|
136
151
|
export function isKeyless(task: TaskConfig): boolean {
|
|
137
152
|
return task.exec !== undefined && task.inputs === undefined
|
|
138
153
|
}
|
|
139
154
|
|
|
140
155
|
/**
|
|
141
|
-
* Drawn once per process, so no run derives another run's
|
|
142
|
-
*
|
|
156
|
+
* Drawn once per process, on first use, so no run derives another run's
|
|
157
|
+
* keyless keys.
|
|
143
158
|
*/
|
|
144
159
|
let keylessSalt: string | undefined
|
|
145
160
|
|
|
@@ -159,6 +174,12 @@ export interface InputFact {
|
|
|
159
174
|
path: string
|
|
160
175
|
digest: string
|
|
161
176
|
since: number
|
|
177
|
+
/**
|
|
178
|
+
* For a link, the content behind it (`behindLink`), which the key folds
|
|
179
|
+
* after the link's own target string. An edit there leaves the link
|
|
180
|
+
* untouched, so the re-check compares this rather than the link's ctime.
|
|
181
|
+
*/
|
|
182
|
+
behind?: string
|
|
162
183
|
}
|
|
163
184
|
|
|
164
185
|
/**
|
|
@@ -180,11 +201,21 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
180
201
|
}> {
|
|
181
202
|
const hashedAt = Date.now()
|
|
182
203
|
const { input, listings } = await resolveKeyInput(args)
|
|
183
|
-
const hash = await args.cache.key(input)
|
|
184
204
|
const sorted = [...input.inputFiles].sort()
|
|
185
205
|
const digests = await Promise.all(
|
|
186
206
|
sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
|
|
187
207
|
)
|
|
208
|
+
// Learned BEFORE the key folds it: an edit in between then reads as a
|
|
209
|
+
// move at the re-check (a withheld save), never as content the key
|
|
210
|
+
// did not fold matching it (a stale entry).
|
|
211
|
+
const hashFile = (f: string): Promise<string> => args.cache.hashFile(f)
|
|
212
|
+
const behind = new Map<string, string>()
|
|
213
|
+
await Promise.all(
|
|
214
|
+
sorted.map(async (f, i) => {
|
|
215
|
+
if (digests[i]!.startsWith('120000:')) behind.set(f, await behindLink(f, hashFile))
|
|
216
|
+
}),
|
|
217
|
+
)
|
|
218
|
+
const hash = await args.cache.key(input)
|
|
188
219
|
// A file gone since the enumeration is no input: the task will not read it.
|
|
189
220
|
const files: InputFile[] = sorted.flatMap((f, i) =>
|
|
190
221
|
digests[i] === ABSENT_INPUT
|
|
@@ -192,11 +223,16 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
192
223
|
: [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
|
|
193
224
|
)
|
|
194
225
|
const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
|
|
195
|
-
const facts: InputFact[] = sorted.map((f, i) =>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
const facts: InputFact[] = sorted.map((f, i) => {
|
|
227
|
+
const fact: InputFact = {
|
|
228
|
+
path: f,
|
|
229
|
+
digest: digests[i]!,
|
|
230
|
+
since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
|
|
231
|
+
}
|
|
232
|
+
const linked = behind.get(f)
|
|
233
|
+
if (linked !== undefined) fact.behind = linked
|
|
234
|
+
return fact
|
|
235
|
+
})
|
|
200
236
|
// Groups expanded: this list describes the INPUT ROOT, not the key, and a
|
|
201
237
|
// group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
|
|
202
238
|
// Sorted by hash so the list — and any action digest derived from it — is
|
|
@@ -260,7 +296,8 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
|
|
|
260
296
|
* the command by content too, when it answers true for that file and its
|
|
261
297
|
* ctime: the task says the same bytes are its own identical rewrite
|
|
262
298
|
* (tsdown's package.json), not an edit undone, unless another command that
|
|
263
|
-
* may write the file ran until then (command-windows.ts).
|
|
299
|
+
* may write the file ran until then (command-windows.ts). A link's content
|
|
300
|
+
* behind it (`InputFact.behind`) is followed again and compared.
|
|
264
301
|
*/
|
|
265
302
|
export async function movedInput(
|
|
266
303
|
facts: readonly InputFact[],
|
|
@@ -294,9 +331,25 @@ export async function movedInput(
|
|
|
294
331
|
const now = await cache.hashFile(f.path).catch(() => undefined)
|
|
295
332
|
if (now !== f.digest) return f.path
|
|
296
333
|
}
|
|
334
|
+
// What a link points to changes without touching the link: each is
|
|
335
|
+
// followed again (links are rare, so a task without one pays nothing).
|
|
336
|
+
const hashFile = (file: string): Promise<string> => cache.hashFile(file)
|
|
337
|
+
for (const f of facts) {
|
|
338
|
+
if (f.behind !== undefined && (await behindLink(f.path, hashFile)) !== f.behind) return f.path
|
|
339
|
+
}
|
|
297
340
|
return undefined
|
|
298
341
|
}
|
|
299
342
|
|
|
343
|
+
/** A task's `filesToken`, as its key's fold memo probe takes it. */
|
|
344
|
+
export function taskFilesToken(
|
|
345
|
+
args: Pick<
|
|
346
|
+
ComputeHashArgs,
|
|
347
|
+
'node' | 'workspaceRoot' | 'nestedProjectDirs' | 'gitFilesCache' | 'hashCache'
|
|
348
|
+
>,
|
|
349
|
+
): string | undefined {
|
|
350
|
+
return filesToken(inputsArgs({ ...args, upstream: [], cache: undefined as never }))
|
|
351
|
+
}
|
|
352
|
+
|
|
300
353
|
/** What `resolveInputs` is asked for a task's key. */
|
|
301
354
|
function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
|
|
302
355
|
const cfg = args.node.config
|
|
@@ -348,6 +401,24 @@ async function resolveKeyInput(
|
|
|
348
401
|
const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
|
|
349
402
|
// hash → upstream task id, for capture-row naming only (not folded).
|
|
350
403
|
const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
|
|
404
|
+
// A group records no run and keeps no fingerprints, so `vx why` could not
|
|
405
|
+
// see past one: each folded group's members are captured as rows of their
|
|
406
|
+
// own, named through it. Rows only, never folded: the group's hash already
|
|
407
|
+
// folds theirs.
|
|
408
|
+
if (args.captureInto !== undefined) {
|
|
409
|
+
const folded = new Set(upstreamPairs.map(([id]) => id))
|
|
410
|
+
for (const g of args.upstream) {
|
|
411
|
+
if (g.groupUpstream === undefined || !folded.has(g.node.id)) continue
|
|
412
|
+
for (const m of expandGroupUpstream(g.groupUpstream)) {
|
|
413
|
+
if (m.hash === undefined) continue
|
|
414
|
+
args.captureInto.push({
|
|
415
|
+
kind: 'upstream',
|
|
416
|
+
name: `${g.node.id}${GROUP_VIA}${m.node.id}`,
|
|
417
|
+
hash: m.hash,
|
|
418
|
+
})
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
351
422
|
// The DEPENDENCY closure, groups expanded — for the executor's input root.
|
|
352
423
|
// Deliberately NOT the filtered set above: `inputs.tasks` says which
|
|
353
424
|
// upstream KEYS this task's key folds, and what a task may READ is
|
|
@@ -393,6 +464,7 @@ async function resolveKeyInput(
|
|
|
393
464
|
forwardArgs: effectiveForwardArgs,
|
|
394
465
|
...(fileHashes !== undefined ? { fileHashes } : {}),
|
|
395
466
|
...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
|
|
467
|
+
...(args.node.platform !== undefined ? { platform: args.node.platform } : {}),
|
|
396
468
|
...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
|
|
397
469
|
}
|
|
398
470
|
return { input, listings: resolved.listings }
|
|
@@ -411,7 +483,7 @@ async function resolveKeyInput(
|
|
|
411
483
|
* each task's config object is created once per run, so a hit there
|
|
412
484
|
* skips the JSON.stringify + xxh3 entirely.
|
|
413
485
|
*/
|
|
414
|
-
function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
486
|
+
export function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
415
487
|
if (hashCache) {
|
|
416
488
|
const cached = hashCache.taskConfig.get(cfg)
|
|
417
489
|
if (cached !== undefined) return cached
|
|
@@ -444,9 +516,12 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
|
|
|
444
516
|
* would bump CACHE_VERSION.
|
|
445
517
|
*/
|
|
446
518
|
function hashableConfig(cfg: TaskConfig): unknown {
|
|
447
|
-
|
|
519
|
+
// Keys sorted: moving `cache` above `exec`, or a `define` in another
|
|
520
|
+
// order, missed every key of the task, and a `project` or `graph` plugin
|
|
521
|
+
// writes a config the lock never sorted (2026-10-25).
|
|
522
|
+
if (cfg.exec?.remote === undefined) return keysSorted(cfg)
|
|
448
523
|
const { remote: _remote, ...execRest } = cfg.exec
|
|
449
|
-
return { ...cfg, exec: execRest }
|
|
524
|
+
return keysSorted({ ...cfg, exec: execRest })
|
|
450
525
|
}
|
|
451
526
|
|
|
452
527
|
/**
|
|
@@ -105,7 +105,7 @@ function budgetCost(chars: number, chunkCount: number): number {
|
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
107
|
* Bounded per-run capture. `append` keeps a chunk LIST + running char count per
|
|
108
|
-
* task, evicting
|
|
108
|
+
* task, evicting from the head past `TASK_LOG_TAIL_CHARS` — no
|
|
109
109
|
* string concatenation until `drain`, so a cache-hit replay (one big chunk) is
|
|
110
110
|
* one array push, zero copies. `finish` decides retention; `drain` emits the
|
|
111
111
|
* bundle, failures first.
|
|
@@ -115,6 +115,16 @@ export class TaskLogBuffer {
|
|
|
115
115
|
private readonly retained = new Map<string, Retained>()
|
|
116
116
|
private retainedChars = 0
|
|
117
117
|
private seq = 0
|
|
118
|
+
/**
|
|
119
|
+
* Retained entries in eviction order, kept as they arrive: successes are
|
|
120
|
+
* stubbed from `successHead` on (oldest first), failures from the end
|
|
121
|
+
* (newest first). An entry since stubbed, taken or replaced is skipped
|
|
122
|
+
* where it lies. A sort of every entry per finish made a run past the
|
|
123
|
+
* budget quadratic: 40,000 tasks took 17 s in a sink's `onRecord`.
|
|
124
|
+
*/
|
|
125
|
+
private readonly successes: Retained[] = []
|
|
126
|
+
private successHead = 0
|
|
127
|
+
private failures: Retained[] = []
|
|
118
128
|
|
|
119
129
|
append(taskId: string, chunk: string): void {
|
|
120
130
|
if (chunk.length === 0) return
|
|
@@ -126,24 +136,27 @@ export class TaskLogBuffer {
|
|
|
126
136
|
acc.chunks.push(chunk)
|
|
127
137
|
acc.chars += chunk.length
|
|
128
138
|
acc.charsFull += chunk.length
|
|
129
|
-
// Evict whole chunks from the head until under the per-task tail cap
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
// Evict whole chunks from the head until under the per-task tail cap;
|
|
140
|
+
// the one that straddles the cap is sliced to what still fits (the only
|
|
141
|
+
// place we copy). Dropped whole, a 128 KiB chunk and a 7-char one after
|
|
142
|
+
// it kept the 7 chars alone.
|
|
143
|
+
while (acc.chars > TASK_LOG_TAIL_CHARS) {
|
|
144
|
+
const head = acc.chunks[0]!
|
|
145
|
+
const excess = acc.chars - TASK_LOG_TAIL_CHARS
|
|
146
|
+
if (head.length <= excess) {
|
|
147
|
+
acc.chunks.shift()
|
|
148
|
+
acc.chars -= head.length
|
|
149
|
+
acc.truncatedHeadChars += head.length
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
let start = excess
|
|
139
153
|
// A cut between a surrogate pair would open the tail on a lone low
|
|
140
154
|
// surrogate, which a sink's UTF-8 encode turns into U+FFFD.
|
|
141
|
-
const first =
|
|
155
|
+
const first = head.charCodeAt(start)
|
|
142
156
|
if (first >= 0xdc00 && first <= 0xdfff) start++
|
|
143
|
-
|
|
144
|
-
acc.
|
|
145
|
-
acc.
|
|
146
|
-
acc.chars = keep.length
|
|
157
|
+
acc.chunks[0] = head.slice(start)
|
|
158
|
+
acc.chars -= start
|
|
159
|
+
acc.truncatedHeadChars += start
|
|
147
160
|
}
|
|
148
161
|
}
|
|
149
162
|
|
|
@@ -180,9 +193,32 @@ export class TaskLogBuffer {
|
|
|
180
193
|
if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
|
|
181
194
|
this.retained.set(taskId, entry)
|
|
182
195
|
this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
|
|
196
|
+
;(status === 'success' ? this.successes : this.failures).push(entry)
|
|
197
|
+
this.compact()
|
|
183
198
|
this.evictToBudget()
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
/** Whether `e` may still be stubbed: retained as it is, content and all. */
|
|
202
|
+
private evictable(e: Retained): boolean {
|
|
203
|
+
return e.chars > 0 && this.retained.get(e.taskId) === e
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Drop what eviction would only skip, once the queues hold twice what is
|
|
208
|
+
* retained: a sink that takes every entry (`takeEntry`) never evicts, and
|
|
209
|
+
* its queues would grow by a task each. Amortized constant per finish.
|
|
210
|
+
*/
|
|
211
|
+
private compact(): void {
|
|
212
|
+
const room = 2 * this.retained.size + 1024
|
|
213
|
+
if (this.successes.length - this.successHead > room || this.successHead > room) {
|
|
214
|
+
const live = this.successes.slice(this.successHead).filter((e) => this.evictable(e))
|
|
215
|
+
this.successes.length = 0
|
|
216
|
+
this.successes.push(...live)
|
|
217
|
+
this.successHead = 0
|
|
218
|
+
}
|
|
219
|
+
if (this.failures.length > room) this.failures = this.failures.filter((e) => this.evictable(e))
|
|
220
|
+
}
|
|
221
|
+
|
|
186
222
|
/**
|
|
187
223
|
* Take ONE finished task's retained tail as a wire entry, removing it from
|
|
188
224
|
* the buffer (so a later `drain` won't re-ship it). Used by per-task
|
|
@@ -249,7 +285,7 @@ export class TaskLogBuffer {
|
|
|
249
285
|
/**
|
|
250
286
|
* Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
|
|
251
287
|
* (oldest by seq); only when failures ALONE still exceed the budget do the
|
|
252
|
-
*
|
|
288
|
+
* newest failures go — a failure is never dropped to keep a success.
|
|
253
289
|
*
|
|
254
290
|
* An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
|
|
255
291
|
* = everything it emitted) rather than removed. Dropping the entry makes a
|
|
@@ -269,25 +305,25 @@ export class TaskLogBuffer {
|
|
|
269
305
|
* sees it.
|
|
270
306
|
*/
|
|
271
307
|
private evictToBudget(): void {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
for (const e of order) {
|
|
285
|
-
if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
|
|
286
|
-
if (e.chars === 0) continue // already a stub — frees nothing
|
|
287
|
-
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
288
|
-
e.truncatedHeadChars += e.chars
|
|
289
|
-
e.chunks = []
|
|
290
|
-
e.chars = 0
|
|
308
|
+
// Successes are dropped first, always, and keep oldest-first: none of
|
|
309
|
+
// them is more interesting than another, and recency is the better
|
|
310
|
+
// guess. Failures go newest-first: when failures alone blow the budget,
|
|
311
|
+
// the FIRST failure is usually the root cause and the later ones its
|
|
312
|
+
// cascade, so it must be the last thing stubbed.
|
|
313
|
+
while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.successHead < this.successes.length) {
|
|
314
|
+
const e = this.successes[this.successHead++]!
|
|
315
|
+
if (this.evictable(e)) this.stub(e)
|
|
316
|
+
}
|
|
317
|
+
while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.failures.length > 0) {
|
|
318
|
+
const e = this.failures.pop()!
|
|
319
|
+
if (this.evictable(e)) this.stub(e)
|
|
291
320
|
}
|
|
292
321
|
}
|
|
322
|
+
|
|
323
|
+
private stub(e: Retained): void {
|
|
324
|
+
this.retainedChars -= budgetCost(e.chars, e.chunks.length)
|
|
325
|
+
e.truncatedHeadChars += e.chars
|
|
326
|
+
e.chunks = []
|
|
327
|
+
e.chars = 0
|
|
328
|
+
}
|
|
293
329
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
PM_EXEC_ENV,
|
|
12
12
|
VX_RUN_TASK_ENV,
|
|
13
13
|
VX_RUN_WORKSPACE_ENV,
|
|
14
|
+
type ServerSpec,
|
|
14
15
|
} from '../exec/index.js'
|
|
15
16
|
import type { TaskNode } from '../graph/index.js'
|
|
16
17
|
import { parseDuration } from '../util/index.js'
|
|
@@ -73,6 +74,7 @@ export function taskEnv(
|
|
|
73
74
|
define: step.env?.define ?? {},
|
|
74
75
|
source: process.env,
|
|
75
76
|
binPaths: bins,
|
|
77
|
+
workspaceRoot,
|
|
76
78
|
})
|
|
77
79
|
env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
|
|
78
80
|
env[VX_RUN_TASK_ENV] = node.id
|
|
@@ -89,6 +91,26 @@ export function taskTimeoutMs(step: ExecConfig): number | undefined {
|
|
|
89
91
|
return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/** Persistent task `node` as a `servers` executor starts it: no forwarded args, which a dependency never gets. */
|
|
95
|
+
export function serverSpec(
|
|
96
|
+
node: TaskNode,
|
|
97
|
+
workspaceRoot: string,
|
|
98
|
+
nested: readonly string[],
|
|
99
|
+
): ServerSpec {
|
|
100
|
+
const step = node.config.exec!
|
|
101
|
+
const timeoutMs = taskTimeoutMs(step)
|
|
102
|
+
const readyWhen = step.persistent?.readyWhen
|
|
103
|
+
return {
|
|
104
|
+
taskId: node.id,
|
|
105
|
+
command: step.command,
|
|
106
|
+
cwd: node.projectDir,
|
|
107
|
+
env: taskEnv(node, step, workspaceRoot),
|
|
108
|
+
...(readyWhen === undefined ? {} : { readyWhen }),
|
|
109
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
110
|
+
...(step.sandbox === undefined ? {} : { sandbox: { config: step.sandbox, nested } }),
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
92
114
|
export function isAborted(signal: AbortSignal | undefined): boolean {
|
|
93
115
|
return signal?.aborted === true
|
|
94
116
|
}
|
|
@@ -17,9 +17,11 @@ import type {
|
|
|
17
17
|
RunContextRecord,
|
|
18
18
|
RunSummaryRecord,
|
|
19
19
|
TelemetryContext,
|
|
20
|
+
TelemetryRecord,
|
|
20
21
|
TelemetrySink,
|
|
22
|
+
TreeLoad,
|
|
21
23
|
} from './telemetry.js'
|
|
22
|
-
import { settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
24
|
+
import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
|
|
23
25
|
import { createTelemetrySource } from './telemetry.js'
|
|
24
26
|
import type { VxPlugin } from './plugin.js'
|
|
25
27
|
|
|
@@ -32,7 +34,17 @@ export interface TelemetryHandle {
|
|
|
32
34
|
/** Remove the bus subscription. Idempotent. */
|
|
33
35
|
dispose(): void
|
|
34
36
|
/** Sample a running task's process tree; see `TelemetrySource.track`. */
|
|
35
|
-
readonly track?: (taskId: string, pid: number) => () =>
|
|
37
|
+
readonly track?: (taskId: string, pid: number) => () => TreeLoad | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Every kind once: a kind added to `TelemetryRecord` fails to compile here.
|
|
41
|
+
const RECORD_KINDS: Record<TelemetryRecord['kind'], true> = {
|
|
42
|
+
'run.start': true,
|
|
43
|
+
'task.start': true,
|
|
44
|
+
'task.log': true,
|
|
45
|
+
'task.sample': true,
|
|
46
|
+
'task.end': true,
|
|
47
|
+
'run.end': true,
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/** Reject anything that is not sink-shaped, naming what arrived. */
|
|
@@ -44,6 +56,15 @@ function checkSink(sink: unknown): TelemetrySink {
|
|
|
44
56
|
if (wants !== undefined && !Array.isArray(wants)) {
|
|
45
57
|
throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
|
|
46
58
|
}
|
|
59
|
+
// A misspelt kind (`task.logs`) was a sink that never heard it, with no word.
|
|
60
|
+
const unknown = (wants as readonly unknown[] | undefined)?.find(
|
|
61
|
+
(k) => typeof k !== 'string' || !Object.hasOwn(RECORD_KINDS, k),
|
|
62
|
+
)
|
|
63
|
+
if (unknown !== undefined) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`telemetry sink 'wants' names ${typeof unknown === 'string' ? JSON.stringify(unknown) : typeof unknown}, which is no record kind (${Object.keys(RECORD_KINDS).join(', ')})`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
47
68
|
// Every handler is optional, so `{ nope: true }` was a valid sink that
|
|
48
69
|
// subscribed to the bus and heard nothing — the plugin was "on" and did
|
|
49
70
|
// nothing. Refused here, which the caller turns into the same warning
|
|
@@ -101,7 +122,7 @@ export async function subscribeTelemetry(
|
|
|
101
122
|
accepted = list.map((sink) => checkSink(sink))
|
|
102
123
|
} catch (err) {
|
|
103
124
|
ctx.warn(
|
|
104
|
-
`[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${
|
|
125
|
+
`[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${thrownMessage(err)}`,
|
|
105
126
|
)
|
|
106
127
|
continue
|
|
107
128
|
}
|