Files
openworker/packaging/openworker-server.spec
Rohit C Prasad 5f3ffe385d packaging: ship builtin persona bundles in the sidecar
collect_submodules only takes .py, so packaged builds had no builtin coworkers.
Caught by inspecting the DMG — dev installs read them from the source tree.
2026-08-13 15:46:13 -07:00

136 lines
5.4 KiB
RPMSpec

# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec for the bundled `openworker-server` (desktop sidecar).
One-DIR bundle (exe + `_internal/` support folder) shipped via Tauri's `resources` slot.
It used to be a onefile binary in the externalBin slot, but onefile self-extracts its whole
archive to a temp dir on EVERY launch 6-7s of "Starting coworker…" splash (measured; the
actual Python import is ~0.5s). The wrinkles handled here:
- aisuite is a regular pip dependency (git-pinned in pyproject.toml); collect coworker +
aisuite submodules from the venv.
- uvicorn loads its protocol/lifespan impls dynamically collect_all.
- certifi's CA bundle must ship for TLS (OpenAI, web search, Telegram/Slack).
- messaging extras (slack_bolt, telegram) are optional; collected if importable.
Cross-platform: paths are derived from this spec's own location (SPECPATH), never hardcoded,
so the same spec builds native binaries on macOS, Windows, and Linux. On Windows PyInstaller
appends `.exe` to `name`. The binary is built as a normal console app on every OS a windowed
(console=False) build leaves sys.stdout/stderr as None, which breaks uvicorn's startup logging
and hangs the server. To avoid a console window flashing in the desktop app, the Tauri shell
spawns this sidecar with the Windows CREATE_NO_WINDOW flag (see src-tauri/src/lib.rs), which
hides the window while keeping stdio intact.
"""
import os
import sys
from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules
# SPECPATH is injected by PyInstaller and points at this file's directory
# (<repo>/packaging). Derive everything else from it — no hardcoded paths.
PACKAGING = SPECPATH
ROOT = os.path.dirname(PACKAGING)
IS_WINDOWS = sys.platform == "win32"
# Experimental (use-at-your-own-risk) connectors are excluded from official builds: the code
# is stripped, not just disabled. Self-builders opt in with COWORKER_EXPERIMENTAL=1; the
# loader in coworker/connectors/descriptors.py treats the missing package as a no-op.
INCLUDE_EXPERIMENTAL = os.environ.get("COWORKER_EXPERIMENTAL") == "1"
hiddenimports = []
datas = []
binaries = []
for pkg in ("coworker", "aisuite", "mcp", "ddgs", "croniter", "docstring_parser"):
hiddenimports += collect_submodules(pkg)
# Builtin personas ship as DATA, not code: personas/builtin/<id>/manifest.md plus their
# skills/<name>/SKILL.md. collect_submodules only takes .py files, so without this the
# packaged sidecar starts with NO builtin coworkers — the picker comes up empty and every
# persona-scoped skill silently disappears. (pyproject's package-data covers pip installs;
# PyInstaller needs its own instruction.) Keep this even if the persona set changes — it
# collects whatever non-.py files the package carries.
datas += collect_data_files("coworker")
if not INCLUDE_EXPERIMENTAL:
hiddenimports = [
m for m in hiddenimports if not m.startswith("coworker.connectors.experimental")
]
# `websockets` powers the managed Slack relay client (relay_client.py). It is
# lazy-imported inside a function, so PyInstaller's static analysis misses it —
# collect it explicitly or the packaged relay adapter fails to open its socket.
# `pypdf`/`pypdfium2` are lazy-imported the same way (pdf_support.py) — and pypdfium2
# carries the libpdfium binary, which collect_all is what actually stages.
for pkg in ("uvicorn", "certifi", "anyio", "websockets", "pypdf", "pypdfium2"):
d, b, h = collect_all(pkg)
datas += d
binaries += b
hiddenimports += h
# Windows has no system tz database; tzdata ships the zoneinfo files the scheduler needs.
if IS_WINDOWS:
try:
d, b, h = collect_all("tzdata")
datas += d
binaries += b
hiddenimports += h
except Exception:
pass
# [bedrock] extra — boto3 is lazy-imported (bedrock_provider.py) so static analysis
# misses it, and botocore's service-model JSON data dir only ships via collect_all.
for pkg in ("boto3", "botocore"):
try:
d, b, h = collect_all(pkg)
datas += d
binaries += b
hiddenimports += h
except Exception:
pass
for pkg in ("slack_bolt", "telegram"): # [messaging] extra — optional
try:
hiddenimports += collect_submodules(pkg)
except Exception:
pass
a = Analysis(
[os.path.join(PACKAGING, "server_entry.py")],
pathex=[ROOT],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
runtime_hooks=[],
excludes=["tkinter", "matplotlib", "PIL", "PyQt5", "PySide6"]
+ ([] if INCLUDE_EXPERIMENTAL else ["coworker.connectors.experimental"]),
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="openworker-server",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
# Console on every OS: a windowed build nulls stdout/stderr and hangs uvicorn. The Tauri
# shell hides the window on Windows via CREATE_NO_WINDOW when spawning the sidecar.
console=True,
# target_arch left unset → PyInstaller builds for the host architecture.
)
# Onedir: dist/openworker-server/{openworker-server[.exe], _internal/}. The build scripts stage
# this whole folder into src-tauri/binaries/sidecar/ for Tauri's `resources` bundling.
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="openworker-server",
)