Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/desktop-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,13 @@ jobs:
# mcp[cli] is a build-env requirement, not a runtime dep: --collect-all
# mcp imports every mcp submodule, and mcp.cli hard-errors at import
# when typer is missing (broke all legs once an unpinned mcp drifted).
# requirements-docs.txt is the ON-by-default document-generation stack
# (ADR 0092): installing it here makes build_sidecar's DOC_COLLECT_ALL
# bundle docx/xlsx/pptx/pdf so cowork's skills work on the frozen runtime.
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt pyinstaller "mcp[cli]"
pip install -r apps/desktop/sidecar/requirements-docs.txt
python apps/desktop/sidecar/build_sidecar.py
ls -la apps/desktop/src-tauri/binaries/

Expand Down
54 changes: 48 additions & 6 deletions apps/desktop/sidecar/build_sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,33 @@
"googleapiclient",
]

# Document-generation stack (ADR 0092) — the knowledge-work baseline the desktop
# app ships ON by default so first-party skill packs (cowork's docx/xlsx/pptx/pdf
# skills) and plugins like protobanana (Pillow) actually produce files on the
# frozen runtime instead of hitting the ADR 0058 D2 "install on a server/Docker"
# refusal. Installed in CI from ``requirements-docs.txt``; a lean local freeze
# without it still works (find_spec-guarded, like the Google tier). ``--collect-all``
# is REQUIRED here — reportlab ships font/AFM data, python-docx/pptx ship their
# default .docx/.pptx templates as package data, and a bare import-scan misses all
# of it (the library raises at runtime opening its default template).
#
# Each entry is (import_name, distribution_name) — they DIFFER for the ones that matter
# (python-docx→``docx``, python-pptx→``pptx``, Pillow→``PIL``), and that gap is load-bearing:
# the ADR 0058 D2 gate checks a plugin's ``requires_pip`` with installer._importable, which
# tries ``importlib.metadata.version(<dist>)`` FIRST. So we ``--copy-metadata <dist>`` alongside
# ``--collect-all <import>`` — otherwise cowork's ``requires_pip: [python-docx, …]`` could still
# be refused on the frozen app even though ``import docx`` works, because the dist-info wasn't
# bundled. (lxml + PIL also arrive transitively, but naming them keeps a runtime-installed
# plugin's lazy ``import PIL`` — protobanana — importable when nothing else pulled it.)
DOC_COLLECT_ALL = [
("docx", "python-docx"),
("openpyxl", "openpyxl"),
("pptx", "python-pptx"),
("reportlab", "reportlab"),
("lxml", "lxml"),
("PIL", "pillow"),
]

# tkinter is GUI dead weight in a headless server and a freeze hazard — exclude it.
# (Gradio + the chat_ui module it backed were removed from the project entirely.)
EXCLUDE = ["tkinter"]
Expand Down Expand Up @@ -168,15 +195,30 @@ def main() -> None:
collect: list[str] = []
for pkg in COLLECT_ALL:
collect += ["--collect-all", pkg]
# Optional Google libs: collect only what's importable in this build env.
# Optional tiers: collect only what's importable in this build env, so a lean
# freeze (extra not installed) still succeeds instead of erroring on a --collect
# of a missing package.
import importlib.util

for pkg in OPTIONAL_COLLECT_ALL:
if importlib.util.find_spec(pkg) is not None:
collect += ["--collect-all", pkg]
def _collect_if_present(pkgs: list[str], hint: str) -> None:
for pkg in pkgs:
if importlib.util.find_spec(pkg) is not None:
collect.extend(["--collect-all", pkg])
else:
print(f"note: optional package not installed, skipping: {pkg} ({hint})",
file=sys.stderr)

