diff --git a/PLUGIN.md b/PLUGIN.md new file mode 100644 index 0000000..7bc21ba --- /dev/null +++ b/PLUGIN.md @@ -0,0 +1,169 @@ +# Atelier — extracted from a standalone service into a plugin + +**Status: extracted 2026-08-20, unattended overnight run.** Read `BLOCKERS.md` alongside this file — +it has the decisions made without being able to ask, in the order they were found. This file is what +moved, what didn't, and why; that one is what was uncertain. + +Atelier is image generation as a service: a provider-shaped API over OpenAI's image endpoints, storing +what it makes so every image has a durable reference. It came from +`~/officer-dev/dockers/officer-atelier`, a standalone Bun/Hono/Postgres server predating the plugin +system, still running in production as `officer-atelier` (PM2, port 3002, `atelier.officer.dev`) — see +"What did not move" below before assuming it is safe to touch. + +--- + +## What moved, and what changed on the way + +**Nothing pointed the other direction.** Unlike offscale and music, a grep of the whole platform for +`atelier` found no reverse dependency — no widget, no relay, nothing importing from the standalone +service. It came out whole with no seam to design around, which is the exception rather than the rule +for this kind of extraction. + +### The backend — `sidecar/` + +Providers (adapter-shaped: `types.ts`, `openai.ts`, an `index.ts` that dispatches — OpenAI is the only +adapter today, and the point of the shape is that a second one is a new file, not a new screen), the +generation queue (Postgres-backed, `MAX_CONCURRENT_GENERATIONS` workers), collections, and the database +layer moved essentially unchanged. Three things did not: + +1. **Images are served from `GET /files/:filename`, authenticated, not an unauthenticated `PUBLIC_URL` + route.** The standalone service served `/images/*` off Bun's own route table with no session, + because every image needed "a permanent URL" reachable by the mobile app and anything else on the + tailnet. Inside Officer that is not an option — see "What you may NOT do" in the kickoff prompt. The + platform's own `?token=` escape hatch (already used by `FileViewer/file-types.ts` and the music + plugin's `MusicPlayerHost` for exactly this — a browser cannot put an `Authorization` header on an + ``) covers it: the JSON API returns `/files/` (relative, no host, no token — the + sidecar cannot know the platform's public origin any more and does not need to), and the frontend + appends its own token when rendering (`web/lib/api.ts`'s `fileUrl`). + +2. **The OpenAI-key bootstrap fallback reads the secret store before `.env`.** `keyForProvider` still + prefers a provider row's own encrypted key; the fallback that used to be `process.env.OPENAI_API_KEY` + directly is now `getSecret('atelier--key')` first, `.env` second — `getSecret`, never + `getKey`, because a foreign credential must not be silently substituted with a random one. Set it + with `bun run secret:set atelier-openai-key`. + +3. **`provider_settings` did not come along.** It is the one-row-per-adapter shape `providers` replaced + (see the standalone repo's own `providers/schema.ts` comment and `scripts/migrate-providers.ts`), and + grepping the server code found no caller anywhere except that migration script — which also did not + move. Its table and its one row stay in the `atelier` database, untouched. If a machine's migration + never ran, that is a real gap, but it is the standalone service's gap, not one this extraction + introduced. + +`ping`/`echo` (smoke-test routes, no caller) did not move either — the sidecar's `/_health` covers +liveness, matching every other HTTP sidecar in this codebase. + +### The database — stays separate, does not fold into officer_db + +Decision 3 said to check whether `collections` and `provider_settings` were empty before folding them +into `officer_db`. Both had rows (2 and 1), so the `atelier` Postgres database stays separate — the +sidecar keeps its own connection (`sidecar/db/db.ts`), reading `ATELIER_POSTGRES_URL` from the platform +`.env`. **This is new territory for the plugin system**: `db/schema.ts` at a plugin's root is the +`officer_db`-prefixed-tables convention (`pushPluginSchema`, `_` prefix), and this plugin has no +such file — `discoverPlugins()` correctly reports `schema: null` for it, and the install flow's schema +step is simply skipped. Nothing in `src/servers/plugins/` needed to change for this, because "no +`db/schema.ts`" is already a valid state the discovery contract handles — a plugin with an internal, +self-managed database is indistinguishable from a plugin with no tables at all, as far as the platform +is concerned. Worth knowing before assuming every plugin's data lives in `officer_db`. + +**Not set automatically.** `ATELIER_POSTGRES_URL` has no default — see `BLOCKERS.md` for why (mainly: +pointing it at the live production `atelier` database while that database's own standalone service is +still running risks two processes racing the same generation queue). The owner has to add it before +installing. + +**Migrating into `officer_db` as `atelier_*`-prefixed tables is real follow-up work**, not done here — +five tables (`generations`, `collections`, `collection_items`, `providers`, and the dead +`provider_settings`, which should be dropped rather than migrated), real data, needs a human watching it. + +### The frontend — three panels, plus a settings screen + +`web/panels.ts` + `web/layout.ts`: Generate, Gallery (+ Lightbox), Collections — the ≈1,200 lines of +real work the brief identified, ported with the shadcn components Officer already ships +(`@/components/ui/*` — the standalone service's own `src/apps/frontend/components/ui/*` did not move, +per decision 1). Providers did not become a fourth panel: + +**Providers is a platform Settings screen** (`Settings → Integrations → Atelier`, +`src/apps/officer-web/.../IntegrationsSettings/AtelierProviders.tsx`), per decision 2. Worth flagging +loudly: **`IntegrationsSettings/index.tsx` itself carries a comment, written five days earlier when email +was extracted, that says the opposite** — "a plugin contributes panels, not settings sections, so it +now lives inside the email workspace itself." Decision 2 was explicit and pre-made, so it was followed +anyway, and the contradiction is recorded in `BLOCKERS.md` rather than silently resolved either way. +`AtelierProviders.tsx` calls `/api/atelier/providers` over plain HTTP via `useClient()` — it imports +nothing from `plugins/atelier/`, so nothing here reaches back across the plugin boundary; it is exactly +the same relationship any authenticated frontend has with any authenticated backend. + +Panel coordination is URL + one channel, never props (a panel component only ever receives `panelId`): + +- Gallery's lightbox is `?selected=`; Collections' detail is `?collection=` — two different + names because both panels render on the same route at once and would otherwise collide. +- Generate bumps `useAtelierRefresh` (added to `src/workspaces/officerdev/src/channels.ts`, same + stable-bump shape as `useFilesRefresh`) when a job finishes; Gallery subscribes to it. This replaces + the standalone app's `onFinished` callback prop, which only worked because `App.tsx` lifted the + gallery's state above both tabs — not an option once they are independent panels. + +The default layout is a fixed horizontal three-way split (Generate 26% / Gallery 48% / Collections +26%) rather than the standalone app's hash-based tabs — the `LayoutNode` type has no tab-group concept, +only `group` (split) and `panel`, so three panels means three panes. Users can resize or rearrange +freely; this is only the starting arrangement. + +### `sharp` — promoted to a direct dependency + +`generations/inspect.ts` needs it to measure width/height/alpha-channel/opacity of a written image — +load-bearing, not incidental: it is how a `background: transparent` request that silently produced an +opaque image gets caught, which the standalone repo's own comments call "the single most consequential +failure for an asset destined to be an app icon." It was already resolved in `bun.lock` as an optional +transitive dependency of `next`; adding it as a direct dependency pulled in no new package versions — +verified by reading the lockfile diff before committing it. + +--- + +## What did not move, and must not be touched + +**The standalone `officer-atelier` service keeps running, unchanged.** Its own `ecosystem.config.cjs` +says plainly: "this is the instance the mobile app talks to, so it holds still." It serves +`https://atelier.officer.dev`, holds real data (87 generations, 2 collections, 5 providers on the +machine this was extracted from), and this extraction did not stop it, did not touch its nginx config, +and did not touch the mobile app. **Cutting the mobile app over to the plugin's route — or deciding +whether to at all — is the owner's call, not something this run made unilaterally.** Until that happens, +having both installed means two independent Atelier deployments existing side by side, which is +expected, not a bug. + +--- + +## Verification + +- `bunx tsgo`: clean. +- `bun test`: baseline was 1041 pass / 0 fail across 90 files; after this extraction, 1069 pass / 0 fail + across 92 files — the two new files are `sidecar/serialize.test.ts` and + `sidecar/generations/response.test.ts`, both ported from the standalone repo (URLs updated for the new + `/files/` scheme). No regressions, no file count drop. +- `discoverPlugins()` finds the plugin, the manifest validates, `schema: null` as expected (separate + database). +- The platform-side proxy router (`api/router.ts`) mounts and correctly answers `503 atelier sidecar + not available` with no sidecar running — same behaviour as offscale's. +- The sidecar's internal Hono app was exercised directly (`buildHonoApp()`, real HTTP requests, no port + bound) against the `atelier_dev` Postgres database: `/providers`, `/providers/adapters`, + `/collections`, `/images` all answered `200` with real rows; `/files/` answered `404`; an + unknown route answered the OpenAI-shaped `404` envelope. Old provider rows correctly failed to + decrypt (a fresh `secret.key` was generated for this checkout, as intended — a key that will not + decrypt is broken, not absent, and `keyForProvider` reports `configured: false` rather than crashing). +- **Not done**: a live PM2 install/enable/disable/uninstall/purge cycle through `/marketplace`, per the + runbook's own verification table. This machine's live `officer` process serves the owner's real + session and its own `plugins/` directory is the main checkout, not this worktree — installing through + it would write real rows into the shared `officer` Postgres database from code that only exists here. + That crosses into "affects shared systems beyond the local environment," so it was not done + unattended. The safer checks above cover the same ground read-only or against an isolated database. + +--- + +## Open, for whoever picks this up + +1. **Publish to `gitea.officer.dev/plugins/atelier` and register it in the marketplace.** Not done — see + `BLOCKERS.md` for why and the exact commands. +2. **`ATELIER_POSTGRES_URL` has to be set before install.** No default, on purpose. +3. **The `atelier` → `officer_db` migration**, described above, once someone can watch it happen. +4. **`assets/icon.png` and `assets/splash.png` are `[placeholder]`** flat swatches — see + `assets/PLACEHOLDER.md`. +5. **A real PM2 install/enable/disable/uninstall/purge cycle**, on a machine where doing so does not + touch the owner's live session or shared database. +6. **Deciding whether and when to cut the mobile app over** from `atelier.officer.dev` to the plugin's + route, and what happens to the standalone service afterward. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6ed195e --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# Atelier + +Image generation for an Officer server: a provider-shaped API over OpenAI's image endpoints, storing +what it makes so every image has a durable reference — a gallery you can browse, collections you can +organise, and a job an agent can poll rather than block on. + +Atelier was a standalone service before this plugin existed +(`~/officer-dev/dockers/officer-atelier`, still running as `officer-atelier`, still what the mobile app +talks to). This plugin is a fresh extraction into Officer, not a replacement for it yet — see +`PLUGIN.md` before assuming the two are interchangeable. + +## What you get + +- **Generate** — a prompt, a model, size/quality/background, and a job you watch settle. `n` images + become `n` independently-tracked rows, so one failure out of four does not discard the three that + worked. +- **Gallery** — everything generated, newest first, with a lightbox: pan, zoom about the cursor, + checkerboard/dark/light backdrops for telling a transparent background from an opaque one that only + looks it. +- **Collections** — named sets of images. A generation request can name one by name and it is created on + first use; the gallery and the lightbox can file an image into one after the fact. +- **A provider-shaped backend.** "OpenAI" is code, written once; a *provider* is a row — which model, + whose key, on or off — and several can share one adapter. Today OpenAI is the only adapter; the point + of the shape is that a second one is a new file, not a redesign. +- **An OpenAI-compatible job API**, for agents. `POST /api/atelier/images/generations` accepts OpenAI's + request body verbatim and answers `202` with a job id rather than blocking for the length of the + generation — the caller polls `GET /api/atelier/images/generations/:id`, optionally with `?wait=` to + long-poll instead of retrying in a loop. + +## Requirements + +- An Officer server, version 1.0.0 or later. +- A **separate Postgres database**, and its own environment variable: `ATELIER_POSTGRES_URL`. Atelier's + tables (`generations`, `collections`, `collection_items`, `providers`) do not live in `officer_db` — + see `PLUGIN.md` for why, and set this before installing. There is no default. +- An OpenAI API key, set with `bun run secret:set atelier-openai-key`, or configured per-provider in + Settings → Integrations → Atelier, or (for an install that predates the secret store) as + `OPENAI_API_KEY` in `.env`. + +## Install + +From the Officer marketplace: find Atelier, install. That mounts `/api/atelier` and `/atelier`, starts +the `officer-atelier` sidecar under PM2, and connects it to whatever `ATELIER_POSTGRES_URL` points at — +which has to already have the schema (the standalone repo's own `bun db:push` creates it; this plugin +does not run migrations against a database it does not own the schema of). + +Uninstalling stops the sidecar and unmounts the route. **It never touches your database** — Atelier's +tables and rows are not this plugin's to drop, since it does not own the schema push for them either. + +## Permissions + +One permission, `atelier`, grantable per role at read or write. + +- **Read** lets an account use Generate, browse the Gallery and Collections, and see which providers are + configured (never a key, in any form — not even masked). +- **Write** additionally lets an account add, edit or delete providers, including their API keys. A + provider's key authorises real spend on someone's OpenAI account, so treat write here the way you + would treat write on any billing-adjacent surface. + +Providers is not a panel — it is a platform Settings screen (Settings → Integrations → Atelier), because +it is owner-level configuration rather than a working view. See `PLUGIN.md` for why. + +## Limitations, honestly + +- **Two Ateliers can exist at once.** This plugin and the standalone service are independent — installing + this one does not migrate the standalone one's data, stop it, or repoint the mobile app. See + `PLUGIN.md`. +- **No per-user data.** Every account with `read` sees the same gallery and the same collections — there + is no ownership model yet, the same simple shape offscale shipped with first. +- **The database is separate from `officer_db`**, which means Atelier's tables are not covered by + `bun db:push`, are not part of the platform's usual backup story unless you back up the second + database too, and need their own connection string. Migrating them in is real, tracked follow-up work + — see `PLUGIN.md`.