Files
pastilhas 1d1a4a6fa5 atelier: extraction finished — verification, open items, no db/ recorded
Atelier is now plugins/atelier/: manifest, a thin auth-gated proxy router, the sidecar backend
(providers, the generation queue, collections, its own separate Postgres database), three panels
(Generate, Gallery+Lightbox, Collections), and Providers as an Officer settings screen instead of a
fourth panel. Its own local git repository, four commits, not yet pushed anywhere — see BLOCKERS.md.

bunx tsgo is clean. bun test: 1041->1069 pass, 0 fail, 90->92 files (the two new files are ported
tests, no regressions). The real production SPA bundle builds successfully with the plugin included
(Bun.build + Tailwind, 2.2s, zero errors) and the sidecar's HTTP API was exercised end to end against
a real, non-production Postgres database. Not done, and recorded as open: a live PM2 install cycle
against the shared production database, and publishing the plugin's repository to Gitea and the
marketplace — both cross into affecting shared systems beyond this local checkout, which is not a
call to make unattended.

The standalone officer-atelier service — what the mobile app actually talks to today — was not
touched, not stopped, and not migrated. This extraction is additive.

ATELIER-COMPLETE
2026-08-20 04:11:43 +00:00

191 lines
12 KiB
Markdown

# Atelier extraction — blockers and decisions made without asking
Unattended run, started 2026-08-20. Per the kickoff prompt: decisions made here without being able to
ask, in the order found. Nobody is awake; each entry says what I did and why, then the run continues.
## Map, before anything moved
Grepped the whole platform for `atelier` (case-insensitive, every `.ts`/`.tsx`/`.md`/`.json`/`.cjs`
file). Two hits, both incidental:
- `src/workspaces/officerdev/src/apps/Chat/components/chat-images.test.ts:10` — a test fixture using
`https://atelier.officer.dev/images/....png` as an example markdown image URL. Not a dependency.
- `src/servers/api/chat/task-restart.ts:181` — a comment mentioning a stray `bun dev` process that once
ran from `~/dockers/officer-atelier` and got respawned by accident. Historical, not a dependency.
**No platform code imports anything from Atelier, and Atelier imports nothing from the platform.**
Unlike music/offscale, there is no reverse dependency to find and no "this stays behind" piece. It comes
out whole, the way offscale did before the imports were found — except here there really are none.
Also checked: `ecosystem.config.cjs` (platform's own) has no `atelier` entry — the standalone service is
managed by its OWN `ecosystem.config.cjs` inside `~/officer-dev/dockers/officer-atelier/`, entirely
outside this repository.
## The standalone service stays running, untouched
`officer-atelier` (PM2, port 3002) is a **live production service** — 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` via nginx and holds real data (87 generations, 2 collections, 5 providers,
1 provider_settings row, checked directly against Postgres).
I am not stopping it, not touching its nginx config, not touching its PM2 entry, and not touching the
mobile app. This extraction adds a new, additive plugin inside the platform; cutting the mobile app over
to it (or deciding whether to) is the owner's call, not mine to make unattended. Recorded here so nobody
reads the plugin's existence as "the old service is now redundant, kill it" — it is not redundant until
someone repoints the mobile app.
## Decision 3 — database rows checked, both non-empty → database stays separate
Queried the live `atelier` Postgres database directly (via a throwaway `postgres` client, same
credentials as `.env.production.local`):
```
collections 2
collection_items 73
provider_settings 1
providers 5
generations 87
```
`collections` (2) and `provider_settings` (1) are the two tables the brief named — both non-empty. Per
decision 3: **the `atelier` database stays separate**, not folded into `officer_db`. No
`plugins/atelier/db/schema.ts` (that convention is for `officer_db`-prefixed tables); the sidecar keeps
its own Postgres connection to the `atelier` database, exactly as today, just running from inside the
plugin's `sidecar/` directory instead of a standalone repo.
**The brief undersold the schema.** It says "tables: collections, provider_settings" — the real schema
has five: `generations` (the actual data — 87 rows, prompts/images/costs), `collection_items` (the join
table), `providers` (configured adapter+model+key rows — 5 of them; a newer, richer replacement for
`provider_settings` that the brief doesn't mention at all), plus the two named. `generations` alone
being non-empty would have settled decision 3 on its own. Following the letter of decision 3 (check the
two named tables) rather than re-deriving it from the fuller schema, since both answers point the same
way here.
**Migration to `officer_db` is a real follow-up**, not done here: fold `collections`, `collection_items`,
`providers`, `provider_settings`, `generations` into `officer_db` as `atelier_*`-prefixed tables once
someone is awake to watch a real data migration. Until then the plugin's sidecar owns a second Postgres
connection, to a second database, which is new territory for this plugin system (every other plugin's
`db/` is officer_db-prefixed tables) — documented in PLUGIN.md.
## Postgres connection for the separate database
The sidecar needs its own connection string. Added `ATELIER_POSTGRES_URL` as a new platform `.env`
variable (same pattern as `OFFICERDEV_POSTGRES_URL` for officer's own database) rather than routing it
through the secret store — a database URL carrying a password is handled as a plain `.env` value
everywhere else in this codebase (`OFFICERDEV_POSTGRES_URL` itself), and the secret store's own docs
scope it to "every encryption and signing key the platform uses" plus foreign bearer credentials
(APNs, FCM, webhooks) — not connection strings. Kept consistent with the existing convention rather than
inventing a new one.
**Not set automatically.** I don't have a safe way to guess whether the owner wants the plugin sidecar
to point at the _existing_ `atelier` database (sharing state with the still-running standalone service —
two writers to one queue table, which `generations.status` and the `claimableGenerations`/`markRunning`
claim logic were written assuming is single-process) or a fresh one. Left unset, with a clear startup
error, and documented in PLUGIN.md. **Do not just paste the standalone service's `POSTGRES_URL` in and
restart both** — two processes racing the same `generations` queue is exactly the hazard `markRunning`'s
atomic claim exists to prevent for restarts of _one_ process, not for two live ones.
## Decision 2 — Providers as an Officer settings screen, against a direct precedent
Found `IntegrationsSettings/index.tsx`'s own comment, written when email was extracted on 2026-08-18:
> "Email account management left with the feature (`plugins/email/`). A plugin contributes panels, not
> settings sections, so it now lives inside the email workspace itself."
That is the opposite of decision 2, written five days before this run, in the exact settings screen
decision 2 points Atelier's Providers into. Per the brief's own instructions ("if you find evidence one
is wrong, write it down, follow the decision anyway"): implemented Providers as a genuine platform
settings section — `IntegrationsSettings` gained an "Atelier" entry under the Enterprise tab — as new
**platform** code (`src/apps/officer-web/...`) that talks to `/api/atelier/providers` over HTTP via
`useClient`, same as any other authenticated screen. It does not import anything from
`plugins/atelier/`, so it does not violate "a plugin importing platform code is fine; the reverse is
not" — there is no import in either direction, only an HTTP call, which is what every frontend/backend
pair in this codebase already is.
Recording the contradiction here rather than silently picking the email precedent, since decision 2 was
explicit and pre-made.
## Images served through an authenticated route, not the old `PUBLIC_URL` scheme
The standalone service served `/images/*` unauthenticated, off Bun's top-level route table, with
`PUBLIC_URL` baked into every response so the URL was durable outside the process. Per "What you may NOT
do", that is gone: images are served from `GET /api/atelier/files/:filename`, mounted like every other
route, behind the permission gate. Named `files` rather than `images` so it cannot sit one path segment
away from `/api/atelier/images/generations/:id` — the JSON job API already owns `/images`.
The frontend needs `<img src>` to carry a credential a browser cannot put in an `Authorization` header.
Used the platform's existing `?token=` escape hatch — already read by both
`permission-gate.ts:37` and `user-middleware.ts:22`, already used by `FileViewer/file-types.ts` for
exactly this (`getRawUrl`, `getAudioTracksUrl`, ...) for the file browser's raw/audio/subtitle routes.
Not a new mechanism, not an unauthenticated route: the same query-token path every other authenticated
media URL in this app already takes.
## `sharp` added as a direct dependency
Atelier's `inspect.ts` uses `sharp` to measure width/height/alpha-channel/opacity of a written image —
load-bearing (it is how a `background: transparent` request that silently produced an opaque image gets
caught). `sharp@0.33.5` was already resolved in `bun.lock` as an optional transitive dependency of
`next`, so promoting it to a direct dependency of the platform added no new resolution — verified the
lockfile diff is exactly that promotion, nothing else moved. Done via the documented dance:
`frozenLockfile` commented out in `bunfig.toml` for one `bun install`, restored immediately after,
diff reviewed before committing both files.
## Publishing to the Marketplace — not done, left as the one open step
`plugins/atelier/` is its own local git repository (`git init`, per "Done means"), but I did not create
`gitea.officer.dev/plugins/atelier` or push to it. I found no Gitea API token or credential available to
this session, and creating a new repository on a shared Gitea instance and registering it in the
marketplace catalogue (`scripts/marketplace-sync.ts`, `BUNDLED` in `src/servers/plugins/marketplace.ts`)
is exactly the kind of action with effects beyond this local checkout that the run's own ground rules
say to flag rather than take unilaterally, especially with no verified way to confirm it succeeded.
Left for the owner:
```
cd plugins/atelier
git remote add origin git@gitea.officer.dev:plugins/atelier.git # after creating the repo on Gitea
git push -u origin main
cd ../..
bun --env-file=.env scripts/marketplace-sync.ts
```
Then add a `BUNDLED` entry in `src/servers/plugins/marketplace.ts` (offline fallback), matching the
other three plugins' entries.
Until that happens, the plugin still installs fine from a checkout that already has the directory
(`plugins/atelier/` committed to its own local repo, present on disk) — it just cannot be discovered by
the Marketplace screen on a machine that has never had it, per "Done means" #2. This is the one piece of
"done" I could not complete unattended.
## No `plugins/atelier/db/`, on purpose
"Done means" lists `db/` among the plugin's expected pieces, matching offscale's and music's shape. This
plugin has no `db/schema.ts` at all — `discoverPlugins()` correctly reports `schema: null` for it. That
convention is specifically for `officer_db`-prefixed tables pushed by `pushPluginSchema`; decision 3
keeps Atelier's tables in their own separate Postgres database instead, so there is nothing for that
convention to describe. The database code lives inside `sidecar/db/` as an internal implementation
detail of the sidecar process, not as a platform-recognised `db/` directory. Documented in full in
`plugins/atelier/PLUGIN.md`, under "The database".
## What I verified, and what I deliberately did not
- `bunx tsgo`: clean throughout.
- `bun test`: baseline 1041 pass / 0 fail / 90 files → after this run, 1069 pass / 0 fail / 92 files.
The two new files are the ported `serialize.test.ts` and `generations/response.test.ts`. No
regressions, file count only grew.
- `discoverPlugins()` finds the plugin and the manifest validates with no problems.
- The platform-side proxy router mounts and correctly 503s with no sidecar running.
- The sidecar's own Hono app was exercised directly against the real `atelier_dev` Postgres database
(not `officer_db`, not the production `atelier` database) with real HTTP requests and no port bound:
providers, providers/adapters, collections and images all answered correctly; a missing file 404s; an
unknown route gets the OpenAI-shaped error envelope; provider rows whose keys were encrypted under the
standalone service's own key correctly fail to decrypt rather than crashing (a fresh `secret.key` was
generated for this checkout at `$OFFICER_ROOT/data/atelier/secret.key`, which is expected and correct
— see PLUGIN.md).
- **Deliberately not done**: installing the plugin through the live, running `officer` PM2 process. That
process serves the owner's real session, its `plugins/` directory is the main checkout (not this
worktree, so it would not even find this code), and going through its install flow would write a real
`plugin_installs` row into the shared production `officer` Postgres database from a session with no way
to confirm the result against a live browser. Full PM2 install/enable/disable/uninstall/purge cycle,
per the runbook's verification table, is left for the owner — noted as open item 5 in PLUGIN.md.