Skip to content

feat: extractable template pattern (repo + sources) + wizard bridge - #436

Draft
abeldantas wants to merge 24 commits into
work/switch-expert-templatefrom
work/switch-expert-registry
Draft

abeldantas wants to merge 24 commits into
work/switch-expert-templatefrom
work/switch-expert-registry

Conversation

@abeldantas

Copy link
Copy Markdown
Collaborator

Brief

Extends the template registry with the extractable pattern — CHOO-2665. A template now declares its full dependencies as data: persona + provider (user input) + repo + sources. Switch-expert is the first instance with repo sandbox-quantum/switch and Switch docs sources. Part of the Framework workstream (CHOO-2600).

Builds on PR #409 (Console template listing + bundled Switch expert) and #421 (template registry).

Summary

  • repo_url + sources columns on the registry's templates table via a new migration (ddb436ff9c0d). repo_url is nullable Text, sources is nullable JSONB (array of {url, label}).
  • Full Switch expert persona — replaces the 145-line bundled copy with the production switch-expert/AGENT.md (~340 lines of system prompt + knowledge file references).
  • Bundled template enriched — declares repo sandbox-quantum/switch and 3 doc source URLs.
  • Template cards show repo URL and source count in the listing.
  • Gateway client types carry repoUrl and sources on both summary and detail.
  • Room-template wizard bridge (part 4) — "+ Create" button on not-found agent-slot chips opens the add-agent modal with the name prefilled and auto-session on.
  • Add-agent modal prefillName prop — for the wizard bridge.

Supersedes the old napoleon Gemini-pinned persona.

Test plan

Backend: ruff format/check clean, mypy clean across core/ and connectors/. Console: workspace packages build, oxfmt clean, tsgo typecheck passes across all 5 workspace projects. The extractable pattern is proven by the bundled Switch-expert template declaring repo + sources as data. Live verification (composed demo) pending Abel's validation.

abeldantas and others added 24 commits September 9, 2026 09:56
Three-step flow: paste YAML or pick a file → fill a form generated from
the template's params block → POST to /rooms/from-yaml and navigate to the
new room. Paramless templates skip straight to creation.

Agent-name params (named exactly "agent" or ending in "_agent") render as
a combobox over the live agent list with an inline warning when the name
doesn't match. Required fields block submission with inline errors before
any server call. Server 400s are mapped back to the offending field.

Registered as "Templates" in the workspace sidebar nav. One appended entry
each in view-ids.ts, view-registry.ts, and workspace-nav.tsx.
The package is "type": "module" so __dirname is undefined at runtime,
crashing electron-vite dev on launch. import.meta.dirname is the ESM
equivalent and works in both dev and packaged builds.
Two-column layout: form on left, "What this creates" summary panel on
right showing a live preview of the room name (interpolated from inputs),
which agents will be added, and template source info.

Field labels now use humanized param names with description as helper text
underneath. Agent-name fields show an inline "exists ✓" or "not found"
badge inside the input. Fields with unchanged defaults show "(default
kept)". Buttons are stacked full-width: "Back to template" + "Create
room". Step indicator "Step N of 2" in the header.

Parser now also extracts the agents list from the template's room block
for the summary panel preview.
Make the YAML paste area taller (min-h-64) and vertically resizable so
large templates are comfortable to work with.

Use describeFailure's detail field directly for server errors instead of
the HTTP-prefixed message, so the user sees "Unknown agents: foo" rather
than "Could not create the room from this template. (HTTP 400: ...)".
…rors

Pull the server's detail field from the RpcError before describeFailure
wraps it with an HTTP prefix, so the user sees "Unknown agents: foo"
rather than "HTTP 400: Unknown agents: foo".
Server side: new GET /gateway/rooms/template-schema endpoint returning
the JSON Schema for a valid room template document, generated from
Pydantic's TemplateDocument model (RoomSpec + ParamSpec).

Console side: fetches the schema when the Templates view loads and
validates the parsed YAML against it (via ajv) at the step 1→2
transition. Schema violations surface as parse errors before the form,
so the user learns their template is invalid before filling anything out.
Graceful degradation: if the server doesn't support the endpoint (404),
validation is skipped.

ajv added as an explicit dependency (was already present as a transitive
dep).
When the schema endpoint returns 404 (server lacks params support), the
parser now rejects unknown top-level keys like params: at step 1 with a
clear message instead of letting the user fill a form that will fail at
create time.

