Skip to content

feat(ui): port the dev-platform operator SPA out of core web-ui (epic byte5ai/omadia#470 P2) - #2

Closed
Weegy wants to merge 3 commits into
mainfrom
feat/p2-spa-port
Closed

feat(ui): port the dev-platform operator SPA out of core web-ui (epic byte5ai/omadia#470 P2)#2
Weegy wants to merge 3 commits into
mainfrom
feat/p2-spa-port

Conversation

@Weegy

@Weegy Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

What

Ports the Dev Platform's four operator screens out of omadia core's web-ui into a standalone Vite/React 19 SPA in packages/ui. The bundle builds into packages/plugin/ui/, ships inside the plugin ZIP, and is served by core at /p/<pluginId>/ui/ behind the /plugin-ui/<pluginId> host page that C8 added.

web-ui is untouched. This is the epic's biggest single risk, isolated in its own repo and its own package.

Fragment Screen
#/ Hub — repos / jobs / apps / gates, tab deep-linked via #/?tab=
#/jobs/<id> Job detail — phase rail, live SSE log, artifacts, gates
#/repos/<id> Repo detail — budget, webhook, bind GitHub App
#/repos/new Add-repo wizard — device flow, credential, checks

What replaced what

Core Here Why
next-intl src/lib/i18n.tsx No Next request context inside an iframe. 300 keys per locale, plain {name} interpolation, no ICU parser.
next/link, next/navigation src/lib/router.tsx Hash router. Same hook names, so call sites are unchanged.
@/app/_lib/api (4,827 lines) src/lib/apiError.ts Exactly one name was imported from it: ApiError.
framer-motion, lucide-react dropped Both animate or size with classes the served vocabulary does not contain.
Button, ConfirmDialog rewritten Core writes every variant as an arbitrary value (bg-[color:var(--accent)]). Ingest rejects that shape; bg-accent resolves to the same variable.

DevJobChatCard is deliberately not ported — it renders inside core's chat transcript, and plan.md §4.3 excludes the chat surface (H3, still undecided). The one five-line function JobDetailScreen needed from its state module is in src/lib/gates.ts.

Tailwind, constrained to what core actually serves

The ported pages carried 334 arbitrary values. All are gone. A class core never saw does not error — it renders unstyled, on the operator's screen and nowhere else — so scripts/check-ui-vocabulary.mjs gates the build with three checks:

  1. No emitted stylesheet. .css is absent from the ZIP allowlist and the static router's Content-Type table; that absence is the enforcement.
  2. Core's two ingest regexes, verbatim. Copied rather than imported (this repo does not depend on core's middleware); a check that were only approximately the ingest check would build green here and be rejected there.
  3. A whitelist diff against vocabulary/classes.txt — 690 classes extracted from the generated stylesheet, not transcribed from the spec table. This is the half core has no counterpart for: ingest cannot see bg-blue-500, which is not an arbitrary value, merely a class that does not exist.

Packaging and nav

ui/ joins dist and migrations in build-zip's REQUIRED_DIRS, with assertions that ui/index.html exists and no stylesheet is present. Optional would mean "a build that forgot vite build ships silently, installs, activates, adds a nav entry, and 404s when the operator clicks it".

The nav entry moves from /admin/dev-platform — a page core deletes in this epic — to /plugin-ui/<id>, percent-encoded because this plugin's id is scoped.

Verification

Claim Evidence
Typechecks npm run typecheck, exit 0
Builds, emits no CSS vite build; find -name '*.css' = 0
Only served classes check-ui-vocabulary.mjs, exit 0
Rejects bad classes fixtures for w-[137px], [&>tr]:…, bg-blue-500
Four screens render, en + de, themed test/screens.test.tsx
Tests fail when code breaks two mutations run, both killed
ZIP carries the bundle, no CSS npm run package + unzip -l
Clean npm ci reproduces all of it verified from a wiped node_modules

50 tests. package-lock.json is regenerated — it did not contain the new workspace, which would have failed npm ci in CI.

⚠️ Three core defects found — NOT fixable from this repo

Full write-up in docs/iframe-credentials.md. All three fail silently.

1. The host page's sandbox makes every authenticated call cross-origin. PluginUiFrame.tsx sets sandbox="allow-scripts allow-forms allow-popups" — no allow-same-origin — so the document has an opaque origin. Every fetch leaves with Origin: null, credentials: 'include' cannot attach the session cookie, and EventSource(withCredentials) has the same problem. All four screens are data-driven. The bundle is correct; the boundary is not, and no screen loads data in a real browser until this trust-model decision is made. Tests do not show it because they stub fetch, which has no origin.

2. The host page rejects every scoped plugin id — including this one. Its regex is /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/ and its comment claims it "mirrors the plugin-id charset gate in manifestLoader". It does not: manifestLoader.ts:182 is /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/, where the scope is optional but blessed. This plugin's id is @omadia/dev-platform. One-line fix in core.

3. border renders invisible. plugin-ui.source.css lines 341/346/353 use a top-level comma where @source inline() expands only braces:

@source inline("border,border-{0,2,4}");    /* emits NOTHING */
@source inline("rounded{,-none,-sm,...}");  /* works — brace form */

Verified against the committed artifact: no .border, .divide-y or .transition rule exists. Tailwind's base reset is border: 0 solid, so class="border border-border" — 27 occurrences in the ported pages — sets a colour on a zero-width border and renders invisible. plugin-ui-vocabulary.md lists all three groups as available, so the doc and the artifact disagree.

src/lib/cx.ts exports BORDER = 'border-t border-r border-b border-l' (all four are emitted, 1px each) and test/vocabulary.test.ts pins the broken state, so regenerating the vocabulary after the core fix fails that test and prompts the workaround to collapse back to 'border' rather than rotting.

Fix in core:

@source inline("border{,-0,-2,-4}");
@source inline("divide-{y,x}");
@source inline("transition{,-none,-all,-colors,-opacity,-transform}");

Test plan

  • npm run typecheck — exit 0
  • npm run build — exit 0, zero .css emitted
  • npm run test -w packages/ui — 50/50
  • npm run lint:vocabulary — exit 0
  • npm run package -w packages/plugin — ZIP contains ui/index.html, zero stylesheets
  • mutation check — dropping data-theme and faking border into the vocabulary both turn the suite red
  • browser verification — blocked on core defects 1 and 2

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Weegy added 3 commits August 20, 2026 18:33
… of core

Epic byte5ai/omadia#470 P4. The dev-platform half of core's `middleware/sidecars/`
(dev-runner, dev-runner-daemon, dev-dind), the runner shim, the compose overlay
and the operator transcript CLI now live beside the plugin they serve. The other
four sidecars in core (pii-detector, privacy-detector-presidio, skillspector,
updater) are core's and stay there.

What moved:
  sidecars/dev-runner/           2 files   (image that runs exactly one job)
  sidecars/dev-runner-daemon/   30 files   (control plane + egress proxy)
  sidecars/dev-dind/             2 files   (the one privileged service)
  packages/runner-shim/         21 files   (was middleware/packages/dev-runner-shim)
  docker-compose.dev-platform.yaml
  packages/plugin/scripts/dev-transcript.ts

Paths rewritten for the new root: the dev-runner Dockerfile builds the shim from
`packages/runner-shim/`, and the compose overlay's build contexts point at
`sidecars/`. Compose resolves `build.context` relative to the file that declares
it, so the overlay still composes against a core `docker-compose.yaml` living
anywhere else.

The daemon becomes a workspace member so the repo has ONE typescript, ONE
@types/node and one lockfile. Its own `typecheck` script had never been wired
into any CI, and turning it on found 38 errors — all pre-existing on core's
pinned TypeScript 5.9, so none of them are the port's doing. Two are real
defects rather than annotations:

  - `proxyClient.mjs` passed its abort callback as `withDeadline`'s THIRD
    argument, which is `label`. The timeout message read
    `() => controller.abort() exceeded 5000ms` and the abort never fired, so a
    hung egress proxy leaked its fetch until the process exited.
  - `buildEgressProxyClient` passed `tokens[0]` through unchecked. On an empty
    DEV_RUNNER_DAEMON_TOKEN the daemon would have authenticated to its own proxy
    as `Bearer undefined`; every job then sees 407 on every request, which
    inside a runner presents as a total network outage. Now a boot refusal.

`PolicyClient.fetchJobPolicy`'s typedef had also drifted a whole parameter
behind its implementation.

The shim's suite is compiled and run as real files rather than bundled:
`src/index.ts` self-invokes behind an `import.meta.url` main guard that a
bundler silently defeats.

450/450 daemon tests and 76/76 shim tests green; both packages typecheck clean.
…ifacts

Epic byte5ai/omadia#470 P4. SEAMS.md parked `daemonProtocol`, `composeTopology`
and `goldenFixture.e2e` in P3 because each asserts against an artifact that had
not moved yet. All three arrived in the previous commit, so all three come back.

  daemonProtocol   28 tests. The wire schema is DUPLICATED — once in the plugin,
                   once in the daemon, because the daemon must not import host
                   code — and this snapshots both to JSON Schema and deep-diffs
                   them. It now reaches across packages, which is the point: the
                   split is exactly when a duplicated schema starts to rot.

  composeTopology  26 tests. The overlay lives here now; the base
                   docker-compose.yaml is core's and always will be, so it is
                   read from OMADIA_CORE_DIR — the variable _helpers/coreSchema.ts
                   already uses. Without a core checkout the cross-file
                   assertions SKIP BY NAME with a warning rather than silently
                   shrinking to the overlay-only subset: "only dev-dind is
                   privileged" is worth nothing if it only ever read the file
                   that declares dev-dind.

  goldenFixture    4 tests, pg + git gated. One job driven end to end through
                   the real pieces with only the container and the LLM faked.

Two traps the port had to clear, both the same shape — a bundler defeating an
`import.meta.url` guard:

  - `yaml` is CommonJS. Bundled into an ESM test it becomes esbuild's "Dynamic
    require of process is not supported" and throws at import, before any
    assertion. Now external.
  - The shim's `src/index.ts` ends in
    `if (process.argv[1] && import.meta.url === \`file://${process.argv[1]}\`)`,
    the guard that lets one file be both the image entrypoint and an importable
    module. Bundled into a suite, `import.meta.url` becomes the bundle path —
    which under `node --test` is also `process.argv[1]`. The shim ran ITSELF at
    import and died reading a real runner's env. Now external and imported by
    package name, so the suite exercises the BUILT shim the dev-runner image
    copies in, not a re-transpilation of its sources.

Also closes a gap of the same class as the daemon's: `scripts/dev-transcript.ts`
sits outside the plugin's `tsconfig.json` (`rootDir: "src"`), so `npm run
typecheck` had never seen the operator CLI at all, despite it importing three
modules out of `src/`. `tsconfig.cli.json` covers it; `scripts/build-cli.mjs`
emits the runnable artifact without disturbing `dist/`'s flat layout, which
`manifest.yaml`'s `lifecycle.entry` depends on.

The CLI also stops loading `dotenv/config`. Under core it ran inside the
middleware checkout; here it is an operator tool pointed at a deployment, and
silently sourcing whatever `.env` is in the working directory is how a purge
gets run against the wrong database.

747/747 plugin tests green, 0 skipped, with Postgres and a core checkout.
…yte5ai/omadia#470 P2)

Twenty-eight source files that lived in `web-ui/app/admin/dev-platform/**` and
`web-ui/app/_components/devjobs/**` now build as a standalone Vite/React 19
bundle in `packages/ui`, ship inside the plugin ZIP as `ui/`, and are served by
core at `/p/<pluginId>/ui/` behind web-ui's `/plugin-ui/<pluginId>` host page.
`web-ui` is untouched.

Four screens: hub, job detail, repo detail, add-repo wizard.

What replaced what
- `next-intl` -> `src/lib/i18n.tsx`. 300 keys per locale, plain `{name}`
  interpolation, no ICU parser. The three ICU plurals were de-sugared to
  `{ one, other }` at extraction; en and de share the `n === 1` rule.
- `next/link`, `next/navigation` -> `src/lib/router.tsx`, a hash router. The
  static route serves only the bundle root and real files, so a client route in
  the PATH would 404 on reload; a fragment never reaches the server. It also
  avoids baking a plugin id into the build, since the id comes from the install.
- core's 4,827-line `app/_lib/api.ts` -> `src/lib/apiError.ts`. Exactly one name
  was imported from it.
- `framer-motion` and `lucide-react` -> dropped. Both animate or size with
  classes the served vocabulary does not contain.

Tailwind, constrained to what core actually serves
The pages carried 334 arbitrary values (`text-[color:var(--fg-muted)]` and
friends); the ZIP allowlist rejects that shape and a class core never saw
renders unstyled rather than erroring. All 334 are gone. `Button` and
`ConfirmDialog` were rewritten rather than ported for the same reason — core
expresses every Button variant as an arbitrary value.

`scripts/check-ui-vocabulary.mjs` gates the build with three checks: no emitted
stylesheet, core's two ingest regexes verbatim, and a whitelist diff against
`vocabulary/classes.txt` — 690 classes extracted from the generated stylesheet
itself, not transcribed from the spec table. The diff is the half core has no
counterpart for: ingest cannot see `bg-blue-500`, which is not an arbitrary
value, merely a class that does not exist.

Packaging and nav
`ui/` joins `dist` and `migrations` in build-zip's REQUIRED_DIRS, with
assertions that `ui/index.html` exists and that no stylesheet is present. The
nav entry moves from `/admin/dev-platform` — a page core deletes in this epic —
to `/plugin-ui/<id>`, percent-encoded because this plugin's id is scoped.

Tests: 50, covering the four screens against fixtures in both locales, the
theme attribute crossing the iframe boundary, the router, the i18n runtime, and
the vocabulary gate (fixtures for `w-[137px]`, `[&>tr]:`, `bg-blue-500`). Two
mutations were run against the suite and both were killed.

Three core defects found and NOT fixable here — see docs/iframe-credentials.md
1. The host page's `sandbox` omits `allow-same-origin`, so every authenticated
   call from the frame is cross-origin with `Origin: null`. All four screens are
   data-driven; none can load data in a real browser until this is decided.
2. That page's plugin-id regex rejects scoped ids, though `manifestLoader`
   blesses them and this plugin's id is `@omadia/dev-platform`.
3. `plugin-ui.source.css` lines 341/346/353 use a top-level comma where
   `@source inline()` expands only braces, so `border`, `divide-*` and
   `transition*` emit NOTHING. With the base reset at `border: 0 solid`,
   `class="border border-border"` renders invisible — the exact silent failure
   the contract exists to prevent. `src/lib/cx.ts` works around it with the four
   directional utilities and a test pins the broken state so the workaround
   cannot rot.
@Weegy

Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #3 — a clean cherry-pick onto main carrying only the P2 commit. This branch had picked up two unpushed P4 commits (d2feca7, d844d4b) from a concurrent agent sharing the same local checkout, and their goldenFixture.e2e suite (unresolved @omadia/dev-runner-shim build output) was what turned CI red here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant