@openparachute/vault 0.6.5-rc.9 → 0.6.5
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/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +56 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
|
@@ -90,4 +90,29 @@ describe("defaultTranscriptionProvider — provider selection", () => {
|
|
|
90
90
|
process.env.TRANSCRIPTION_PROVIDER = "transcribe-cpp";
|
|
91
91
|
expect(defaultTranscriptionProvider().name).toBe("transcribe-cpp");
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
test("TRANSCRIPTION_PROVIDER=parakeet-mlx → the parakeet-mlx provider (2b)", () => {
|
|
95
|
+
process.env.TRANSCRIPTION_PROVIDER = "parakeet-mlx";
|
|
96
|
+
expect(defaultTranscriptionProvider().name).toBe("parakeet-mlx");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("TRANSCRIPTION_PROVIDER=onnx-asr → the onnx-asr provider (2b)", () => {
|
|
100
|
+
process.env.TRANSCRIPTION_PROVIDER = "onnx-asr";
|
|
101
|
+
expect(defaultTranscriptionProvider().name).toBe("onnx-asr");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an unconfigured python provider resolves to disabled (no throw)", async () => {
|
|
105
|
+
// No venv/PATH binary in a fresh env → available() is ok:false and the
|
|
106
|
+
// landing capability is {enabled:false} rather than a crash.
|
|
107
|
+
process.env.TRANSCRIPTION_PROVIDER = "parakeet-mlx";
|
|
108
|
+
const prevBin = process.env.PARAKEET_MLX_BIN;
|
|
109
|
+
process.env.PARAKEET_MLX_BIN = "/definitely/not/a/real/parakeet-mlx";
|
|
110
|
+
try {
|
|
111
|
+
const cap = await resolveTranscriptionCapability(defaultTranscriptionProvider());
|
|
112
|
+
expect(cap.enabled).toBe(false);
|
|
113
|
+
} finally {
|
|
114
|
+
if (prevBin === undefined) delete process.env.PARAKEET_MLX_BIN;
|
|
115
|
+
else process.env.PARAKEET_MLX_BIN = prevBin;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
93
118
|
});
|
|
@@ -17,9 +17,18 @@
|
|
|
17
17
|
import type { TranscriptionProvider } from "../../core/src/transcription/provider.ts";
|
|
18
18
|
import { ScribeHttpProvider } from "./providers/scribe-http.ts";
|
|
19
19
|
import { TranscribeCppProvider } from "./providers/transcribe-cpp.ts";
|
|
20
|
+
import { ParakeetMlxProvider } from "./providers/parakeet-mlx.ts";
|
|
21
|
+
import { OnnxAsrProvider } from "./providers/onnx-asr.ts";
|
|
20
22
|
import { getCachedScribeUrl } from "../scribe-discovery.ts";
|
|
21
23
|
import { resolveScribeAuthToken } from "../scribe-env.ts";
|
|
22
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
resolveTranscriptionProviderName,
|
|
26
|
+
resolveTranscribeCppPaths,
|
|
27
|
+
resolveParakeetMlxBin,
|
|
28
|
+
resolveParakeetMlxModel,
|
|
29
|
+
resolveOnnxAsrBin,
|
|
30
|
+
resolveOnnxAsrModel,
|
|
31
|
+
} from "./select.ts";
|
|
23
32
|
|
|
24
33
|
export interface TranscriptionCapability {
|
|
25
34
|
/** True when a provider is configured AND available. Notes gates the mic on this. */
|
|
@@ -36,16 +45,32 @@ export interface TranscriptionCapability {
|
|
|
36
45
|
* - `transcribe-cpp` → the local provider, resolving the installed binary +
|
|
37
46
|
* GGUF model paths; `available()` is `false` until `transcription install`
|
|
38
47
|
* has run.
|
|
48
|
+
* - `parakeet-mlx` / `onnx-asr` (scribe-fold Phase 2b) → the Python-based
|
|
49
|
+
* local providers, resolving the binary via env override → managed venv →
|
|
50
|
+
* PATH; `available()` is `false` until a runnable binary exists.
|
|
39
51
|
* - `scribe-http` (default) → the remote provider (URL from
|
|
40
52
|
* `SCRIBE_URL`/services.json, bearer from `SCRIBE_AUTH_TOKEN`). When scribe
|
|
41
53
|
* isn't discoverable the URL is undefined and it reports itself unavailable
|
|
42
54
|
* rather than throwing.
|
|
43
55
|
*/
|
|
44
56
|
export function defaultTranscriptionProvider(): TranscriptionProvider {
|
|
45
|
-
|
|
57
|
+
const name = resolveTranscriptionProviderName();
|
|
58
|
+
if (name === "transcribe-cpp") {
|
|
46
59
|
const paths = resolveTranscribeCppPaths();
|
|
47
60
|
return new TranscribeCppProvider({ binPath: paths.binPath, modelPath: paths.modelPath });
|
|
48
61
|
}
|
|
62
|
+
if (name === "parakeet-mlx") {
|
|
63
|
+
return new ParakeetMlxProvider({
|
|
64
|
+
binPath: resolveParakeetMlxBin(),
|
|
65
|
+
model: resolveParakeetMlxModel(),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (name === "onnx-asr") {
|
|
69
|
+
return new OnnxAsrProvider({
|
|
70
|
+
binPath: resolveOnnxAsrBin(),
|
|
71
|
+
model: resolveOnnxAsrModel(),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
49
74
|
return new ScribeHttpProvider({
|
|
50
75
|
url: getCachedScribeUrl(),
|
|
51
76
|
token: resolveScribeAuthToken(),
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { downloadTo } from "./download.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `downloadTo` tests (vault#534 blocker 1). The manual body→FileSink pump
|
|
9
|
+
* replaced `Bun.write(dest, resp)`, which hangs forever on Linux for large
|
|
10
|
+
* responses. These run the pump against an in-process `Bun.serve` (no
|
|
11
|
+
* subprocess involved — see CLAUDE.md's spawnSync note; plain in-process
|
|
12
|
+
* fetches are fine) and pin: bytes land intact, HTTP errors throw, and a
|
|
13
|
+
* mid-stream failure never leaves a partial file behind.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const dir = mkdtempSync(join(tmpdir(), "vault-download-test-"));
|
|
17
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
18
|
+
|
|
19
|
+
// 4MB of deterministic non-trivial bytes — large enough to stream in many
|
|
20
|
+
// chunks (the hang was specific to the large-response path).
|
|
21
|
+
const PAYLOAD = new Uint8Array(4 * 1024 * 1024);
|
|
22
|
+
for (let i = 0; i < PAYLOAD.length; i++) PAYLOAD[i] = (i * 31 + (i >> 8)) & 0xff;
|
|
23
|
+
|
|
24
|
+
function serve(handler: (req: Request) => Response | Promise<Response>) {
|
|
25
|
+
const server = Bun.serve({ port: 0, fetch: handler });
|
|
26
|
+
return { server, url: `http://127.0.0.1:${server.port}` };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("downloadTo", () => {
|
|
30
|
+
test("streams a multi-MB body to disk byte-for-byte", async () => {
|
|
31
|
+
const { server, url } = serve(() => new Response(PAYLOAD));
|
|
32
|
+
const dest = join(dir, "ok.bin");
|
|
33
|
+
try {
|
|
34
|
+
await downloadTo(`${url}/file`, dest);
|
|
35
|
+
const got = readFileSync(dest);
|
|
36
|
+
expect(got.byteLength).toBe(PAYLOAD.byteLength);
|
|
37
|
+
expect(Buffer.from(got).equals(Buffer.from(PAYLOAD))).toBe(true);
|
|
38
|
+
} finally {
|
|
39
|
+
server.stop(true);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("non-OK status throws with the status + URL, writes nothing", async () => {
|
|
44
|
+
const { server, url } = serve(() => new Response("nope", { status: 404 }));
|
|
45
|
+
const dest = join(dir, "missing.bin");
|
|
46
|
+
try {
|
|
47
|
+
expect(downloadTo(`${url}/gone`, dest)).rejects.toThrow(/download failed \(404\)/);
|
|
48
|
+
expect(existsSync(dest)).toBe(false);
|
|
49
|
+
} finally {
|
|
50
|
+
server.stop(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("truncated download throws AND removes the partial file", async () => {
|
|
55
|
+
// Raw TCP fixture: a real content-length response (how GitHub releases +
|
|
56
|
+
// HuggingFace serve these binaries) whose connection dies mid-body.
|
|
57
|
+
// Bun.serve can't fixture this — it re-frames streamed responses as
|
|
58
|
+
// chunked (dropping content-length), and Bun's fetch ends a truncated
|
|
59
|
+
// chunked stream CLEANLY, so truncation is only detectable on the
|
|
60
|
+
// content-length path `downloadTo` verifies.
|
|
61
|
+
const partial = PAYLOAD.slice(0, 64 * 1024);
|
|
62
|
+
const listener = Bun.listen({
|
|
63
|
+
hostname: "127.0.0.1",
|
|
64
|
+
port: 0,
|
|
65
|
+
socket: {
|
|
66
|
+
data(socket) {
|
|
67
|
+
socket.write(
|
|
68
|
+
`HTTP/1.1 200 OK\r\ncontent-type: application/octet-stream\r\ncontent-length: ${PAYLOAD.byteLength}\r\n\r\n`,
|
|
69
|
+
);
|
|
70
|
+
socket.write(partial);
|
|
71
|
+
socket.end(); // die after 64KB of a declared 4MB
|
|
72
|
+
},
|
|
73
|
+
open() {},
|
|
74
|
+
close() {},
|
|
75
|
+
error() {},
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const dest = join(dir, "partial.bin");
|
|
79
|
+
try {
|
|
80
|
+
let threw = false;
|
|
81
|
+
try {
|
|
82
|
+
await downloadTo(`http://127.0.0.1:${listener.port}/flaky`, dest);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
threw = true;
|
|
85
|
+
// The truncation surfaces differently per platform: on macOS the body
|
|
86
|
+
// stream errors mid-pump (wrapped "failed after N bytes") or the
|
|
87
|
+
// short read trips the content-length check ("truncated"); on Linux
|
|
88
|
+
// Bun's fetch() itself rejects on the early socket close. All are
|
|
89
|
+
// loud failures — the invariants are THROWS + NO PARTIAL FILE.
|
|
90
|
+
expect(String(err)).toMatch(
|
|
91
|
+
/failed after \d+ bytes|truncated|socket connection was closed|connection closed/i,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
expect(threw).toBe(true);
|
|
95
|
+
// The half-written file must not survive to be mistaken for a good artifact.
|
|
96
|
+
expect(existsSync(dest)).toBe(false);
|
|
97
|
+
} finally {
|
|
98
|
+
listener.stop(true);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming file download for `transcription install` (tarball, GGUF models,
|
|
3
|
+
* CLI sources).
|
|
4
|
+
*
|
|
5
|
+
* ## Why a manual pump instead of `Bun.write(dest, resp)` (vault#534 blocker 1)
|
|
6
|
+
*
|
|
7
|
+
* `Bun.write(dest, response)` HANGS FOREVER on Linux when streaming a large
|
|
8
|
+
* Response body to disk — zero bytes land, no error, no timeout. Reproduced
|
|
9
|
+
* deterministically in real Linux containers on BOTH arches (aarch64 native +
|
|
10
|
+
* x86_64 emulated) across bun 1.2.23 / 1.3.13 / 1.3.14, while the identical
|
|
11
|
+
* code works on macOS. In the same environment `curl` pulls the same URL at
|
|
12
|
+
* full speed and `await resp.arrayBuffer()` gets all 26MB in ~2s — the bug is
|
|
13
|
+
* specifically Bun's Response→file streaming fast path. Manually pumping
|
|
14
|
+
* `resp.body` chunks into a `Bun.file(dest).writer()` moves the same 26MB in
|
|
15
|
+
* <1s and works on both platforms, so that's what we do everywhere (no
|
|
16
|
+
* platform gate). See vault#534 for the full container verification.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { rmSync } from "fs";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Download `url` to `dest` (follows redirects). Streams chunk-by-chunk — never
|
|
23
|
+
* buffers the whole body (models run up to ~660MB). On any mid-stream failure
|
|
24
|
+
* the partial file is removed so a retry never trusts a truncated artifact.
|
|
25
|
+
* When the server sent an honest `content-length` (no content-encoding
|
|
26
|
+
* transform), a byte-count mismatch is treated as a failed download too.
|
|
27
|
+
*/
|
|
28
|
+
export async function downloadTo(url: string, dest: string): Promise<void> {
|
|
29
|
+
const resp = await fetch(url, { redirect: "follow" });
|
|
30
|
+
if (!resp.ok) throw new Error(`download failed (${resp.status}) for ${url}`);
|
|
31
|
+
if (!resp.body) throw new Error(`download failed (empty response body) for ${url}`);
|
|
32
|
+
|
|
33
|
+
// content-length is only the on-the-wire byte count when no transfer
|
|
34
|
+
// decompression happened; fetch auto-decodes content-encoding'd bodies, so
|
|
35
|
+
// only enforce the size check for identity responses (the normal case for
|
|
36
|
+
// release tarballs + GGUFs — both already-compressed binary).
|
|
37
|
+
const encoding = resp.headers.get("content-encoding");
|
|
38
|
+
const lengthHeader = resp.headers.get("content-length");
|
|
39
|
+
const expectedBytes =
|
|
40
|
+
(!encoding || encoding === "identity") && lengthHeader && /^\d+$/.test(lengthHeader)
|
|
41
|
+
? Number(lengthHeader)
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
const sink = Bun.file(dest).writer();
|
|
45
|
+
let written = 0;
|
|
46
|
+
try {
|
|
47
|
+
for await (const chunk of resp.body) {
|
|
48
|
+
sink.write(chunk);
|
|
49
|
+
written += chunk.byteLength;
|
|
50
|
+
}
|
|
51
|
+
await sink.end();
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// Flush/close what we can, then remove the partial file — a half-written
|
|
54
|
+
// tarball/model must never survive to be mistaken for a good artifact.
|
|
55
|
+
try {
|
|
56
|
+
await sink.end();
|
|
57
|
+
} catch {
|
|
58
|
+
// best-effort close; the rm below is what matters
|
|
59
|
+
}
|
|
60
|
+
rmSync(dest, { force: true });
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
throw new Error(`download of ${url} failed after ${written} bytes: ${msg}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (expectedBytes !== null && written !== expectedBytes) {
|
|
66
|
+
rmSync(dest, { force: true });
|
|
67
|
+
throw new Error(
|
|
68
|
+
`download of ${url} was truncated: got ${written} bytes, expected ${expectedBytes} (content-length)`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import {
|
|
4
|
+
PYTHON_PROVIDERS,
|
|
5
|
+
installPythonBackend,
|
|
6
|
+
type PythonInstallDeps,
|
|
7
|
+
type PythonProviderName,
|
|
8
|
+
type RunResult,
|
|
9
|
+
} from "./install-python.ts";
|
|
10
|
+
import { pythonVenvDir } from "./select.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Install-routine tests for the python providers (scribe-fold Phase 2b) —
|
|
14
|
+
* every subprocess/platform probe goes through the injected deps, so the tier
|
|
15
|
+
* guards, apt/venv logic, warm-pull, and idempotency are exercised WITHOUT
|
|
16
|
+
* installing anything. Ported discipline from scribe's install-backend tests.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const GB = 2 ** 30;
|
|
20
|
+
const HOME = "/test-home";
|
|
21
|
+
|
|
22
|
+
type DepsOverrides = Partial<Omit<PythonInstallDeps, "run" | "which" | "existsImpl">> & {
|
|
23
|
+
run?: (cmd: string[]) => Promise<RunResult>;
|
|
24
|
+
/** Binaries "on PATH". */
|
|
25
|
+
path?: Record<string, string>;
|
|
26
|
+
/** Files that "exist" on disk. */
|
|
27
|
+
files?: Set<string>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function makeDeps(over: DepsOverrides = {}): { deps: PythonInstallDeps; runs: string[][]; logs: string[] } {
|
|
31
|
+
const runs: string[][] = [];
|
|
32
|
+
const logs: string[] = [];
|
|
33
|
+
const files = over.files ?? new Set<string>();
|
|
34
|
+
const path = over.path ?? {};
|
|
35
|
+
const deps: PythonInstallDeps = {
|
|
36
|
+
run: async (cmd) => {
|
|
37
|
+
runs.push(cmd);
|
|
38
|
+
if (over.run) return over.run(cmd);
|
|
39
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
40
|
+
},
|
|
41
|
+
which: (bin) => path[bin] ?? null,
|
|
42
|
+
platform: over.platform ?? "linux",
|
|
43
|
+
arch: over.arch ?? "x64",
|
|
44
|
+
totalRamBytes: over.totalRamBytes ?? 8 * GB,
|
|
45
|
+
uid: over.uid ?? (() => 1000),
|
|
46
|
+
env: over.env ?? { PARACHUTE_HOME: HOME },
|
|
47
|
+
existsImpl: (p) => files.has(p),
|
|
48
|
+
log: (line) => logs.push(line),
|
|
49
|
+
};
|
|
50
|
+
return { deps, runs, logs };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const venvBin = (bin: string) => join(pythonVenvDir({ PARACHUTE_HOME: HOME }), "bin", bin);
|
|
54
|
+
|
|
55
|
+
/** Deps for a happy-path Linux onnx-asr install: python3+ffmpeg present, and
|
|
56
|
+
* the venv binary "appears" after the pip run. */
|
|
57
|
+
function linuxHappyDeps(provider: PythonProviderName = "onnx-asr") {
|
|
58
|
+
const spec = PYTHON_PROVIDERS[provider];
|
|
59
|
+
const files = new Set<string>();
|
|
60
|
+
const { deps, runs, logs } = makeDeps({
|
|
61
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/usr/bin/ffmpeg", sudo: "/usr/bin/sudo" },
|
|
62
|
+
files,
|
|
63
|
+
run: async (cmd) => {
|
|
64
|
+
// pip install → the venv binary materializes.
|
|
65
|
+
if (cmd.some((c) => c.endsWith("/pip"))) files.add(venvBin(spec.bin));
|
|
66
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
return { deps, runs, logs, files };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Platform / arch gates (never bypassed)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
describe("installPythonBackend — platform gates", () => {
|
|
77
|
+
test("parakeet-mlx on Linux → refused (darwin-only), even with --force", async () => {
|
|
78
|
+
const { deps, runs } = makeDeps({ platform: "linux" });
|
|
79
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx", force: true });
|
|
80
|
+
expect(out.ok).toBe(false);
|
|
81
|
+
expect(out.steps[0]!.status).toBe("refused");
|
|
82
|
+
expect(out.summary).toContain("darwin");
|
|
83
|
+
expect(runs.length).toBe(0); // nothing ran
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("parakeet-mlx on Intel mac (darwin/x64) → refused (Apple Silicon only)", async () => {
|
|
87
|
+
const { deps } = makeDeps({ platform: "darwin", arch: "x64", totalRamBytes: 32 * GB });
|
|
88
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx", force: true });
|
|
89
|
+
expect(out.ok).toBe(false);
|
|
90
|
+
expect(out.summary).toContain("arm64");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("onnx-asr has no platform gate (installable on macOS too)", async () => {
|
|
94
|
+
const files = new Set<string>([venvBin("onnx-asr")]); // already installed → skips pip
|
|
95
|
+
const { deps } = makeDeps({
|
|
96
|
+
platform: "darwin",
|
|
97
|
+
arch: "arm64",
|
|
98
|
+
files,
|
|
99
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/opt/ffmpeg" },
|
|
100
|
+
});
|
|
101
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
102
|
+
expect(out.ok).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// RAM floors (the ratified tiers; --force bypasses)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
describe("installPythonBackend — RAM floors", () => {
|
|
111
|
+
test("onnx-asr below 4GB → refused with the scribe#82 rationale", async () => {
|
|
112
|
+
const { deps } = makeDeps({ totalRamBytes: 2 * GB });
|
|
113
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr" });
|
|
114
|
+
expect(out.ok).toBe(false);
|
|
115
|
+
const ram = out.steps.find((s) => s.name === "ram-guard")!;
|
|
116
|
+
expect(ram.status).toBe("refused");
|
|
117
|
+
expect(ram.detail).toContain("scribe#82");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("parakeet-mlx below 8GB → refused, steers to transcribe-cpp", async () => {
|
|
121
|
+
const { deps } = makeDeps({ platform: "darwin", arch: "arm64", totalRamBytes: 4 * GB });
|
|
122
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx" });
|
|
123
|
+
expect(out.ok).toBe(false);
|
|
124
|
+
expect(out.summary).toContain("transcribe-cpp");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("nominal-4GB box reporting 3.8GB → passes the onnx-asr floor (slack)", async () => {
|
|
128
|
+
const { deps } = linuxHappyDeps();
|
|
129
|
+
deps.totalRamBytes = 3.8 * GB;
|
|
130
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
131
|
+
expect(out.steps.find((s) => s.name === "ram-guard")!.status).toBe("ok");
|
|
132
|
+
expect(out.ok).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("--force bypasses the RAM floor with a loud warning step", async () => {
|
|
136
|
+
const { deps, files } = linuxHappyDeps();
|
|
137
|
+
deps.totalRamBytes = 2 * GB;
|
|
138
|
+
void files;
|
|
139
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", force: true, skipModel: true });
|
|
140
|
+
expect(out.ok).toBe(true);
|
|
141
|
+
const ram = out.steps.find((s) => s.name === "ram-guard")!;
|
|
142
|
+
expect(ram.status).toBe("skipped");
|
|
143
|
+
expect(ram.detail).toContain("--force");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// System deps (apt on Linux; instruct on macOS)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe("installPythonBackend — system deps", () => {
|
|
152
|
+
test("Linux with python3+ffmpeg present → apt skipped entirely", async () => {
|
|
153
|
+
const { deps, runs } = linuxHappyDeps();
|
|
154
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
155
|
+
expect(out.steps.find((s) => s.name === "system-deps")!.status).toBe("skipped");
|
|
156
|
+
expect(runs.some((r) => r.includes("apt-get"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("Linux missing ffmpeg, non-root with sudo → sudo apt-get install", async () => {
|
|
160
|
+
const files = new Set<string>();
|
|
161
|
+
const { deps, runs } = makeDeps({
|
|
162
|
+
path: { python3: "/usr/bin/python3", sudo: "/usr/bin/sudo", "apt-get": "/usr/bin/apt-get" },
|
|
163
|
+
files,
|
|
164
|
+
run: async (cmd) => {
|
|
165
|
+
if (cmd.some((c) => c.endsWith("/pip"))) files.add(venvBin("onnx-asr"));
|
|
166
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
170
|
+
expect(out.ok).toBe(true);
|
|
171
|
+
const aptInstall = runs.find((r) => r.includes("install") && r.includes("apt-get"))!;
|
|
172
|
+
expect(aptInstall[0]).toBe("sudo");
|
|
173
|
+
expect(aptInstall).toContain("ffmpeg");
|
|
174
|
+
expect(aptInstall).toContain("python3-venv");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("Linux as root → apt without sudo", async () => {
|
|
178
|
+
const files = new Set<string>();
|
|
179
|
+
const { deps, runs } = makeDeps({
|
|
180
|
+
uid: () => 0,
|
|
181
|
+
path: { python3: "/usr/bin/python3", "apt-get": "/usr/bin/apt-get" },
|
|
182
|
+
files,
|
|
183
|
+
run: async (cmd) => {
|
|
184
|
+
if (cmd.some((c) => c.endsWith("/pip"))) files.add(venvBin("onnx-asr"));
|
|
185
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
189
|
+
const aptInstall = runs.find((r) => r.includes("install") && r.includes("apt-get"))!;
|
|
190
|
+
expect(aptInstall[0]).toBe("apt-get");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("Linux, deps missing, no root and no sudo → failed step with the exact command to run", async () => {
|
|
194
|
+
const { deps } = makeDeps({ path: { "apt-get": "/usr/bin/apt-get" } });
|
|
195
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr" });
|
|
196
|
+
expect(out.ok).toBe(false);
|
|
197
|
+
expect(out.summary).toContain("sudo apt-get install");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a transient apt-get update failure does NOT abort (install still runs)", async () => {
|
|
201
|
+
const files = new Set<string>();
|
|
202
|
+
const { deps } = makeDeps({
|
|
203
|
+
path: { python3: "/usr/bin/python3", sudo: "/usr/bin/sudo", "apt-get": "/usr/bin/apt-get" },
|
|
204
|
+
files,
|
|
205
|
+
run: async (cmd) => {
|
|
206
|
+
if (cmd.includes("update")) return { exitCode: 100, stdout: "", stderr: "mirror down" };
|
|
207
|
+
if (cmd.some((c) => c.endsWith("/pip"))) files.add(venvBin("onnx-asr"));
|
|
208
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
212
|
+
expect(out.ok).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("macOS missing ffmpeg → instruct (skipped step naming brew), never drives brew", async () => {
|
|
216
|
+
const files = new Set<string>([venvBin("parakeet-mlx")]);
|
|
217
|
+
const { deps, runs } = makeDeps({
|
|
218
|
+
platform: "darwin",
|
|
219
|
+
arch: "arm64",
|
|
220
|
+
totalRamBytes: 16 * GB,
|
|
221
|
+
path: { python3: "/usr/bin/python3" },
|
|
222
|
+
files,
|
|
223
|
+
});
|
|
224
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx" });
|
|
225
|
+
const sys = out.steps.find((s) => s.name === "system-deps")!;
|
|
226
|
+
expect(sys.status).toBe("skipped");
|
|
227
|
+
expect(sys.detail).toContain("brew install ffmpeg");
|
|
228
|
+
expect(runs.some((r) => r[0] === "brew")).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("macOS missing python3 entirely → failed with an actionable fix", async () => {
|
|
232
|
+
const { deps } = makeDeps({ platform: "darwin", arch: "arm64", totalRamBytes: 16 * GB });
|
|
233
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx" });
|
|
234
|
+
expect(out.ok).toBe(false);
|
|
235
|
+
expect(out.summary).toContain("python3");
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
// venv + pip + idempotency
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
describe("installPythonBackend — venv package install", () => {
|
|
244
|
+
test("happy path: venv created under PARACHUTE_HOME, pip installs the extras target", async () => {
|
|
245
|
+
const { deps, runs } = linuxHappyDeps();
|
|
246
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
247
|
+
expect(out.ok).toBe(true);
|
|
248
|
+
expect(out.binPath).toBe(venvBin("onnx-asr"));
|
|
249
|
+
expect(out.venv).toBe(pythonVenvDir({ PARACHUTE_HOME: HOME }));
|
|
250
|
+
const mkVenv = runs.find((r) => r[0] === "python3" && r.includes("venv"))!;
|
|
251
|
+
expect(mkVenv[3]).toBe(pythonVenvDir({ PARACHUTE_HOME: HOME }));
|
|
252
|
+
const pip = runs.find((r) => r[0]?.endsWith("/pip"))!;
|
|
253
|
+
expect(pip).toContain("onnx-asr[cpu,hub]");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("idempotent: an already-runnable binary skips venv+pip entirely", async () => {
|
|
257
|
+
const files = new Set<string>([venvBin("onnx-asr")]);
|
|
258
|
+
const { deps, runs } = makeDeps({
|
|
259
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/usr/bin/ffmpeg" },
|
|
260
|
+
files,
|
|
261
|
+
});
|
|
262
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
263
|
+
expect(out.ok).toBe(true);
|
|
264
|
+
expect(out.steps.find((s) => s.name === "package")!.status).toBe("skipped");
|
|
265
|
+
expect(runs.some((r) => r[0] === "python3")).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("an operator's own PATH install counts as runnable (no venv forced)", async () => {
|
|
269
|
+
const files = new Set<string>(["/usr/local/bin/parakeet-mlx"]);
|
|
270
|
+
const { deps } = makeDeps({
|
|
271
|
+
platform: "darwin",
|
|
272
|
+
arch: "arm64",
|
|
273
|
+
totalRamBytes: 16 * GB,
|
|
274
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/opt/ffmpeg", "parakeet-mlx": "/usr/local/bin/parakeet-mlx" },
|
|
275
|
+
files,
|
|
276
|
+
});
|
|
277
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx" });
|
|
278
|
+
expect(out.ok).toBe(true);
|
|
279
|
+
expect(out.binPath).toBe("/usr/local/bin/parakeet-mlx");
|
|
280
|
+
expect(out.venv).toBe(""); // not vault's venv
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("pip failure → failed step, non-fatal outcome (no throw)", async () => {
|
|
284
|
+
const { deps } = makeDeps({
|
|
285
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/usr/bin/ffmpeg" },
|
|
286
|
+
run: async (cmd) =>
|
|
287
|
+
cmd[0]?.endsWith("/pip")
|
|
288
|
+
? { exitCode: 1, stdout: "", stderr: "no matching distribution" }
|
|
289
|
+
: { exitCode: 0, stdout: "", stderr: "" },
|
|
290
|
+
});
|
|
291
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr" });
|
|
292
|
+
expect(out.ok).toBe(false);
|
|
293
|
+
expect(out.summary).toContain("pip install onnx-asr[cpu,hub] failed");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("venv creation failure names python3-venv", async () => {
|
|
297
|
+
const { deps } = makeDeps({
|
|
298
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/usr/bin/ffmpeg" },
|
|
299
|
+
run: async (cmd) =>
|
|
300
|
+
cmd[0] === "python3"
|
|
301
|
+
? { exitCode: 1, stdout: "", stderr: "ensurepip missing" }
|
|
302
|
+
: { exitCode: 0, stdout: "", stderr: "" },
|
|
303
|
+
});
|
|
304
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr" });
|
|
305
|
+
expect(out.ok).toBe(false);
|
|
306
|
+
expect(out.summary).toContain("python3-venv");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Model warm-pull (best-effort) + verify
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
describe("installPythonBackend — warm-pull + verify", () => {
|
|
315
|
+
test("onnx-asr warm-pulls the ratified model via `<bin> <model> --help`", async () => {
|
|
316
|
+
const { deps, runs } = linuxHappyDeps();
|
|
317
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr" });
|
|
318
|
+
expect(out.ok).toBe(true);
|
|
319
|
+
const pull = runs.find((r) => r.includes("--help"))!;
|
|
320
|
+
expect(pull[0]).toBe(venvBin("onnx-asr"));
|
|
321
|
+
expect(pull[1]).toBe("nemo-parakeet-tdt-0.6b-v3");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("a warm-pull failure is non-fatal (model lazy-loads on first transcription)", async () => {
|
|
325
|
+
const files = new Set<string>();
|
|
326
|
+
const { deps } = makeDeps({
|
|
327
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/usr/bin/ffmpeg" },
|
|
328
|
+
files,
|
|
329
|
+
run: async (cmd) => {
|
|
330
|
+
if (cmd.some((c) => c.endsWith("/pip"))) files.add(venvBin("onnx-asr"));
|
|
331
|
+
if (cmd.includes("--help")) return { exitCode: 1, stdout: "", stderr: "offline" };
|
|
332
|
+
return { exitCode: 0, stdout: "", stderr: "" };
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr" });
|
|
336
|
+
expect(out.ok).toBe(true);
|
|
337
|
+
expect(out.steps.find((s) => s.name === "model-warm-pull")!.status).toBe("skipped");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("parakeet-mlx never warm-pulls (model downloads on first use)", async () => {
|
|
341
|
+
const files = new Set<string>([venvBin("parakeet-mlx")]);
|
|
342
|
+
const { deps, runs } = makeDeps({
|
|
343
|
+
platform: "darwin",
|
|
344
|
+
arch: "arm64",
|
|
345
|
+
totalRamBytes: 16 * GB,
|
|
346
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/opt/ffmpeg" },
|
|
347
|
+
files,
|
|
348
|
+
});
|
|
349
|
+
const out = await installPythonBackend(deps, { provider: "parakeet-mlx" });
|
|
350
|
+
expect(out.ok).toBe(true);
|
|
351
|
+
expect(out.steps.find((s) => s.name === "model-warm-pull")!.status).toBe("skipped");
|
|
352
|
+
expect(runs.some((r) => r.includes("--help"))).toBe(false);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("HONEST verify: pip 'succeeds' but no binary appears → ok:false, no activation signal", async () => {
|
|
356
|
+
// The honest-install rule (PRs #532/#533): never report ok without a
|
|
357
|
+
// runnable binary — the CLI only flips TRANSCRIPTION_PROVIDER on ok.
|
|
358
|
+
const { deps } = makeDeps({
|
|
359
|
+
path: { python3: "/usr/bin/python3", ffmpeg: "/usr/bin/ffmpeg" },
|
|
360
|
+
});
|
|
361
|
+
const out = await installPythonBackend(deps, { provider: "onnx-asr", skipModel: true });
|
|
362
|
+
expect(out.ok).toBe(false);
|
|
363
|
+
expect(out.binPath).toBeUndefined();
|
|
364
|
+
expect(out.steps.find((s) => s.name === "verify")!.status).toBe("failed");
|
|
365
|
+
});
|
|
366
|
+
});
|