Adds drag-and-drop support to the source step textarea — drop a YAML
file onto it and it reads the same way the file picker does.
When the schema endpoint is unavailable, skip validation and let the
server decide on create. The previous fallback rejected params: on any
server without the schema endpoint, which blocked servers that support
params but predate this PR's schema endpoint.
The room view reads from a MobX store that caches the rooms list.
Navigating immediately after creation showed "still loading" because the
store had no entry for the new room. Call refreshSidebarRoomState(true)
before navigating — same pattern as CreateRoomModal.
A template has been a YAML file passed around by hand. This is the table it
lives in instead, plus the store that reads and writes it — the storage half
of the registry, with the API to follow.

The document is kept verbatim and never parsed. That is what makes the
round-trip byte-identical, and it means a document written for a format this
server does not yet understand is stored and served back intact rather than
refused at the door. `kind` is free text for the same reason: room, group and
agent templates should differ by a string, not by a migration.

Uniqueness is (owner_id, name), so two people may each keep a "deploy-room"
but neither may keep two. Scoping it to the owner rather than the server is
also what lets it survive the tenant work — an owner belongs to a tenant, so
the constraint narrows instead of needing to be rebuilt.

Listing is server-wide: the registry is a catalogue, so ownership governs who
may change a template, not who may see it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…2677)

The REST half of the registry. Upload a template, browse the catalogue, search
it by name or description, fetch one back, and delete your own.

Two doors for reading a template: the detail route returns it inside a JSON
envelope with its metadata, and `/templates/{id}/content` returns the stored
document alone, byte for byte, for anything that wants to pipe it somewhere.
Nothing on this path parses or reformats the document.

The catalogue is server-wide — every owner's templates are listed to everyone,
because a registry nobody else can see distributes nothing. Ownership decides
who may change or remove a template, via the existing require_manage, so an
admin can clean up and nobody else can touch what is not theirs. A listing
omits the document bodies; fetch one to get its content.

Uploads are bounded by TEMPLATE_MAX_BYTES, counted in encoded bytes rather
than characters so multibyte text cannot slip past, and refused whole rather
than truncated.

Note the store now reloads after an update: `updated_at` is computed by
Postgres, so the flush leaves it expired, and reading it afterwards would trip
a lazy load from synchronous code and raise MissingGreenlet rather than return
a timestamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…HOO-2677)

A Templates tab on the Resources page: the catalogue with search, kind and
owner filters, an upload dialog that can load a document from a file, and a
detail page to read, edit, copy, download or delete one. Edit and delete are
offered only to the owner and to an admin, matching what the server enforces.

Copy and download go back to the server for the document rather than using
what is on screen, so what lands in the file is what is stored even when the
editor has unsaved edits in it.

Deleting has its own dialog rather than reusing DeleteResourceDialog: that one
warns which rooms a resource is about to be detached from, and a template is
attached to nothing.

Also adds the HTTP-level tests. The existing route tests call the handler
coroutines directly, which skips the part the acceptance condition is about —
a template has to survive being JSON-encoded onto the wire and decoded off it
again. These drive the real app over an ASGI transport and assert the document
comes back byte for byte, CRLFs, tabs, quotes, backslashes and all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The server reports users/refs/docs that couldn't be added in
failedAttachments, but the Console discarded them. Now shows a warning
toast naming each failed item so the user knows what didn't land — e.g.
"abel.dantas (no account with this name is known on the bridge)".
The existing override mapped <4.3.0 to >=4.3.0, which resolved to 4.3.1 —
still vulnerable to merge-key CPU exhaustion. Floor is now 4.3.2, the
fixed release. Transitive only; the app's direct js-yaml (5.x) is not in
the affected range.
Six real defects, all in code added by this branch.

**A hostile name broke the download.** The template name went straight into
`Content-Disposition`. Headers are latin-1 on the wire, so a name holding `☕`
failed the whole response — a permanent 500 on that template's document, for
every reader, plantable by any user. A name holding CRLF put a header of the
caller's choosing into the response; h11 refuses to send that, so it was a
poison pill rather than a true injection, but relying on the HTTP server to
sanitise is not a defence. Now RFC 6266: an ASCII skeleton in `filename`, the
real name percent-encoded in `filename*`. The download also says `nosniff`,
since the bytes are whatever somebody uploaded.

