Skip to content

Commit c6c1343

Browse files
authored
perf(electron): ship optional ML/browser deps as installable packs (#10382)
Stage 7 of issue #10321 moves the optional ML and browser automation dependency closures out of the desktop bundle into checksummed, versioned packs installed on demand through the omniroute packs command. - scripts/build/optionalPackStaging.mjs stages pack members under .build/optional-packs, creates release tarballs, and emits optional-packs.index.json with per-member SHA-256 checksums. - scripts/packs provides manifest, install, remove, and verification helpers plus the packs CLI commands. - Runtime lookup includes installed pack node_modules directories, while LLMLingua and browser executors continue to degrade gracefully when packs are absent. The measured darwin-arm64 staging closure was about 534 MB of the 929 MB standalone node_modules tree (57%).
1 parent 2162289 commit c6c1343

19 files changed

Lines changed: 1495 additions & 3 deletions

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2398,6 +2398,12 @@ APP_LOG_TO_FILE=true
23982398
# intended to be published as `omniroute-secure`. See SECURITY.md.
23992399
# OMNIROUTE_BUILD_PROFILE=full
24002400

2401+
# Skip emitting `.tar.gz` tarballs during optional-pack staging for the Electron
2402+
# standalone tree (pack directories + optional-packs.index.json are still produced).
2403+
# Used by the desktop release workflow to trim artifact upload size.
2404+
# Default (when unset): 1 (tarballs emitted). Set to 0 to disable.
2405+
# OMNIROUTE_OPTIONAL_PACK_TAR=1
2406+
24012407
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
24022408
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
24032409
# ELECTRON_SMOKE_TIMEOUT_MS=45000

bin/cli/commands/packs.mjs

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import path from "node:path";
2+
import { fileURLToPath } from "node:url";
3+
4+
import { t } from "../i18n.mjs";
5+
import { resolveDataDir } from "../data-dir.mjs";
6+
import {
7+
EXIT_CODES,
8+
emit,
9+
exitWith,
10+
printError,
11+
printInfo,
12+
printSuccess,
13+
printWarning,
14+
} from "../output.mjs";
15+
import { findPack } from "../../../scripts/packs/optionalPackManifest.mjs";
16+
import {
17+
findPackIndexFile,
18+
installPack,
19+
listPackStates,
20+
packState,
21+
packsRoot,
22+
readPackIndex,
23+
removePack,
24+
} from "../../../scripts/packs/optionalPackInstaller.mjs";
25+
26+
const CLI_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
27+
28+
/**
29+
* Locate + parse the bundle-shipped `optional-packs.index.json`.
30+
* Search order: explicit --source dir, then walking up from the CLI module
31+
* (bundle installs keep the index at the bundle root), then cwd.
32+
*/
33+
function loadIndex(sourceDir) {
34+
const indexFile = findPackIndexFile([sourceDir, CLI_DIR, process.cwd()]);
35+
if (!indexFile) return { indexFile: null, index: null };
36+
return { indexFile, index: readPackIndex(indexFile) };
37+
}
38+
39+
function stateRow(state, dataDir) {
40+
return {
41+
pack: state.name,
42+
packVersion: state.packVersion,
43+
installed: state.installed ? "yes" : "no",
44+
verified: state.verified === null ? "-" : state.verified ? "ok" : "FAILED",
45+
members: state.members.length,
46+
installDir: path.join(packsRoot(dataDir), state.name),
47+
errors: state.errors ?? [],
48+
};
49+
}
50+
51+
const STATE_SCHEMA = [
52+
{ key: "pack", header: "pack" },
53+
{ key: "packVersion", header: "packVersion" },
54+
{ key: "installed", header: "installed" },
55+
{ key: "verified", header: "verified" },
56+
{ key: "members", header: "members" },
57+
];
58+
59+
async function run(action) {
60+
try {
61+
await action();
62+
} catch (err) {
63+
exitWith(EXIT_CODES.ERROR, err instanceof Error ? err.message : String(err));
64+
}
65+
}
66+
67+
export function registerPacks(program) {
68+
const packs = program.command("packs").description(t("packs.description"));
69+
70+
packs
71+
.command("list")
72+
.description(t("packs.listDescription"))
73+
.option("--source <dir>", t("packs.sourceOpt"))
74+
.action(async (opts) => {
75+
await run(async () => {
76+
const dataDir = resolveDataDir();
77+
const { index } = loadIndex(opts.source);
78+
emit(
79+
(await listPackStates({ dataDir, index })).map((s) => stateRow(s, dataDir)),
80+
opts,
81+
STATE_SCHEMA
82+
);
83+
if (!index) printWarning(t("packs.warnNoIndex"));
84+
});
85+
});
86+
87+
packs
88+
.command("install <name>")
89+
.description(t("packs.installDescription"))
90+
.option("--source <dir>", t("packs.sourceOpt"))
91+
.action(async (name, opts) => {
92+
await run(async () => {
93+
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
94+
const { indexFile, index } = loadIndex(opts.source);
95+
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
96+
const dataDir = resolveDataDir();
97+
// The payload (tarball or extracted pack dir) lives next to the index
98+
// unless the caller pointed elsewhere via --source.
99+
await installPack(name, {
100+
dataDir,
101+
index,
102+
sourceDir: opts.source || path.dirname(indexFile),
103+
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
104+
});
105+
const installDir = path.join(packsRoot(dataDir), name);
106+
printSuccess(t("packs.installed", { name, dir: installDir }));
107+
printInfo(t("packs.restartHint"));
108+
emit({ pack: name, installed: "yes", verified: "ok", installDir }, opts, STATE_SCHEMA);
109+
});
110+
});
111+
112+
packs
113+
.command("verify [name]")
114+
.description(t("packs.verifyDescription"))
115+
.option("--source <dir>", t("packs.sourceOpt"))
116+
.action(async (name, opts) => {
117+
await run(async () => {
118+
if (name && !findPack(name))
119+
exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
120+
const { index } = loadIndex(opts.source);
121+
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
122+
const dataDir = resolveDataDir();
123+
const states = name
124+
? [await packState(name, { dataDir, index })]
125+
: await listPackStates({ dataDir, index });
126+
emit(
127+
states.map((s) => stateRow(s, dataDir)),
128+
opts,
129+
STATE_SCHEMA
130+
);
131+
const broken = states.filter((s) => s.installed && s.verified !== true);
132+
if (broken.length > 0) {
133+
for (const state of broken) {
134+
for (const error of state.errors ?? []) printError(`${state.name}: ${error}`);
135+
}
136+
exitWith(EXIT_CODES.ERROR, t("packs.verifyFailed", { count: broken.length }));
137+
}
138+
if (!states.some((s) => s.installed)) {
139+
printInfo(t("packs.noneInstalled"));
140+
return;
141+
}
142+
printSuccess(t("packs.verifyOk"));
143+
});
144+
});
145+
146+
packs
147+
.command("remove <name>")
148+
.description(t("packs.removeDescription"))
149+
.action(async (name, opts) => {
150+
await run(async () => {
151+
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
152+
const dataDir = resolveDataDir();
153+
const removed = removePack(name, {
154+
dataDir,
155+
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
156+
});
157+
if (removed) {
158+
printSuccess(t("packs.removed", { name }));
159+
printInfo(t("packs.restartHint"));
160+
} else {
161+
printInfo(t("packs.notInstalled", { name }));
162+
}
163+
emit({ pack: name, installed: removed ? "no" : "no" }, opts, STATE_SCHEMA);
164+
});
165+
});
166+
}

bin/cli/commands/registry.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ import { registerConfigure } from "./configure.mjs";
7979
import { registerApiCommands } from "../api-commands/registry.mjs";
8080
import { registerPlugin } from "./plugin.mjs";
8181
import { registerRadar } from "./radar.mjs";
82+
import { registerPacks } from "./packs.mjs";
8283

8384
export function registerCommands(program) {
8485
registerMemory(program);
@@ -163,4 +164,5 @@ export function registerCommands(program) {
163164
registerApiCommands(program);
164165
registerPlugin(program);
165166
registerRadar(program);
167+
registerPacks(program);
166168
}

bin/cli/locales/en.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1304,5 +1304,23 @@
13041304
},
13051305
"setupCodex": {
13061306
"description": "Generate ~/.codex profile files from OmniRoute live model catalog"
1307+
},
1308+
"packs": {
1309+
"description": "Manage optional runtime packs (ML / browser automation)",
1310+
"listDescription": "List optional packs and their install state",
1311+
"installDescription": "Install an optional pack into DATA_DIR",
1312+
"verifyDescription": "Verify installed packs against the shipped checksum index",
1313+
"removeDescription": "Remove an installed optional pack",
1314+
"sourceOpt": "Directory holding pack payloads and the pack index",
1315+
"warnNoIndex": "optional-packs.index.json not found — install/verify are unavailable in this checkout (desktop bundles ship it)",
1316+
"errUnknown": "unknown pack: {name}",
1317+
"errNoIndex": "pack index not found; pass --source <dir> holding the pack payload (desktop bundles ship it next to the app)",
1318+
"installed": "pack \"{name}\" installed and verified at {dir}",
1319+
"restartHint": "restart the OmniRoute server (or desktop app) so the runtime picks the pack up",
1320+
"removed": "pack \"{name}\" removed",
1321+
"notInstalled": "pack \"{name}\" was not installed",
1322+
"verifyOk": "all installed packs verified",
1323+
"verifyFailed": "{count} pack(s) failed verification",
1324+
"noneInstalled": "no optional packs installed"
13071325
}
13081326
}