_collect_if_present(OPTIONAL_COLLECT_ALL, "install requirements-google.txt to ship Gmail/Calendar")
# Doc stack: --collect-all the module AND --copy-metadata the distribution, so the ADR 0058
# D2 gate's metadata-first check (installer._importable → metadata.version(<dist>)) resolves
# in the frozen app despite the dist≠import name gap. Guarded on the import name being present
# (a lean local freeze without requirements-docs.txt just skips the tier).
for imp, dist in DOC_COLLECT_ALL:
if importlib.util.find_spec(imp) is not None:
collect.extend(["--collect-all", imp, "--copy-metadata", dist])
else:
print(f"note: optional package not installed, skipping: {pkg} "
"(install requirements-google.txt to ship Gmail/Calendar)",
print(f"note: optional package not installed, skipping: {imp} "
"(install requirements-docs.txt to ship the document-generation stack)",
file=sys.stderr)
exclude: list[str] = []
for mod in EXCLUDE:
Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/sidecar/requirements-docs.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Document-generation stack (ADR 0092) — bundled INTO the frozen desktop app so
# the knowledge-work surface works out of the box: cowork's docx/xlsx/pptx/pdf
# skills, careercoach's PDF packets, and any plugin that authors Office files.
#
# Why a committed extra (unlike the uncommitted requirements-google.txt): this
# stack is ON by default in the shipped desktop build. CI installs it in the
# sidecar-build step; build_sidecar.py's DOC_COLLECT_ALL then --collect-all's it
# (find_spec-guarded, so a lean local freeze without this file still succeeds).
#
# Effect on the ADR 0058 D2 frozen gate: once these are importable in the bundle,
# a plugin's `requires_pip` for them is simply satisfied — cowork installs and
# WORKS on desktop with no manifest change, and protobanana's `pillow>=10`
# refusal disappears (Pillow arrives transitively via python-pptx/reportlab).
#
# lxml, Pillow, et-xmlfile, XlsxWriter come transitively — not pinned here.
python-docx>=1.1 # .docx authoring (imports as `docx`; ships default.docx template data)
openpyxl>=3.1 # .xlsx authoring
python-pptx>=0.6.23 # .pptx authoring (imports as `pptx`; pulls lxml + Pillow)
reportlab>=4.0 # PDF authoring (compiled _rl_accel + bundled font/AFM data)
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# 0092 — Desktop document-generation baseline + versioned file artifacts

Status: **Accepted** (phased — D1 ships with this ADR; D2/D3 follow in-track)

## Context

[ADR 0058](./0058-runtime-plugin-install-frozen-app.md) D2 gates plugin installs
in the frozen desktop sidecar: a plugin whose `requires_pip` entries aren't already
importable in the read-only PyInstaller bundle is refused at install time
(`graph/plugins/installer.py`) with *"install it on a server/Docker build instead."*
[#1953](https://github.com/protoLabsAI/protoAgent/issues/1953) / PR #1954 added an
**optional** tier (missing soft deps warn-and-continue), but that only degrades
gracefully — the feature still doesn't run on desktop.

Two real hits keep landing on this gate:

- **cowork** (the knowledge-work skill pack) declares `python-docx`, `openpyxl`,
`python-pptx`, `reportlab` as **hard** `requires_pip`. On the desktop runtime the
whole plugin is refused — so its docx/xlsx/pptx/pdf skills can't produce a file.
- **protobanana** is refused over `pillow>=10` (a soft dep it imports lazily).

The demographic protoAgent is courting here — the "own your stack" crowd migrating
off Claude Cowork / OpenAI Codex to a **local-first** agent they control — expects
that agent to actually produce documents. *"The desktop app can't make a .docx"* is
a credibility gap for that positioning, not a plugin edge case. cowork is a
**first-party flagship** pack, not an arbitrary third-party plugin, so it warrants a
platform answer rather than a per-plugin workaround.

There is a second, independent gap. The **artifact plugin** ([ADR 0038](./0038-generative-ui-artifacts-two-mode.md))
versions *renderable text source* — a version is a `code` string (HTML / Markdown /
SVG / React), diffed as text (`update_artifact` is an `old_string → new_string`
edit, capped at `max_code_kb`). It has **no bytes / mime / blob path**. So even once
the doc stack is bundled and cowork writes a real `.docx`, that file is a loose
binary in a scoped work folder with **no version history** — the artifact plugin
never sees it. For knowledge work, *the versioning has to hold for the files too.*

## Decision

### D1 — The desktop bundle baseline includes the document-generation stack *(ships with this ADR)*

Bake `python-docx`, `openpyxl`, `python-pptx`, `reportlab` (with `Pillow` + `lxml`
arriving transitively) into the frozen desktop app, **on by default**:

- A committed `apps/desktop/sidecar/requirements-docs.txt`, installed in the
desktop-build CI sidecar step.
- A `DOC_COLLECT_ALL` list in `build_sidecar.py`, `find_spec`-guarded exactly like
the existing Google `OPTIONAL_COLLECT_ALL` tier, so a lean local freeze without the
extra still succeeds. `--collect-all` (not bare hidden-imports) is **required**:
reportlab ships font/AFM data, python-docx/pptx ship their default `.docx`/`.pptx`
templates as package data, and an import-scan misses all of it (the library raises
at runtime opening its default template).

This does **not** contradict ADR 0058's rejection of shipping `git`+`pip` — it bakes
libraries at **build time**, which is precisely 0058's sanctioned mechanism (the same
way the `plugins/` tree and Google libs are bundled). It **expands the baseline**, it
does not add a runtime installer.

**Effect:** once these are importable in the bundle, `_deps_satisfied` short-circuits
and cowork's *hard* `requires_pip` is simply satisfied — cowork **installs and works
on desktop with zero manifest change**. protobanana's `pillow>=10` refusal also
disappears (Pillow is now bundled).

### D2 — A "download artifact" tier in the artifact plugin *(follow-up PR)*

Extend the artifact plugin so a version can carry **bytes + mime + a derived text
preview** in addition to (or instead of) the text `code`:

- Store the blob per version (for download) **and** extract a readable projection
(`docx`→text, `xlsx`→sheet table, `pptx`→outline) so the panel renders an inline
preview *and* the version history stays **diffable** — not an opaque blob.
- Render a **download card** for the binary rather than iframing it; existing text
artifacts are untouched (purely additive `kind`).
- New config: max blob size + blob retention, alongside the existing `max_versions`.

### D3 — An explicit `save_file_artifact(path, title)` tool *(follow-up PR)*

The seam by which a generated file becomes a versioned artifact is an **explicit
tool** the skill calls after writing — deterministic, testable, agent-controlled, and
it works when there's no HTML source (cowork authors `.docx` directly via
python-docx, it doesn't render from an artifact). cowork's doc skills call it in
`~/dev/cowork-plugin` so their outputs land as versioned download-artifacts.
(Auto-watching a work folder was considered and deferred: it versions scratch files
too and needs watcher/debounce/dedup infra for no gain over the explicit call.)

## Consequences

- **Bundle size** grows ~15–25 MB (lxml, Pillow, reportlab binaries + fonts) on a
desktop app that already ships a Python and a Node runtime — acceptable for the
capability, and the driver is *what the demographic needs to work out of the box*.
- **Maintenance:** the doc stack becomes part of the maintained desktop baseline
(security bumps, per-platform wheel availability — all four ship mac arm64/x86_64 +
Windows wheels).
- **Verification:** the frozen sidecar smoke step (`scripts/live_smoke.py --bin`)
should assert both that `import docx, openpyxl, pptx, reportlab` works **and** that
`importlib.metadata.version("python-docx")` resolves in the *actual* frozen binary —
the latter is what the D2 gate (`installer._importable`) checks first, and the dist≠import
name gap (`python-docx`→`docx`) is why the build `--copy-metadata`'s each distribution
alongside `--collect-all`. CI can only simulate the frozen path via `PROTOAGENT_PLUGIN_FROZEN=1`.
- **Scope boundary:** this is the *first-party flagship* answer. The **general**
third-party dep story — installing *any* plugin's unbundled deps at runtime on the
frozen app — stays the forthcoming **ADR 0093** (the pip-less wheel installer,
[#1631](https://github.com/protoLabsAI/protoAgent/issues/1631) Scope A). 0092 and
0093 are complementary: **bake** the flagship stack (0092), **install** arbitrary
plugin deps at runtime (0093).

## References

- [ADR 0058](./0058-runtime-plugin-install-frozen-app.md) — the frozen D2 dep gate this expands
- [ADR 0038](./0038-generative-ui-artifacts-two-mode.md) — the artifact/generative-UI surface D2 extends
- ADR 0093 (forthcoming) — the complementary general pip path ([#1631](https://github.com/protoLabsAI/protoAgent/issues/1631) Scope A)
- [#1631](https://github.com/protoLabsAI/protoAgent/issues/1631), [#1953](https://github.com/protoLabsAI/protoAgent/issues/1953) / PR #1954 (optional tier)
- `apps/desktop/sidecar/build_sidecar.py`, `apps/desktop/sidecar/requirements-docs.txt`, `plugins/artifact/__init__.py`
- cowork-plugin, protobanana-plugin (motivating first-party / third-party cases)
4 changes: 4 additions & 0 deletions plugins/docs/nav.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,10 @@
"path": "adr/0091-agent-snapshot-portability.md",
"title": "ADR 0091 — Agent snapshot portability: a declarative, secret-free bundle (not a raw state dump)"
},
{
"path": "adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md",
"title": "0092 — Desktop document-generation baseline + versioned file artifacts"
},
{
"path": "adr/index.md",
"title": "Architecture Decision Records"
Expand Down
Loading