From ff28641f847a3eff678bf23c2ae06fe787c45652 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Tue, 21 Jul 2026 21:51:45 -0700 Subject: [PATCH 1/2] feat(desktop): bundle the document-generation stack into the frozen app (ADR 0092 D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ADR 0058 D2 frozen gate refuses any plugin whose `requires_pip` isn't importable in the read-only PyInstaller bundle — so cowork's docx/xlsx/pptx/pdf skills (hard deps: python-docx, openpyxl, python-pptx, reportlab) can't produce a file on desktop, and protobanana is refused over `pillow>=10`. For the "own your stack" demographic, "the desktop app can't make a .docx" is a credibility gap, not an edge case. Bake the document-generation stack into the desktop bundle, ON by default: - `apps/desktop/sidecar/requirements-docs.txt` (committed) — the 4 doc libs; installed in the desktop-build CI sidecar step. - `DOC_COLLECT_ALL` in build_sidecar.py — find_spec-guarded like the Google tier, so a lean local freeze without the extra still succeeds. `--collect-all` is required (reportlab fonts + docx/pptx default templates are package data a bare import-scan misses). Effect: once the libs are importable in the bundle, `_deps_satisfied` short-circuits and cowork's hard `requires_pip` is satisfied — cowork installs and WORKS on desktop with zero manifest change; protobanana's pillow refusal disappears (Pillow arrives transitively). This expands 0058's bundle baseline; it does NOT ship pip — libs are baked at build time, 0058's sanctioned mechanism. The general third-party dep path (pip-less wheel installer, #1631 Scope A) stays forthcoming ADR 0093. ADR 0092 also specs the follow-up in-track work: a binary "download artifact" tier in the artifact plugin (D2) and an explicit `save_file_artifact` tool (D3) so generated files become versioned artifacts. Refs #1631, #1953 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EVmWoy2TdXdMshNn5WBR2Z --- .github/workflows/desktop-build.yml | 4 + apps/desktop/sidecar/build_sidecar.py | 42 +++++-- apps/desktop/sidecar/requirements-docs.txt | 19 +++ ...t-baseline-and-versioned-file-artifacts.md | 110 ++++++++++++++++++ plugins/docs/nav.json | 4 + 5 files changed, 171 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/sidecar/requirements-docs.txt create mode 100644 docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index ac9ff18d..c34b91e3 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -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/ diff --git a/apps/desktop/sidecar/build_sidecar.py b/apps/desktop/sidecar/build_sidecar.py index 5efc530c..a210270a 100644 --- a/apps/desktop/sidecar/build_sidecar.py +++ b/apps/desktop/sidecar/build_sidecar.py @@ -139,6 +139,27 @@ "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). Import names, +# not pip names: python-docx→``docx``, python-pptx→``pptx``, Pillow→``PIL``. lxml + +# PIL also arrive transitively, but naming them explicitly keeps a runtime-installed +# plugin's lazy ``import PIL`` (protobanana) importable when nothing else pulled it. +DOC_COLLECT_ALL = [ + "docx", + "openpyxl", + "pptx", + "reportlab", + "lxml", + "PIL", +] + # 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"] @@ -168,16 +189,21 @@ 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] - else: - print(f"note: optional package not installed, skipping: {pkg} " - "(install requirements-google.txt to ship Gmail/Calendar)", - file=sys.stderr) + 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") + _collect_if_present(DOC_COLLECT_ALL, "install requirements-docs.txt to ship the document-generation stack") exclude: list[str] = [] for mod in EXCLUDE: exclude += ["--exclude-module", mod] diff --git a/apps/desktop/sidecar/requirements-docs.txt b/apps/desktop/sidecar/requirements-docs.txt new file mode 100644 index 00000000..6f278f53 --- /dev/null +++ b/apps/desktop/sidecar/requirements-docs.txt @@ -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) diff --git a/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md b/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md new file mode 100644 index 00000000..5c3fc230 --- /dev/null +++ b/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md @@ -0,0 +1,110 @@ +# 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 `import docx, openpyxl, pptx, reportlab` succeeds in the *actual* + frozen binary — 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) diff --git a/plugins/docs/nav.json b/plugins/docs/nav.json index c4cd6c77..1a18cd0d 100644 --- a/plugins/docs/nav.json +++ b/plugins/docs/nav.json @@ -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" From 0c599ddf1b24d4c55459089bd75a71b941197f31 Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Tue, 21 Jul 2026 23:54:04 -0700 Subject: [PATCH 2/2] fix(desktop): --copy-metadata the doc distributions so the D2 gate resolves them (protoreview #2123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit protoreview flagged an uncertain-but-load-bearing gap: the ADR 0058 D2 gate checks a plugin's requires_pip via installer._importable, which tries importlib.metadata.version() FIRST. With only --collect-all , whether the python_docx-*.dist-info metadata lands in the frozen bundle is PyInstaller-version dependent — so cowork's `requires_pip: [python-docx, …]` could still be refused on the desktop app even though `import docx` works. Make it deterministic: DOC_COLLECT_ALL now carries (import_name, distribution_name), and the build emits --copy-metadata alongside --collect-all for each. So metadata.version("python-docx") resolves in the frozen app regardless of PyInstaller's collect-all metadata behavior — the gate short-circuits and cowork installs. The dist≠import gap (python-docx→docx, python-pptx→pptx, Pillow→PIL) is exactly the case this closes. ADR 0092 verification note updated to name the metadata gate (not just import). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EVmWoy2TdXdMshNn5WBR2Z --- apps/desktop/sidecar/build_sidecar.py | 38 +++++++++++++------ ...t-baseline-and-versioned-file-artifacts.md | 7 +++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/apps/desktop/sidecar/build_sidecar.py b/apps/desktop/sidecar/build_sidecar.py index a210270a..e790e107 100644 --- a/apps/desktop/sidecar/build_sidecar.py +++ b/apps/desktop/sidecar/build_sidecar.py @@ -147,17 +147,23 @@ # 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). Import names, -# not pip names: python-docx→``docx``, python-pptx→``pptx``, Pillow→``PIL``. lxml + -# PIL also arrive transitively, but naming them explicitly keeps a runtime-installed -# plugin's lazy ``import PIL`` (protobanana) importable when nothing else pulled it. +# 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()`` FIRST. So we ``--copy-metadata `` alongside +# ``--collect-all `` — 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", - "openpyxl", - "pptx", - "reportlab", - "lxml", - "PIL", + ("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. @@ -203,7 +209,17 @@ def _collect_if_present(pkgs: list[str], hint: str) -> None: file=sys.stderr) _collect_if_present(OPTIONAL_COLLECT_ALL, "install requirements-google.txt to ship Gmail/Calendar") - _collect_if_present(DOC_COLLECT_ALL, "install requirements-docs.txt to ship the document-generation stack") + # 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()) 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: {imp} " + "(install requirements-docs.txt to ship the document-generation stack)", + file=sys.stderr) exclude: list[str] = [] for mod in EXCLUDE: exclude += ["--exclude-module", mod] diff --git a/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md b/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md index 5c3fc230..2a735a37 100644 --- a/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md +++ b/docs/adr/0092-desktop-document-baseline-and-versioned-file-artifacts.md @@ -91,8 +91,11 @@ too and needs watcher/debounce/dedup infra for no gain over the explicit call.) (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 `import docx, openpyxl, pptx, reportlab` succeeds in the *actual* - frozen binary — CI can only simulate the frozen path via `PROTOAGENT_PLUGIN_FROZEN=1`. + 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,