bin/cli/locales/pt-BR.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,5 +1301,23 @@
13011301
},
13021302
"setupCodex": {
13031303
"description": "Gera os arquivos de perfil ~/.codex a partir do catálogo de modelos ao vivo do OmniRoute"
1304+
},
1305+
"packs": {
1306+
"description": "Gerencia packs opcionais de runtime (ML / automação de navegador)",
1307+
"listDescription": "Lista os packs opcionais e seu estado de instalação",
1308+
"installDescription": "Instala um pack opcional no DATA_DIR",
1309+
"verifyDescription": "Verifica os packs instalados contra o índice de checksums embarcado",
1310+
"removeDescription": "Remove um pack opcional instalado",
1311+
"sourceOpt": "Diretório com os payloads dos packs e o índice de packs",
1312+
"warnNoIndex": "optional-packs.index.json não encontrado — install/verify indisponíveis neste checkout (instaladores desktop o embarcam)",
1313+
"errUnknown": "pack desconhecido: {name}",
1314+
"errNoIndex": "índice de packs não encontrado; passe --source <dir> com o payload do pack (instaladores desktop o embarcam ao lado do app)",
1315+
"installed": "pack \"{name}\" instalado e verificado em {dir}",
1316+
"restartHint": "reinicie o servidor OmniRoute (ou o app desktop) para o runtime reconhecer o pack",
1317+
"removed": "pack \"{name}\" removido",
1318+
"notInstalled": "o pack \"{name}\" não estava instalado",
1319+
"verifyOk": "todos os packs instalados verificados",
1320+
"verifyFailed": "{count} pack(s) falharam na verificação",
1321+
"noneInstalled": "nenhum pack opcional instalado"
13041322
}
13051323
}