**The listing read every document to report its size.** `size_bytes` was
computed in Python from `content`, so rendering a page of names dragged every
stored document through Postgres and into memory — exactly what the schema
docstring claimed the listing avoided. Measured with `octet_length` now, and
the query no longer selects the document at all.

**Only the document was bounded.** `name`, `description` and `kind` had no
maximum against unbounded `Text` columns, and nothing else in the request path
caps a field, so a 50MB name was a valid upload.

**Concurrent edits could lose one silently.** Bumping a revision means reading
the old one first, so two edits racing computed the same next number and the
later write dropped the earlier one with both callers seeing 200. The
read-modify-write now takes a row lock.

**A rename that lost a race 500ed.** `update_fields` pre-checked for a clash
and then flushed outside a savepoint, so losing the race raised through the
router instead of answering 409 — and the comment claimed the constraint
backstopped it, which nothing did.

**A concurrent delete 500ed**, for want of the `ValueError` catch every
sibling router has.

The store now raises `TemplateNameTaken`, a ValueError subclass, so a conflict
and a missing row stop being the same exception distinguished only by which
call happened to run first: create answers 409 or 400, patch 409 or 404.

Front end: the clipboard write was never awaited, so a rejected copy was
silently indistinguishable from a successful one, and a non-secure context
reported it as a failed fetch; the file picker's name autofill read a state
value captured before the read, overwriting a name typed while it was in
flight; the object URL was revoked in the same tick as the click; and an
oversize file was read fully into memory to be refused by the server a round
trip later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`main` gained an index migration and then a merge revision to rejoin the two
heads it created (#404, #426). This branch's migration still pointed at the
revision that was the head when it was written, so merging main left the chain
with two heads again and `test_single_head` did its job.

Re-parented onto `b47e0c39a1f5` rather than adding a second merge revision:
this migration has never been released, so it can simply be moved instead of
leaving a permanent join in the history to record a branch that only ever
existed on a feature branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tab's body, route, dialog and handler all landed; the `<Tab>` that
selects it did not. So the page rendered a branch nothing could reach — it
compiled, `tsc` had nothing to object to, and with no tests in this tree the
only way to notice was to open the page and look, which I had not done.

Rather than add the missing line, the tab bar, the URL parameter, the union
and the create button's label now come from one list. Keeping them together
is what stops the next tab from being half-added: a value that is not in the
list cannot typecheck, and one that is gets a bar entry for free.

Also names the templates filter "Kind" rather than the shared bar's default
"Type", which disagreed with the column beside it.

Verified in a real browser this time, not just compiled: all five tabs select,
route, label their button and render; a file picked in the file dialog is
stored byte-identical; editing the document bumps the revision and editing
only metadata does not; and the template is findable, then gone after a
delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The YAML provisioning path returned None when bridge was omitted,
creating an internal-only room. This broke templates with users: since
users live on a collaboration bridge. Now looks up the instance's
default bridge (same behavior as the regular create-room endpoint).
…greying it

Every template on the server is visible to everyone, so opening one you do not
own is the ordinary case here — not the exception it is for the resources next
door, which a reader only sees if they were given access. Disabled fields alone
answer that badly: they look much like enabled ones until you try to type, and
they never say whose template it is or who could change it.

Non-owners now get a line saying so. Owners and admins are unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed locally

The Templates view now reads the server catalogue from the template
registry (CHOO-2677) instead of a parallel stored_templates table, which
this branch previously carried. The bundled Switch expert ships inside
the Console as a local template - it renders in the same listing and
works with no server round trip.

Registry rows map owner_name to the creator label; a template's content
becomes the agent's instructions on create.
Adds repo_url (Text) and sources (JSONB) columns to the registry's
templates table via a new migration. The template shape is now the full
extractable pattern: persona + provider (user input) + repo + sources.

- Replaces the 145-line bundled persona with the full switch-expert/
  AGENT.md (~340 lines of system prompt + knowledge file references)
- Bundled Switch-expert template declares repo sandbox-quantum/switch
  and 3 Switch docs source URLs
- Template cards in the listing show repo URL and source count
- Gateway client types carry repoUrl and sources
- Room-template wizard: "+ Create" button on not-found agent-slot chips
  opens add-agent modal with the name prefilled
- Add-agent modal gains prefillName prop for the wizard bridge

All checks green: ruff, mypy, oxfmt, oxlint, tsgo typecheck.

CHOO-2665.
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.

2 participants