@drakulavich/kesha-voice-kit 1.30.0 → 2.0.0
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 +5 -5
- package/SKILL.md +1 -1
- package/completions/kesha.bash +1 -1
- package/completions/kesha.fish +0 -1
- package/completions/kesha.zsh +3 -1
- package/docs/diagnostic-logs.md +36 -3
- package/docs/product-positioning.md +3 -3
- package/model-plan.json +130 -0
- package/package.json +21 -7
- package/src/cache-layout.ts +78 -8
- package/src/cli/action-result.ts +2 -1
- package/src/cli/command-session.ts +14 -3
- package/src/cli/completions.ts +4 -2
- package/src/cli/dispatch.ts +37 -5
- package/src/cli/init.ts +4 -4
- package/src/cli/install.ts +9 -6
- package/src/cli/main.ts +61 -46
- package/src/cli/mcp.ts +2 -0
- package/src/cli/options.ts +79 -0
- package/src/cli/record.ts +2 -1
- package/src/cli/say.ts +92 -11
- package/src/cli/stats.ts +9 -4
- package/src/cli/support-bundle.ts +3 -3
- package/src/cli.ts +1 -1
- package/src/diagnostic-log.ts +2 -9
- package/src/doctor.ts +62 -11
- package/src/engine/events.ts +20 -1
- package/src/engine/spawn.ts +114 -0
- package/src/engine-health.ts +25 -56
- package/src/engine-install.ts +230 -109
- package/src/engine-targets.ts +53 -5
- package/src/engine.ts +87 -102
- package/src/fluid-roots.ts +5 -0
- package/src/format.ts +11 -5
- package/src/install-lock.ts +45 -11
- package/src/install-plan.ts +96 -48
- package/src/kokoro-ane.ts +67 -4
- package/src/language-routing.ts +51 -0
- package/src/lib.ts +96 -61
- package/src/log.ts +2 -0
- package/src/mcp/audio-output.ts +2 -2
- package/src/mcp/tools.ts +10 -14
- package/src/package-info.ts +28 -2
- package/src/paths.ts +2 -2
- package/src/process-tree.ts +70 -18
- package/src/semver.mjs +7 -10
- package/src/shell-artifacts.ts +4 -3
- package/src/state-paths.ts +84 -0
- package/src/stats.ts +2 -10
- package/src/status.ts +39 -42
- package/src/synth.ts +58 -79
- package/src/transcribe.ts +18 -3
- package/src/voice-inventory.ts +51 -0
- package/src/voice-routing.ts +97 -6
- package/tsconfig.json +1 -1
package/src/cli/stats.ts
CHANGED
|
@@ -64,7 +64,7 @@ export function runStatsAction(args: StatsCommandArgs): ActionResult {
|
|
|
64
64
|
case "errors":
|
|
65
65
|
return { ok: true, messages: info(renderErrors(getRecentErrors())) };
|
|
66
66
|
case "export": {
|
|
67
|
-
const format = parseExportFormat(args.format ?? args.value
|
|
67
|
+
const format = parseExportFormat(args.format ?? args.value);
|
|
68
68
|
if (!format) return { ok: false, error: "usage: kesha stats export --format json|csv" };
|
|
69
69
|
return { ok: true, messages: [], stdout: exportStats(format) };
|
|
70
70
|
}
|
|
@@ -135,12 +135,17 @@ export const statsCommand = defineCommand({
|
|
|
135
135
|
description: "Export format: json | csv",
|
|
136
136
|
},
|
|
137
137
|
},
|
|
138
|
-
run({ args }: { args: StatsCommandArgs }) {
|
|
139
|
-
emitActionResult(runStatsAction(args));
|
|
138
|
+
run({ args, rawArgs }: { args: StatsCommandArgs; rawArgs: string[] }) {
|
|
139
|
+
emitActionResult(runStatsAction({ ...args, value: args.value ?? eatenNegativeNumber(rawArgs) }));
|
|
140
140
|
},
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
// citty reads `-5` as a boolean flag named 5, so the value the user typed never reaches the action (Exploratory S5-F3).
|
|
144
|
+
function eatenNegativeNumber(rawArgs: string[]): string | undefined {
|
|
145
|
+
return rawArgs.find((token) => /^-\d/.test(token));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseExportFormat(value: string | undefined): StatsExportFormat | null {
|
|
144
149
|
return value === "json" || value === "csv" ? value : null;
|
|
145
150
|
}
|
|
146
151
|
|
|
@@ -30,9 +30,9 @@ export const supportBundleCommand = defineCommand({
|
|
|
30
30
|
output: args.output,
|
|
31
31
|
includeLogs: Boolean(args["include-logs"]),
|
|
32
32
|
});
|
|
33
|
-
log.
|
|
34
|
-
log.
|
|
35
|
-
log.
|
|
33
|
+
log.status(`Created support bundle: ${bundle.path}`);
|
|
34
|
+
log.status(`Entries: ${bundle.entries.length}`);
|
|
35
|
+
log.status(`Size: ${bundle.sizeBytes} bytes`);
|
|
36
36
|
} catch (err) {
|
|
37
37
|
log.error(errorMessage(err));
|
|
38
38
|
process.exit(1);
|
package/src/cli.ts
CHANGED
package/src/diagnostic-log.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { errorMessage } from "./error-utils";
|
|
2
|
+
import { resolveStatePaths } from "./state-paths";
|
|
2
3
|
import {
|
|
3
4
|
closeSync,
|
|
4
5
|
existsSync,
|
|
@@ -12,7 +13,6 @@ import {
|
|
|
12
13
|
statSync,
|
|
13
14
|
writeFileSync,
|
|
14
15
|
} from "fs";
|
|
15
|
-
import { homedir } from "os";
|
|
16
16
|
import { dirname, join } from "path";
|
|
17
17
|
import { log } from "./log";
|
|
18
18
|
import { packageVersion } from "./package-info";
|
|
@@ -71,14 +71,7 @@ export interface DiagnosticLogSession {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export function resolveDiagnosticLogDir(): string {
|
|
74
|
-
|
|
75
|
-
if (process.platform === "darwin") return join(homedir(), "Library", "Logs", "kesha");
|
|
76
|
-
if (process.platform === "win32") {
|
|
77
|
-
const base = process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local");
|
|
78
|
-
return join(base, "kesha", "logs");
|
|
79
|
-
}
|
|
80
|
-
const base = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state");
|
|
81
|
-
return join(base, "kesha", "logs");
|
|
74
|
+
return resolveStatePaths().logDir.path;
|
|
82
75
|
}
|
|
83
76
|
|
|
84
77
|
export function resolveDiagnosticLogPath(): string {
|
package/src/doctor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import {
|
|
2
|
+
import { cacheComponents, cacheTotalBytes } from "./cache-layout";
|
|
3
3
|
import { errorMessage } from "./error-utils";
|
|
4
4
|
import { humanBytes } from "./format";
|
|
5
5
|
import { dirname, join } from "path";
|
|
@@ -12,9 +12,10 @@ import {
|
|
|
12
12
|
} from "./engine-health";
|
|
13
13
|
import { readInstalledEngineVersion } from "./engine-version-marker";
|
|
14
14
|
import { keshaCacheDir } from "./paths";
|
|
15
|
+
import { installedVoiceIds } from "./voice-inventory";
|
|
15
16
|
import { engineVersion, packageName, packageVersion } from "./package-info";
|
|
16
17
|
import { getStatsStatus, type StatsStatus } from "./stats";
|
|
17
|
-
import { kokoroAneComponents } from "./kokoro-ane";
|
|
18
|
+
import { kokoroAneComponents, kokoroTtsLanguages } from "./kokoro-ane";
|
|
18
19
|
import { isCoremlBackend } from "./fluid-asr-cache";
|
|
19
20
|
import {
|
|
20
21
|
fluidExternalRoots,
|
|
@@ -22,6 +23,7 @@ import {
|
|
|
22
23
|
type FluidExternalRoot,
|
|
23
24
|
} from "./fluid-roots";
|
|
24
25
|
import { diagnosticHomeDir, dirSizeBytes } from "./diagnostic-paths";
|
|
26
|
+
import { collectStatusPaths, type StatusPaths } from "./status";
|
|
25
27
|
import {
|
|
26
28
|
getDiagnosticLogStatus,
|
|
27
29
|
resolveDiagnosticLogDir,
|
|
@@ -29,6 +31,7 @@ import {
|
|
|
29
31
|
} from "./diagnostic-log";
|
|
30
32
|
|
|
31
33
|
const KNOWN_ENV_KEYS = [
|
|
34
|
+
"KESHA_HOME",
|
|
32
35
|
"KESHA_ENGINE_BIN",
|
|
33
36
|
"KESHA_CACHE_DIR",
|
|
34
37
|
"KESHA_MODEL_MIRROR",
|
|
@@ -64,6 +67,15 @@ interface OptionalComponent extends PathSummary {
|
|
|
64
67
|
runnable?: boolean;
|
|
65
68
|
/** Staged asset sets only: required entries that are not on disk (#831). */
|
|
66
69
|
missing?: string[];
|
|
70
|
+
/** Staged asset sets only: Kokoro languages with a voice pack under this path (T2-7). */
|
|
71
|
+
languagesStaged?: string[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What the synthesis path can actually speak with, which no other section of the report states (T2-7). */
|
|
75
|
+
export interface DoctorTts {
|
|
76
|
+
voices: string[];
|
|
77
|
+
languagesStaged: string[];
|
|
78
|
+
languagesMissing: string[];
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
|
|
@@ -118,9 +130,11 @@ export interface DoctorReport {
|
|
|
118
130
|
grandTotalBytes: number;
|
|
119
131
|
};
|
|
120
132
|
optionalComponents: OptionalComponent[];
|
|
133
|
+
tts: DoctorTts;
|
|
121
134
|
stats: StatsStatus | (Partial<StatsStatus> & { error: string });
|
|
122
135
|
diagnosticLogs: DoctorDiagnosticLogStatus;
|
|
123
136
|
env: Record<string, string | null>;
|
|
137
|
+
paths: StatusPaths;
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
function pathSummary(path: string): PathSummary {
|
|
@@ -231,7 +245,7 @@ async function collectEngine(redact: boolean): Promise<DoctorReport["engine"]> {
|
|
|
231
245
|
return {
|
|
232
246
|
path: redactPath(binPath, redact),
|
|
233
247
|
installed,
|
|
234
|
-
versionMarker,
|
|
248
|
+
versionMarker: redactString("versionMarker", versionMarker, redact),
|
|
235
249
|
pinnedVersion: engineVersion,
|
|
236
250
|
versionState: engineVersionState(versionMarker, engineVersion),
|
|
237
251
|
runnable: health.status !== "missing" && health.status !== "unusable",
|
|
@@ -256,14 +270,8 @@ function collectCache(
|
|
|
256
270
|
): DoctorReport["cache"] {
|
|
257
271
|
const cache = keshaCacheDir();
|
|
258
272
|
const binPath = getEngineBinPath();
|
|
259
|
-
const
|
|
260
|
-
const
|
|
261
|
-
cache,
|
|
262
|
-
engineDir,
|
|
263
|
-
isCoremlBackend(backend),
|
|
264
|
-
).map((component) => ({ label: component.label, ...pathSummary(component.path) }));
|
|
265
|
-
const engineOutsideCache = isInsideDir(engineDir, cache) ? 0 : dirSizeBytes(engineDir);
|
|
266
|
-
const totalBytes = dirSizeBytes(cache) + engineOutsideCache;
|
|
273
|
+
const components: CacheComponent[] = cacheComponents(cache, binPath, isCoremlBackend(backend));
|
|
274
|
+
const totalBytes = cacheTotalBytes(cache, binPath);
|
|
267
275
|
const externalRoots = fluidExternalRoots({ homeDir, cacheRoot: cache });
|
|
268
276
|
const externalTotalBytes = fluidExternalTotalBytes(externalRoots);
|
|
269
277
|
|
|
@@ -324,6 +332,7 @@ async function collectOptionalComponents(
|
|
|
324
332
|
exists: c.exists,
|
|
325
333
|
sizeBytes: c.sizeBytes,
|
|
326
334
|
missing: c.missing,
|
|
335
|
+
languagesStaged: c.languagesStaged,
|
|
327
336
|
})),
|
|
328
337
|
{
|
|
329
338
|
// Diarization (#199) and Kokoro (#207) run in-engine now (native
|
|
@@ -339,6 +348,15 @@ async function collectOptionalComponents(
|
|
|
339
348
|
return components.map((component) => redactComponent(component, redact));
|
|
340
349
|
}
|
|
341
350
|
|
|
351
|
+
async function collectTts(homeDir?: string): Promise<DoctorTts> {
|
|
352
|
+
const languages = kokoroTtsLanguages({ homeDir, cacheRoot: keshaCacheDir() });
|
|
353
|
+
return {
|
|
354
|
+
voices: await installedVoiceIds(),
|
|
355
|
+
languagesStaged: languages.staged,
|
|
356
|
+
languagesMissing: languages.missing,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
342
360
|
function collectStats(redact: boolean): DoctorReport["stats"] {
|
|
343
361
|
try {
|
|
344
362
|
const status = getStatsStatus();
|
|
@@ -410,12 +428,33 @@ export async function collectDoctorReport(
|
|
|
410
428
|
engine,
|
|
411
429
|
cache: collectCache(redact, engine.capabilities?.backend, options.homeDir),
|
|
412
430
|
optionalComponents: await collectOptionalComponents(redact, options.homeDir),
|
|
431
|
+
tts: await collectTts(options.homeDir),
|
|
413
432
|
stats: collectStats(redact),
|
|
414
433
|
diagnosticLogs: collectDiagnosticLogs(redact),
|
|
415
434
|
env: collectEnv(redact),
|
|
435
|
+
paths: collectPaths(redact),
|
|
416
436
|
};
|
|
417
437
|
}
|
|
418
438
|
|
|
439
|
+
function collectPaths(redact: boolean): StatusPaths {
|
|
440
|
+
const paths = collectStatusPaths();
|
|
441
|
+
const entry = (p: StatusPaths[keyof StatusPaths]) => ({ ...p, path: redactPath(p.path, redact) });
|
|
442
|
+
return { cache: entry(paths.cache), logs: entry(paths.logs), stats: entry(paths.stats), mcpAudio: entry(paths.mcpAudio) };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function formatPathsSection(paths: StatusPaths): string[] {
|
|
446
|
+
const row = (label: string, p: StatusPaths[keyof StatusPaths]) =>
|
|
447
|
+
` ${label}: ${p.path}${p.source === "default" ? "" : ` (${p.source})`}`;
|
|
448
|
+
return [
|
|
449
|
+
"",
|
|
450
|
+
"Paths:",
|
|
451
|
+
row("Cache", paths.cache),
|
|
452
|
+
row("Logs", paths.logs),
|
|
453
|
+
row("Stats DB", paths.stats),
|
|
454
|
+
row("MCP audio", paths.mcpAudio),
|
|
455
|
+
];
|
|
456
|
+
}
|
|
457
|
+
|
|
419
458
|
function formatComponentState(component: OptionalComponent): string {
|
|
420
459
|
if (!component.exists) return "missing";
|
|
421
460
|
if (component.runnable === false) return CORRUPT_STATE;
|
|
@@ -487,6 +526,16 @@ function formatOptionalSection(components: DoctorReport["optionalComponents"]):
|
|
|
487
526
|
return lines;
|
|
488
527
|
}
|
|
489
528
|
|
|
529
|
+
function formatTtsSection(tts: DoctorTts): string[] {
|
|
530
|
+
return [
|
|
531
|
+
"",
|
|
532
|
+
"TTS:",
|
|
533
|
+
` Voices: ${tts.voices.length === 0 ? "none" : tts.voices.join(", ")}`,
|
|
534
|
+
` Languages staged: ${tts.languagesStaged.length === 0 ? "none" : tts.languagesStaged.join(", ")}`,
|
|
535
|
+
` Languages missing a voice pack: ${tts.languagesMissing.length === 0 ? "none" : tts.languagesMissing.join(", ")}`,
|
|
536
|
+
];
|
|
537
|
+
}
|
|
538
|
+
|
|
490
539
|
function formatStatsSection(stats: DoctorReport["stats"]): string[] {
|
|
491
540
|
const lines = ["", "Stats:"];
|
|
492
541
|
if ("error" in stats) {
|
|
@@ -539,8 +588,10 @@ export function formatDoctorReport(report: DoctorReport): string {
|
|
|
539
588
|
"",
|
|
540
589
|
...formatCacheSection(report.cache),
|
|
541
590
|
...formatOptionalSection(report.optionalComponents),
|
|
591
|
+
...formatTtsSection(report.tts),
|
|
542
592
|
...formatStatsSection(report.stats),
|
|
543
593
|
...formatDiagnosticLogsSection(report.diagnosticLogs),
|
|
594
|
+
...formatPathsSection(report.paths),
|
|
544
595
|
...formatEnvSection(report.env),
|
|
545
596
|
];
|
|
546
597
|
|
package/src/engine/events.ts
CHANGED
|
@@ -215,6 +215,7 @@ const CLI_EXIT_CODES: Record<string, number> = {
|
|
|
215
215
|
E_TEXT_EMPTY: 2,
|
|
216
216
|
E_TEXT_TOO_LONG: 5,
|
|
217
217
|
E_INTERNAL: 4,
|
|
218
|
+
E_INTERRUPTED: 130,
|
|
218
219
|
};
|
|
219
220
|
|
|
220
221
|
/** Process exit status for a failure: an engine-origin error exits with the subprocess's own status (4 when it left none), a CLI-origin one by its code; anything else is the uncoded 4. */
|
|
@@ -224,11 +225,29 @@ export function exitCodeFor(err: unknown): number {
|
|
|
224
225
|
return err.exitCode ?? CLI_EXIT_CODES[err.code] ?? 1;
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
const MAX_QUOTED_LINE_CHARS = 200;
|
|
229
|
+
|
|
230
|
+
/** A library line can carry the user's whole input back, so the quote is bounded and the transcript no longer repeats it (#T1-14). */
|
|
231
|
+
function quoteInvalidLine(line: string): string {
|
|
232
|
+
const chars = Array.from(line);
|
|
233
|
+
return chars.length <= MAX_QUOTED_LINE_CHARS ? line : `${chars.slice(0, MAX_QUOTED_LINE_CHARS).join("")}…`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** One wording for an off-protocol line, whether it fails the run or only warns. */
|
|
237
|
+
export function offProtocolLine(command: string, line: string): string {
|
|
238
|
+
return `kesha-engine ${command} wrote a line that is not a protocol event: "${quoteInvalidLine(line)}"`;
|
|
239
|
+
}
|
|
240
|
+
|
|
227
241
|
/** The KeshaError for a run that wrote a non-event line, reported an error event, or exited non-zero in silence; `stderr` is the transcript unless the caller substitutes one. */
|
|
228
242
|
export function engineFailure(command: string, outcome: StderrOutcome, exitCode: number | undefined, stderr = outcome.stderr.trim()): KeshaError {
|
|
229
243
|
const extra = { exitCode, stderr, origin: "engine" as const };
|
|
230
244
|
if (outcome.invalid.length > 0) {
|
|
231
|
-
|
|
245
|
+
const rest = stderr.split("\n").filter((line) => !outcome.invalid.includes(line)).join("\n").trim();
|
|
246
|
+
return new KeshaError(
|
|
247
|
+
"E_INTERNAL",
|
|
248
|
+
offProtocolLine(command, outcome.invalid[0]!),
|
|
249
|
+
{ ...extra, stderr: rest },
|
|
250
|
+
);
|
|
232
251
|
}
|
|
233
252
|
if (outcome.error) return new KeshaError(outcome.error.code, outcome.error.message, { ...extra, hint: outcome.error.hint });
|
|
234
253
|
return new KeshaError("E_INTERNAL", `kesha-engine ${command} exited with code ${exitCode}`, extra);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { errorMessage } from "../error-utils";
|
|
2
|
+
import { log } from "../log";
|
|
3
|
+
import { abortOnSignal, engineAbortError, pendingInterruption, registerProcessTree } from "../process-tree";
|
|
4
|
+
import { resolveStatePaths } from "../state-paths";
|
|
5
|
+
import { PROTOCOL_VERSION } from "./describe";
|
|
6
|
+
import { KeshaError, readEvents, type EventSinks, type StderrOutcome } from "./events";
|
|
7
|
+
|
|
8
|
+
type SpawnStdioEntry = "inherit" | "pipe" | "ignore";
|
|
9
|
+
type SpawnStdio = [SpawnStdioEntry, SpawnStdioEntry, SpawnStdioEntry];
|
|
10
|
+
type EngineProcess = ReturnType<typeof Bun.spawn>;
|
|
11
|
+
|
|
12
|
+
/** The env for a spawn whose stderr is parsed as protocol 4 events. */
|
|
13
|
+
function protocolEnv(): Record<string, string | undefined> {
|
|
14
|
+
return { ...process.env, KESHA_PROTOCOL: String(PROTOCOL_VERSION) };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function spawnHint(): string {
|
|
18
|
+
return process.env.KESHA_ENGINE_BIN
|
|
19
|
+
? "KESHA_ENGINE_BIN points at it; fix the path or unset it and run `kesha install`"
|
|
20
|
+
: "run `kesha install`";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The engine reads only `KESHA_CACHE_DIR`; it gets the root the CLI resolved whenever that differs from the raw value. */
|
|
24
|
+
function withResolvedCacheDir(env: Record<string, string | undefined>): Record<string, string | undefined> {
|
|
25
|
+
const cache = resolveStatePaths(env).cacheDir;
|
|
26
|
+
const raw = env.KESHA_CACHE_DIR;
|
|
27
|
+
if (cache.source === "default") {
|
|
28
|
+
if (raw === undefined) return env;
|
|
29
|
+
const { KESHA_CACHE_DIR: _empty, ...rest } = env;
|
|
30
|
+
return rest;
|
|
31
|
+
}
|
|
32
|
+
return raw === cache.path ? env : { ...env, KESHA_CACHE_DIR: cache.path };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `Bun.spawn` throws synchronously on ENOENT/EACCES; every launch failure becomes `E_ENGINE_SPAWN`. */
|
|
36
|
+
function spawnEngineProcess(
|
|
37
|
+
binPath: string,
|
|
38
|
+
args: string[],
|
|
39
|
+
stdio: SpawnStdio,
|
|
40
|
+
env: Record<string, string | undefined> = process.env,
|
|
41
|
+
): EngineProcess {
|
|
42
|
+
const interrupted = pendingInterruption();
|
|
43
|
+
if (interrupted) throw interrupted;
|
|
44
|
+
try {
|
|
45
|
+
// `env` is passed explicitly: Bun snapshots process.env at startup otherwise (#874).
|
|
46
|
+
return Bun.spawn([binPath, ...args], { detached: true, stdio, env: withResolvedCacheDir(env) });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
throw new KeshaError("E_ENGINE_SPAWN", `failed to launch kesha-engine at ${binPath}: ${errorMessage(err)}`, {
|
|
49
|
+
hint: spawnHint(),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** An engine that refuses the run before reading closes the pipe; that EPIPE is its answer, reported by the exit status and stderr. */
|
|
55
|
+
async function writeEngineStdin(proc: EngineProcess, text: string): Promise<void> {
|
|
56
|
+
const sink = proc.stdin as Bun.FileSink;
|
|
57
|
+
try {
|
|
58
|
+
sink.write(text);
|
|
59
|
+
await sink.end();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
log.debug(`stdin write failed: ${errorMessage(err)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface EngineProcessOptions<T> {
|
|
66
|
+
/** `[stdin, stdout, stderr]`. A piped stderr is read as protocol 4 events and the engine is told so through its env. */
|
|
67
|
+
stdio: SpawnStdio;
|
|
68
|
+
/** Written to a piped stdin, which is then closed. */
|
|
69
|
+
stdin?: string;
|
|
70
|
+
/** Consumes a piped stdout; `proc` lets a relay stop the engine once nobody reads it. */
|
|
71
|
+
readStdout?: (stream: ReadableStream<Uint8Array>, proc: EngineProcess) => Promise<T>;
|
|
72
|
+
sinks?: EventSinks;
|
|
73
|
+
/** Aborting terminates the process tree, SIGKILL after a grace; an already-aborted signal is `E_INTERRUPTED` before any spawn. */
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface EngineProcessRun<T> {
|
|
78
|
+
exitCode: number;
|
|
79
|
+
signalCode: string | null;
|
|
80
|
+
/** The signal fired while the engine ran: a caller's cancel, or a deadline it set with `AbortSignal.timeout`. */
|
|
81
|
+
aborted: boolean;
|
|
82
|
+
stdout: T;
|
|
83
|
+
events: StderrOutcome;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Spawns the engine (or a sidecar) as its own process tree and waits for it; the tree is unregistered however the run ends. */
|
|
87
|
+
export async function runEngineProcess<T = undefined>(
|
|
88
|
+
binPath: string,
|
|
89
|
+
args: string[],
|
|
90
|
+
opts: EngineProcessOptions<T>,
|
|
91
|
+
): Promise<EngineProcessRun<T>> {
|
|
92
|
+
if (opts.signal?.aborted) throw engineAbortError();
|
|
93
|
+
const startedAt = performance.now();
|
|
94
|
+
log.debug(`spawn ${binPath} ${args.join(" ")}`);
|
|
95
|
+
const eventsOnStderr = opts.stdio[2] === "pipe";
|
|
96
|
+
const proc = spawnEngineProcess(binPath, args, opts.stdio, eventsOnStderr ? protocolEnv() : process.env);
|
|
97
|
+
const tree = registerProcessTree(proc);
|
|
98
|
+
const cancel = abortOnSignal(tree, opts.signal);
|
|
99
|
+
try {
|
|
100
|
+
const [stdout, events, exitCode] = await Promise.all([
|
|
101
|
+
opts.readStdout?.(proc.stdout as ReadableStream<Uint8Array>, proc),
|
|
102
|
+
eventsOnStderr
|
|
103
|
+
? readEvents(proc.stderr as ReadableStream<Uint8Array>, opts.sinks)
|
|
104
|
+
: { stderr: "", error: null, invalid: [] },
|
|
105
|
+
proc.exited,
|
|
106
|
+
opts.stdin === undefined ? undefined : writeEngineStdin(proc, opts.stdin),
|
|
107
|
+
]);
|
|
108
|
+
log.debug(`exit=${exitCode} dt=${Math.round(performance.now() - startedAt)}ms args=${JSON.stringify(args)}`);
|
|
109
|
+
return { exitCode, signalCode: proc.signalCode, aborted: cancel.aborted, stdout: stdout as T, events };
|
|
110
|
+
} finally {
|
|
111
|
+
cancel.dispose();
|
|
112
|
+
tree.dispose();
|
|
113
|
+
}
|
|
114
|
+
}
|
package/src/engine-health.ts
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
import { existsSync } from "fs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
getDescribe,
|
|
5
|
-
getEngineBinPath,
|
|
6
|
-
spawnEngineProcess,
|
|
7
|
-
type EngineCapabilities,
|
|
8
|
-
} from "./engine";
|
|
2
|
+
import { ENGINE_PROBE_TIMEOUT_MS, getDescribe, getEngineBinPath, type EngineCapabilities } from "./engine";
|
|
9
3
|
import { describeToCapabilities } from "./engine/describe";
|
|
10
4
|
import { KeshaError } from "./engine/events";
|
|
11
|
-
import {
|
|
5
|
+
import { runEngineProcess } from "./engine/spawn";
|
|
12
6
|
|
|
13
7
|
export type ExecutableHealth =
|
|
14
8
|
| { status: "ok" }
|
|
15
9
|
| { status: "missing" }
|
|
16
10
|
| { status: "unusable"; detail: string };
|
|
17
11
|
|
|
18
|
-
const PROBE_TIMEOUT_MS = 15_000;
|
|
19
|
-
|
|
20
12
|
/**
|
|
21
13
|
* Proves a downloaded binary actually executes — existence is not health (#770).
|
|
22
14
|
*
|
|
@@ -28,39 +20,32 @@ const PROBE_TIMEOUT_MS = 15_000;
|
|
|
28
20
|
* The timeout guards against a binary that starts but never exits; macOS Gatekeeper can take
|
|
29
21
|
* several seconds to scan a freshly written binary on its first run, so it is generous.
|
|
30
22
|
*/
|
|
23
|
+
/** Message plus hint, not the rendered error: a caller wrapping it would otherwise nest a second code line. */
|
|
24
|
+
function spawnFailureDetail(err: unknown): string {
|
|
25
|
+
if (!(err instanceof Error)) return String(err);
|
|
26
|
+
return err instanceof KeshaError && err.hint ? `${err.message}; ${err.hint}` : err.message;
|
|
27
|
+
}
|
|
28
|
+
|
|
31
29
|
export async function probeExecutable(
|
|
32
30
|
binPath: string,
|
|
33
31
|
args: string[] = [],
|
|
34
32
|
): Promise<ExecutableHealth> {
|
|
35
33
|
if (!existsSync(binPath)) return { status: "missing" };
|
|
36
34
|
|
|
37
|
-
let
|
|
35
|
+
let run: Awaited<ReturnType<typeof runEngineProcess>>;
|
|
38
36
|
try {
|
|
39
|
-
|
|
37
|
+
run = await runEngineProcess(binPath, args, {
|
|
38
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
39
|
+
signal: AbortSignal.timeout(ENGINE_PROBE_TIMEOUT_MS),
|
|
40
|
+
});
|
|
40
41
|
} catch (err) {
|
|
41
|
-
return { status: "unusable", detail:
|
|
42
|
-
}
|
|
43
|
-
const tree = registerProcessTree(proc);
|
|
44
|
-
|
|
45
|
-
let timedOut = false;
|
|
46
|
-
let forceKillTimer: Timer | undefined;
|
|
47
|
-
const timer = setTimeout(() => {
|
|
48
|
-
timedOut = true;
|
|
49
|
-
tree.terminate();
|
|
50
|
-
forceKillTimer = tree.forceKillAfterGrace();
|
|
51
|
-
}, PROBE_TIMEOUT_MS);
|
|
52
|
-
try {
|
|
53
|
-
await proc.exited;
|
|
54
|
-
} finally {
|
|
55
|
-
clearTimeout(timer);
|
|
56
|
-
tree.dispose();
|
|
57
|
-
if (!timedOut && forceKillTimer) clearTimeout(forceKillTimer);
|
|
42
|
+
return { status: "unusable", detail: spawnFailureDetail(err) };
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
if (
|
|
61
|
-
return { status: "unusable", detail: `no exit within ${
|
|
45
|
+
if (run.aborted) {
|
|
46
|
+
return { status: "unusable", detail: `no exit within ${ENGINE_PROBE_TIMEOUT_MS / 1000}s` };
|
|
62
47
|
}
|
|
63
|
-
if (
|
|
48
|
+
if (run.signalCode) return { status: "unusable", detail: `killed by ${run.signalCode}` };
|
|
64
49
|
return { status: "ok" };
|
|
65
50
|
}
|
|
66
51
|
|
|
@@ -73,36 +58,20 @@ export async function probeExecutable(
|
|
|
73
58
|
*/
|
|
74
59
|
export async function readExecutableVersion(
|
|
75
60
|
binPath: string,
|
|
76
|
-
timeoutMs =
|
|
61
|
+
timeoutMs = ENGINE_PROBE_TIMEOUT_MS,
|
|
77
62
|
): Promise<string | null> {
|
|
78
63
|
if (!existsSync(binPath)) return null;
|
|
79
64
|
|
|
80
|
-
let
|
|
65
|
+
let stdout: string;
|
|
81
66
|
try {
|
|
82
|
-
|
|
67
|
+
({ stdout } = await runEngineProcess(binPath, ["--version"], {
|
|
68
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
+
readStdout: (stream) => new Response(stream).text(),
|
|
70
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
71
|
+
}));
|
|
83
72
|
} catch {
|
|
84
73
|
return null;
|
|
85
74
|
}
|
|
86
|
-
const tree = registerProcessTree(proc);
|
|
87
|
-
|
|
88
|
-
let timedOut = false;
|
|
89
|
-
let forceKillTimer: Timer | undefined;
|
|
90
|
-
const timer = setTimeout(() => {
|
|
91
|
-
timedOut = true;
|
|
92
|
-
tree.terminate();
|
|
93
|
-
forceKillTimer = tree.forceKillAfterGrace();
|
|
94
|
-
}, timeoutMs);
|
|
95
|
-
let stdout: string;
|
|
96
|
-
try {
|
|
97
|
-
[stdout] = await Promise.all([
|
|
98
|
-
new Response(proc.stdout as ReadableStream<Uint8Array>).text(),
|
|
99
|
-
proc.exited,
|
|
100
|
-
]);
|
|
101
|
-
} finally {
|
|
102
|
-
clearTimeout(timer);
|
|
103
|
-
tree.dispose();
|
|
104
|
-
if (!timedOut && forceKillTimer) clearTimeout(forceKillTimer);
|
|
105
|
-
}
|
|
106
75
|
return /(\d+\.\d+\.\d+[0-9A-Za-z.+-]*)/.exec(stdout)?.[1] ?? null;
|
|
107
76
|
}
|
|
108
77
|
|
|
@@ -132,7 +101,7 @@ const MUTE_DETAIL = "reports no capabilities";
|
|
|
132
101
|
* that could not be made to answer (`unusable`), not one that answered nothing (`mute`).
|
|
133
102
|
*/
|
|
134
103
|
export async function engineFunctionalHealth(
|
|
135
|
-
timeoutMs =
|
|
104
|
+
timeoutMs = ENGINE_PROBE_TIMEOUT_MS,
|
|
136
105
|
): Promise<EngineFunctionalHealth> {
|
|
137
106
|
const controller = new AbortController();
|
|
138
107
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|