docs/reference/ENVIRONMENT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1521,6 +1521,7 @@ These settings were introduced after the previous environment-contract snapshot.
15211521
| `TELEGRAM_DEFAULT_MODEL` | `auto/chat` | `src/lib/telegram/chatProxy.ts` | Model used for Telegram chat replies. |
15221522
| `TELEGRAM_BOT_API_BASE` | `https://api.telegram.org` | `src/lib/telegram/config.ts` | Bot API base URL override for proxies or self-hosted Bot API servers. |
15231523
| `TELEGRAM_WEBHOOK_TIMEOUT_MS` | `60000` | `src/lib/telegram/config.ts` | Timeout in milliseconds for outbound Bot API calls. |
1524+
| `OMNIROUTE_OPTIONAL_PACK_TAR` | `1` (enabled) | `scripts/build/optionalPackStaging.mjs` | Set `0` to skip emitting `.tar.gz` tarballs while staging optional ML/browser packs for the Electron standalone tree (pack directories and `optional-packs.index.json` are still produced). Used by the desktop release workflow to trim artifact upload size. |
15241525
### ChatGPT Web (Codex)
15251526

15261527
Globale Defaults für den headless Browser und den ausgehenden Tool-Tunnel. Im Dashboard gesetzte Connection-Werte haben Vorrang.

