Both masters already existed in OfficerIcons — the painter duck with the beret and palette — so the plugin was showing a flat #f59e0b square for want of a copy step, not for want of art. The icon master is 1312x1199, not square, so it is trimmed to the artwork before it is fitted to 256: resizing that canvas directly would have drawn the duck at about four fifths the size everything else in the dock uses. Splash is a straight 1254 -> 1024, and PLACEHOLDER.md goes with them.
205 lines
14 KiB
Markdown
205 lines
14 KiB
Markdown
# 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
|
|
`<img src>`) covers it: the JSON API returns `/files/<name>` (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-<adapter>-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 `OFFICERDEV_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`, `<appname>_` 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.** `OFFICERDEV_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=<id>`; Collections' detail is `?collection=<id>` — 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/<missing>` 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).
|
|
- **The real production SPA bundle builds successfully with the plugin included.** Called
|
|
`generatePluginsModule` and `rebuildFrontend` directly (the same functions `refreshPluginMounts` calls
|
|
during a real install) against a synthetic "atelier is installed and enabled" state — no server
|
|
running, no port bound. `Bun.build`, with the real Tailwind plugin, compiled `Plugins.gen.tsx`, all
|
|
three panels, and the new `AtelierProviders` settings section in 2.2s with zero errors: 3 outputs, no
|
|
warnings. This is the same build step an install performs and exercises everything `bunx tsgo` cannot
|
|
— module resolution through Bun's own bundler, JSX compilation, Tailwind processing — without needing
|
|
a running server. What it does not prove is that the result **renders correctly in a browser**; see
|
|
below.
|
|
- **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. **`OFFICERDEV_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.~~ Done
|
|
2026-08-20: both come from the suite's own masters in the OfficerIcons repo —
|
|
`OffAtelierAppIcon` (trimmed, since that master is 1312x1199 rather than square, then centred on
|
|
a transparent 256 canvas) and `OffAtelierSplashScreen` at 1024. Same shapes and same convention as
|
|
plugins/music.
|
|
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.
|
|
|
|
## Reversed 2026-08-20: officer_db, not a separate database
|
|
|
|
The extraction kept Atelier's own `atelier` Postgres database, because `BLOCKERS.md` decision 3 said to
|
|
when the tables already had rows. The owner reversed that afterwards, and the reasoning is worth keeping:
|
|
|
|
**A plugin that behaves differently from every other plugin is a plugin nobody can reason about.** The
|
|
separate database meant a second connection string to set before install, a second thing to back up, a
|
|
second thing to get wrong on a new machine, and failure modes no other plugin shares. None of that was
|
|
bought by anything except avoiding a migration.
|
|
|
|
So: four `atelier_`-prefixed tables in `officer_db` — `atelier_collections`, `atelier_collection_items`,
|
|
`atelier_providers`, `atelier_generations` — created empty by `bun db:push`, like every other plugin.
|
|
Index and check constraint names carry the prefix too; those are global in Postgres and would otherwise
|
|
be the one thing that collides.
|
|
|
|
`ATELIER_POSTGRES_URL` is gone. `sidecar/db/db.ts` reads `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`, the
|
|
house convention, which the platform already sets.
|
|
|
|
**The standalone service's data is NOT here.** It is still in the old `atelier` database, and the old
|
|
service is still running against it, untouched. Migrating those rows — 2 collections, 73 items, 5
|
|
providers, 87 generations — is a deliberate, watched step, done after the plugin is installed and proven
|
|
on alpha. Nothing about this plugin depends on it happening.
|