Files
platform/BLOCKERS.md
T
pastilhas 5854ccd4d8 atelier: map the platform, record what stays untouched, add sharp
no platform code references atelier and atelier references no platform code, so this extraction
has no reverse-dependency seam to design around. also records the database-separate decision
(collections and provider_settings both have rows on the live server) and adds sharp as a direct
dependency for the image-inspection code the sidecar needs — promoted from an existing transitive
resolution, no new package versions pulled in.
2026-08-20 03:53:30 +00:00

9.7 KiB

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/images/:filename, mounted like every other route, behind the permission gate.

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.