electron/main.js

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,32 @@ function resolveNodeExecutable(env = process.env) {
114114
return process.execPath;
115115
}
116116

117-
function resolveServerNodePath(env = process.env) {
117+
// Stage 7 (issue #10321): optional runtime packs are installed under
118+
// `${DATA_DIR}/packs/<name>/node_modules` (see open-sse/utils/optionalPacks.ts —
119+
// this is the plain-JS mirror; keep semantics identical). Prepending their
120+
// node_modules to NODE_PATH lets the server's dynamic imports (playwright, the
121+
// LLMLingua closure) resolve pack members while the default bundle stays slim.
122+
function resolvePackNodePaths(dataDir) {
123+
const packsRoot = path.join(dataDir, "packs");
124+
let names;
125+
try {
126+
names = fs.readdirSync(packsRoot);
127+
} catch {
128+
return []; // No packs dir yet — nothing installed.
129+
}
130+
const dirs = [];
131+
for (const name of names) {
132+
const candidate = path.join(packsRoot, name, "node_modules");
133+
try {
134+
if (fs.statSync(candidate).isDirectory()) dirs.push(candidate);
135+
} catch {
136+
// Unreadable entry — treat as not installed.
137+
}
138+
}
139+
return dirs;
140+
}
141+
142+
function resolveServerNodePath(env = process.env, extraDirs = []) {
118143
const seen = new Set();
119144
const entries = [];
120145

@@ -136,6 +161,12 @@ function resolveServerNodePath(env = process.env) {
136161
addEntry(existing);
137162
}
138163

164+
// Optional packs take precedence over bundle-resident copies so an installed
165+
// pack can never be shadowed by a stale bundled duplicate.
166+
for (const packDir of extraDirs) {
167+
addEntry(packDir);
168+
}
169+
139170
// Electron-builder installs native modules like better-sqlite3 under
140171
// app.asar.unpacked, while the standalone bundle still carries helper deps
141172
// such as bindings/file-uri-to-path inside resources/app/node_modules.
@@ -752,7 +783,7 @@ function startNextServer() {
752783
PORT: String(serverPort),
753784
NODE_ENV: "production",
754785
ELECTRON_RUN_AS_NODE: "1",
755-
NODE_PATH: resolveServerNodePath(serverEnv),
786+
NODE_PATH: resolveServerNodePath(serverEnv, resolvePackNodePaths(dataDir)),
756787
NODE_OPTIONS: serverNodeOptions,
757788
},
758789
stdio: "pipe",

open-sse/services/compression/engines/llmlingua/worker.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { pathToFileURL } from "node:url";
3737

3838
import { LLMLINGUA_WORKER_TIMEOUT_MS, LLMLINGUA_WORKER_IDLE_MS } from "./constants.ts";
3939
import { resolveLlmlinguaModel } from "./modelStore.ts";
40+
import { packMemberInstalled } from "../../../../utils/optionalPacks.ts";
4041
import type { LlmlinguaBackend } from "./index.ts";
4142

4243
/** One-time model-load budget on the first call for a given model (tinybert ~2s, bert-base ~27s). */
@@ -121,7 +122,12 @@ let _depsAvailable: boolean | null = null;
121122
*/
122123
export function depsAvailable(): boolean {
123124
if (_depsAvailable !== null) return _depsAvailable;
124-
_depsAvailable = firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null;
125+
// Stage 7 (issue #10321): the desktop bundle ships the LLMLingua closure as an
126+
// optional pack installed under `${DATA_DIR}/packs/ml-runtime/node_modules`
127+
// (prepended to NODE_PATH by electron/main.js), so also probe the pack dirs —
128+
// the ancestor walk only covers bundle-resident installs (npm/Docker).
129+
_depsAvailable =
130+
firstAncestorWith(runtimeAnchors(), GATE_DEP_REL) !== null || packMemberInstalled(GATE_DEP_REL);
125131
return _depsAvailable;
126132
}
127133

0 commit comments

Comments
 (0)