Skip to content

Workshop full-stack decomposition: layered crates, self-registration, lazy SPA - #36

Merged
vinniefalco merged 19 commits into
cppalliance:masterfrom
vinniefalco:master
Sep 13, 2026
Merged

Workshop full-stack decomposition: layered crates, self-registration, lazy SPA#36
vinniefalco merged 19 commits into
cppalliance:masterfrom
vinniefalco:master

Conversation

@vinniefalco

Copy link
Copy Markdown
Member

Decomposes the 18.2k-line workshop-server into 8 crates across a strictly one-way tier graph (shell -> features -> services -> vocabulary), enforced mechanically by a new xtask with tidy-style architecture tests. The SPA restructures into 11 lazy-loaded feature directories with colocated CSS, .ws-* class prefixes, and three-tier --ws-* design tokens.

Server: workshop-protocol (wire contract, fixture-pinned), workshop-support (incl. generic RetainedBus collapsing three hand-rolled copies), workshop-registry (sealed proxy slots; subsystems self-register routes, state handles, and push channels), workshop-gateway, workshop-status, workshop-menu, workshop-sessions, workshop-workspace. AppState is now a composition root over the registry. Wire format unchanged; all existing tests moved with their modules and stay green.

SPA: initial bundle 390 KB vs 2.65 MB before (esbuild code splitting, panels load on first activation). Panel/command/menu/service registries replace the hand-wired main.ts; window-menu.ts (618 lines) split into three registry modules. Component CSS contains zero raw hex/px values.

Enforcement: xtask checks the tier dependency table, the 500-line file ceiling, and workspace lint inheritance; cargo xtask new-crate scaffolds conformant crates. Architecture invariants recorded in .cursor/rules/.

Notes:

  • First commit fixes the scheduler determinism-violation test broken by f566cc1 (CI was red).
  • The 2k-lines-per-crate aspiration is unmet (gateway 5.2k, sessions 4.9k) - the plan's own crate map predicted these sizes; the 500-line file ceiling holds and is enforced.
  • The shell's GatewaySupervisor stays in the workshop crate; porting to a shared crate defers to the headless agent mode plan.
  • Headless test configuration (workshop-server --features headless) is wired into CI.

Plan: vibe/2026-09-12-3-workshop-server-decomposition.md (in tree, all 9 steps marked completed).

A scheduler test covering two live arms appending to one store path raced: it passed only when the second arm's blocking-pool operation claimed the path before the first arm finished and released its claims, so a slow blocking pool let both appends land and the run return success. The test now parks the first backend append with its write claim held until the losing arm's conflict observation releases it, so the conflict fires no matter how late the second operation's thread starts. The parked wait is bounded at ten seconds, so a claims model that stops conflicting fails the test instead of hanging the run-end drain.

- `AppendGate` is a one-shot gate: the first caller parks on a condvar until `open` releases it, later callers pass through, and the wait times out after ten seconds as a backstop.
- `GatedStore` and `GatedAccess` wrap a memory backend so the first append served parks on the gate with its write claim held; every other operation forwards unchanged.
- `from_vfs` wraps a caller-built `VfsRef` in the test store's seeding and assertion helpers, letting the test mount the gated backend.
- `GateObserver` opens the gate on `Observation::StoreAppendFailed`, which fires before the conflict answer posts, so the winner's parked operation completes ahead of the run-end drain that awaits it.
- `Scheduler` and the claims model are untouched: the diff changes only test code and the plan's step status.

Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
This change writes the decomposition's structural rules into the repository's agent guidance and adds mechanical enforcement so they bind without reviewer vigilance. A new tooling crate checks that tiered crates depend only on lower tiers, that no source file exceeds the line ceiling, and that every participating crate inherits the workspace lints, with the checks running as ordinary tests. It also adds a generator that scaffolds new crates with the required manifest, invariant docs, and integration-test binary so each future crate starts conformant.

- `AGENTS.md` records the one-way tier dependency rule, the per-crate invariant doc requirement, and the 500-line file ceiling, plus the CSS colocation and token rules for the SPA side.
- `crates/xtask/src/tidy.rs` keeps the tier policy in a pure table over four tier lists; crates not yet extracted have no manifest and are skipped, so the check passes today and binds each crate as it lands.
- `crates/xtask/src/new_crate.rs` emits a generated library doc carrying the invariant marker, which is what opts the new crate into the ceiling and lint checks.
- `crates/xtask/src/main.rs` prints violations and exits nonzero on the tidy subcommand, scaffolds on the new-crate subcommand, and prints usage with exit code 2 otherwise.
- `validate_name` confines scaffolding to the kebab-case workshop namespace, with tests covering overwrite refusal and rejected names.
- `crates/xtask/Cargo.toml` declares only anyhow and toml, so the tooling tier depends on none of the crates it checks.

Design: new pure-function @ crates/xtask/src/new_crate.rs::validate_name deps: &str
Design: new pure-function @ crates/xtask/src/tidy.rs::allowed_dependencies deps: &str
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
Splits the workshop server's bottom layer into three vocabulary crates: the socket wire protocol, the support utilities, and a new subsystem registry. Every frame type moves with its wire shape unchanged, still pinned against the shared JSON fixture and the TypeScript contract, so nothing on the wire changes. A generic retained broadcast bus replaces the three hand-rolled copies behind the status, catalog, and menu buses. The registry introduces sealed traits and proxy slots so subsystems self-register, and the status bus migrates as the proof of concept.

- `crates/workshop-protocol/src/lib.rs` now owns the whole socket wire contract with zero I/O; the frame types, their per-shape pin tests, and the shared agent-frame fixture move together, and the TypeScript half cross-cites the new home.
- `crates/workshop-registry/src/traits.rs` defines five sealed subsystem traits; only the status channel ships its closure-backed adapter, and the crate docs state the remaining adapters arrive with their subsystem migrations.
- `crates/workshop-support/src/bus.rs` introduces the generic retained bus; the status, catalog, and menu buses become thin wrappers that keep their own ring capacities and intent-named helpers.
- `crates/workshop-server/src/app.rs::AppState` gains the registry plus the guard that keeps the status bus's self-registration alive for the state's lifetime.
- `crates/workshop-server/src/session.rs::run_session` reads the status channel through the registry slot; an empty slot pends the receive branch forever, so a session runs without status frames rather than failing.
- `crates/workshop-server/src/error.rs::AppError` serializes its HTTP error body from the pinned envelope type instead of an ad-hoc JSON literal, with a status-text fallback that cannot trigger for two strings.
- `crates/workshop-server/ui/test/agent-wire-fixtures.mjs` reads the shared fixture from its new location, keeping the two-sided drift pin intact.
- `crates/workshop-server/src/protocol.rs` is deleted; every type and test it held reappears under the protocol crate, so nothing it contained is dropped.
- `crates/workshop-registry/src/registry.rs::Registry` exposes route, state, task, and shutdown slots that no subsystem registers into yet.

Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentsFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentsFrame
Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentSessionFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentSessionFrame
Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentEventFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentEventFrame
Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentDeltaKind boundary: wire was: crates/workshop-server/src/protocol.rs::AgentDeltaKind
Design: replaces value-object @ crates/workshop-protocol/src/agent.rs::AgentDeltaFrame boundary: wire was: crates/workshop-server/src/protocol.rs::AgentDeltaFrame
Design: replaces value-object @ crates/workshop-protocol/src/input.rs::InputFrame boundary: wire was: crates/workshop-server/src/protocol.rs::InputFrame
Design: replaces value-object @ crates/workshop-protocol/src/status.rs::Severity boundary: wire was: crates/workshop-server/src/protocol.rs::Severity
Design: replaces value-object @ crates/workshop-protocol/src/status.rs::Activity boundary: wire was: crates/workshop-server/src/protocol.rs::Activity
Design: new value-object @ crates/workshop-protocol/src/error.rs::ErrorEnvelope boundary: wire
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::RouteRegistrar
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::StateProvider
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::BackgroundTasks
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::StatusChannel
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::ShutdownHook
Design: new shared-mutable-state @ crates/workshop-registry/src/slot.rs::ProxySlot
Design: extends ambient-context @ crates/workshop-server/src/app.rs::AppState
Design: new service-locator @ crates/workshop-server/src/session.rs::run_session deps: AppState,WebSocket
Design: removes clone-block @ crates/workshop-server/src/status.rs::StatusBus
Design: removes clone-block @ crates/workshop-server/src/catalog.rs::CatalogBus
Design: removes clone-block @ crates/workshop-server/src/menu.rs::MenuBus
Design: extends facade @ crates/workshop-server/src/lib.rs
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
Splits the workshop server's domain services into three crates: the gateway client with its binding, heartbeat, progress subscriber, resolution, and run event log; the status bus with its progress renderer; and the model menu with its catalog channel. The push facade moves into the registry and reads new producer sink slots, so a subsystem reports what happened without naming another subsystem's bus. The shell re-exports the extracted crates at their old module paths, so its internals and tests read as they did before the split. The chat-capability predicate moves into the wire protocol crate, where the menu's picker filter and the gateway's catalog refresh both share it.

- `crates/workshop-registry/src/push.rs::Push` wraps the registry and resolves the status, catalog, and menu sink slots on every call; an intent whose slot is empty degrades to a no-op, so a producer spawned before its subsystem registers never fails.
- `crates/workshop-registry/src/traits.rs` adds the sealed `StatusSink`, `CatalogSink`, and `MenuSink` traits with closure-backed adapters; registrants plug in through the adapters and never implement the traits downstream.
- `crates/workshop-menu/src/lib.rs::register` and `crates/workshop-status/src/lib.rs::register` self-register the buses' producer sinks and the status push channel, returning the guards the composition root holds for the process lifetime.
- `crates/workshop-server/src/lib.rs` re-exports the three crates at their pre-split module paths and narrows its own push module to a re-export of the registry's facade.
- `crates/workshop-protocol/src/catalog.rs::is_chat_capable` interprets the gateway's catalog shape as wire semantics, documented as living in the protocol crate rather than in any one subsystem.
- `crates/workshop-server/src/session/menu.rs::run_switch` takes the menu bus as a parameter and calls `finish_switch` on it directly; the registry's menu facade keeps only the heartbeat's reachability, profile, and restore mutators.
- `crates/workshop-server/src/session/menu.rs::drive_switch` answers an unrecognized switch response variant with a stream-ended failure and skips an unrecognized event variant, so a gateway that grows new response shapes never degrades the server.
- `crates/workshop-server/tests/it/heartbeat_loop.rs` composes the extracted heartbeat with the real status, catalog, and menu buses through the registry: single-fire transition announcements, refresh-on-reconnect, startup convergence retries, the anti-flap backoff rule, and give-up reporting all run against mock gateways.
- `crates/workshop-gateway/src/lib.rs` registers nothing into the registry; the crate exposes its modules and re-exports only.

Design: new facade @ crates/workshop-gateway/src/lib.rs
Design: new facade @ crates/workshop-menu/src/lib.rs
Design: new facade @ crates/workshop-status/src/lib.rs
Design: extends facade @ crates/workshop-server/src/lib.rs
Design: new facade @ crates/workshop-registry/src/push.rs::Push boundary: wire
Design: new service-locator @ crates/workshop-registry/src/push.rs::Push
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::StatusSink
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::CatalogSink
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::MenuSink
Design: new pure-function @ crates/workshop-protocol/src/catalog.rs::is_chat_capable deps: serde_json::Value boundary: wire
Deferred: workshop-gateway registers no routes, state handle, or background tasks into the registry
Deferred: workshop-status registers no state handle into the registry
Deferred: workshop-menu registers no state handle into the registry
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
Split the two remaining feature subsystems out of the server shell into their own crates: the workbench and agent-session sockets, run supervision, input waits, and the catalog relay into one, and the jailed workspace filesystem behind the workspace routes into the other. The shell's shared state stops naming subsystem fields and becomes a composition root: every subsystem is built at startup, self-registers its routes and state handles into the registry, and is reached through the registry's slots thereafter. Each feature crate maps its own failures to the wire envelope at its route boundary, so the shell's error type shrinks to the shell's own routes.

- `Registry` gains seven slots - two per-subsystem route registrars, four state-handle providers, and the workspace granted-roots handle - plus closure adapters for the sealed traits, so registrants plug their routes and handles in without implementing a registry trait.
- `SessionsState` receives every collaborator as a constructor parameter, including the shell's WebSocket origin policy as a plain function, so the cross-site guard stays the shell's security boundary while the subsystem owns the upgrade handlers.
- `SessionHost` drops its workspace field and reads the granted roots through the registry's roots slot; the `ui()` snapshot serves null while the slot is empty.
- `AppState` shrinks to the reconnect backoff, the progress hub, the registry, and the registration guards; its accessors downcast the registered handle sets and panic on a missing registration, a composition-root bug rather than a runtime condition.
- `WorkspaceError` renders its own wire envelope at the workspace route boundary, and the shell's `AppError` drops the sixteen workspace variants, the `From<WorkspaceError>` seam, and the `GatewayUnreachable` variant the relay now answers as a 502 itself.
- `/ws`, `/agents/ws`, `/v1/models`, and `/workspace/*` stay mounted, merged from the registered routers, and `POST /chat` still answers 404, pinned by the tests that moved with the extraction.
- `serve.rs` splits its tests into `serve/tests.rs`, keeping every file under the 500-line ceiling.

Design: new registry @ crates/workshop-registry/src/registry.rs::Registry
Design: new speculative-abstraction @ crates/workshop-registry/src/traits.rs::WorkspaceRoots
Design: ambient-context -> service-locator @ crates/workshop-server/src/app.rs::AppState
Design: new constructor-injection @ crates/workshop-sessions/src/state.rs::SessionsState deps: AgentSessions,CatalogBus,GatewayBinding,GatewayHealth,MenuBus,Registry,fn(&HeaderMap) -> bool instead-of: layer-violation: the feature crate importing the shell's cross_site module
Design: new constructor-injection @ crates/workshop-sessions/src/agents.rs::SessionHost deps: CatalogBus,MenuBus,ReconnectBackoff,Registry instead-of: layer-violation: naming the workspace crate for the ui() snapshot's granted roots
Design: new facade @ crates/workshop-sessions/src/lib.rs
Design: new facade @ crates/workshop-workspace/src/lib.rs
Design: extends facade @ crates/workshop-server/src/lib.rs
Deferred: workshop-sessions registers no push channels or background tasks into the registry
Repairs: a crate's doctests compile against the crate itself @ crates/workshop-gateway/src/observer.rs - three WorkshopObserver examples imported workshop_server::WorkshopObserver, an import the gateway crate cannot resolve
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
The client app grew as two flat directories mixing unrelated concerns, so its source files now sort into eleven feature directories, each with a barrel module re-exporting its public API. Every workshop-owned style class carries a shared project prefix for namespace isolation, while library and shared-ui classes keep their names. Raw size and shadow values move out of component styles into a three-tier token sheet of base primitives, semantic intent aliases, and per-component slots, so theming edits one layer instead of every component. Imports, tests, and the shipped markup follow the new paths and class names; no logic changes.

- `crates/workshop-server/ui/src/ui/agent/index.ts` re-exports the directory's public API through one barrel; the ten sibling feature directories follow the same pattern.
- `crates/workshop-server/ui/src/tokens/base.css` is the only stylesheet holding raw values; `crates/workshop-server/ui/src/tokens/semantic.css` maps intent aliases to those primitives and `crates/workshop-server/ui/src/tokens/component.css` assigns per-component slots, so component styles reference only `var(--ws-border-width)`-style tokens.
- `crates/workshop-server/ui/src/main.ts` imports the three token sheets at boot ahead of the component tree and repoints every UI import at the new feature paths.
- `crates/workshop-server/ui/index.html` carries the renamed `ws-window-titlebar` and `ws-shell` classes, matching the selectors the moved stylesheets and scripts now query.
- `is-editor-empty` keeps its name, as do the other library and shared-ui classes; the prefix applies only to workshop-owned classes.
- `crates/workshop-server/ui/test/agent-session-view.mjs` and the other test edits swap selectors and import paths for the renamed classes and locations; assertions are unchanged.

Design: new facade @ crates/workshop-server/ui/src/ui/agent/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/chrome/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/editor/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/gateway/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/layout/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/menu/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/shared/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/status/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/stt/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/take/index.ts
Design: new facade @ crates/workshop-server/ui/src/ui/workspace/index.ts
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
The workshop interface now boots as a small shell that lazy-loads each feature directory on first activation, keeping the heavy editor and agent graphs out of the initial bundle. Panel kinds, commands, and menu placements live in registries that feature directories populate when their chunks resolve, replacing the hand-wired composition root. Session state that sat at module scope, namely zone placement and the file tree's expansion and listing cache, moves into named services shared through a service registry, and every panel extends a common part base class. The server gains a wildcard route for the content-hashed chunks the split bundle emits.

- `crates/workshop-server/ui/src/services/panel-registry.ts` declares each panel kind once with its zone affinity, title, tab renderer, and import thunk; the first activation loads the chunk and runs the directory's register() exactly once, and a failed load is forgotten so the next activation retries.
- `crates/workshop-server/ui/src/services/service-registry.ts` maps tokens to lazy factories cached for the page lifetime; resolving an unregistered token throws naming it, and re-registering drops the cached instance so tests rebind without a restart.
- `crates/workshop-server/ui/src/ui/menu/command-registry.ts` and `crates/workshop-server/ui/src/ui/menu/menu-registry.ts` hold commands as descriptors keyed by id and menus as placements per menu id; window-menu.ts shrinks to the workshop's own registrations and the renderer reads both registries without knowing what is registered.
- `crates/workshop-server/ui/src/base/workshop-part.ts` builds a panel's content exactly once on the first init, so a panel re-added after a layout restore never rebuilds its DOM.
- `crates/build-ui/src/lib.rs` grows a `splitting` flag that switches esbuild between one unversioned app.js and a split entry with content-hashed chunks; the workshop UI splits, the config UI does not.
- `crates/workshop-server/src/routes/assets.rs::ui_chunk` serves only the .js and .css kinds esbuild emits under chunks/; any other extension and any traversal-shaped name answer 404, pinned by new route tests.
- `crates/workshop-server/ui/src/ui/layout/shortcuts.ts` dispatches matched chords through the command registry and always preventDefaults, even when the owning feature's chunk has not loaded yet, so Ctrl+W can never reach the browser.
- `crates/workshop-server/ui/src/ui/layout/panel-types.ts::LazyPanel` mounts an empty shell immediately and swaps the real panel in when the chunk resolves, forwarding the init parameters; disposing before resolution cancels the swap.
- `crates/workshop-server/ui/src/ui/agent/index.ts` awaits the markdown highlighter inside register() before the panel mounts, so the first painted message is never unhighlighted; a failed init degrades to plain code blocks.
- `crates/workshop-server/ui/src/main.ts` no longer passes services through the dock's createComponent seam and no longer awaits highlighting at boot; panels resolve services from the registry when their chunks activate.
- `crates/workshop-server/ui/src/ui/layout/zones.ts` and `crates/workshop-server/ui/src/ui/layout/workshop-panel.ts` keep no module-scope Maps; ZoneStateService and TreeStateService hold that state and survive panel close and reopen.

Design: new flag-parameter @ crates/build-ui/src/lib.rs::UiBuild.splitting boundary: pub
Design: new surface-growth @ crates/workshop-server/src/routes/assets.rs::ui_chunk deps: Path boundary: wire
Design: new service-locator @ crates/workshop-server/ui/src/services/service-registry.ts
Design: replaces registry @ crates/workshop-server/ui/src/services/panel-registry.ts was: crates/workshop-server/ui/src/ui/layout/panel-types.ts::PANEL_TYPES
Design: new registry @ crates/workshop-server/ui/src/ui/menu/command-registry.ts
Design: new registry @ crates/workshop-server/ui/src/ui/menu/menu-registry.ts
Design: removes global-state @ crates/workshop-server/ui/src/ui/layout/zones.ts
Design: removes global-state @ crates/workshop-server/ui/src/ui/layout/workshop-panel.ts
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
This change gives the SPA a typed error vocabulary with stable machine-readable codes, so callers match on the code rather than message text, and the write-conflict signal becomes a code instead of a dedicated class. It puts the server's webview assets behind a narrow interface with an embedded implementation and a no-op one selected by a new headless build feature, so server-only integration tests run without Node.js or the UI bundle. It also switches the UI build to content-hashed bundle filenames resolved through a generated manifest, letting hashed assets be cached immutably while the stable logical routes keep revalidating.

- `AssetServer` is the new narrow interface between the asset routes and the webview bundle: the shell wires `EmbeddedAssets` in a normal build and `NoopAssets` under the `headless` feature, whose build script skips the esbuild UI build and embeds an empty asset directory.
- `manifest.json` is the logical-to-hashed name map both builds now emit: splitting content-hashes the entry under `bundle/`, and the dist index page is stamped with the hashed URLs so it loads the immutable assets directly.
- `CachePolicy` makes the caching decision explicit at each route: content-hashed bundle and chunk URLs go out as `public, max-age=31536000, immutable`, while the stable logical routes keep `no-cache`.
- `grantPath` now returns a typed `Result` and never throws; transport failures and grant refusals arrive as distinct catalog codes the status bar flow branches on.
- `isModifiedConflict` now keys on the `ModifiedConflict` catalog code rather than a class, and `fetchTree`, `fetchFile`, `writeFile`, and `revokeRoot` throw typed `CatalogError` variants for transport, HTTP, and shape failures.
- `ModifiedConflictError` is gone: the write conflict is a catalog code, not a dedicated class.
- `headless` builds serve no UI assets at all: every asset route answers 404 while the API surface keeps answering, and CI covers that configuration in its own nextest invocation.

Design: new strategy @ crates/workshop-server/src/assets.rs::AssetServer
Design: new feature-flag @ crates/workshop-server/Cargo.toml
Design: new surface-growth @ crates/workshop-server/src/routes/assets.rs::ui_bundle
  boundary: wire
Deferred: typed error variants cover only the workspace API and grant flow, not yet the wider services and UI code
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
Two new workspace rule files record the structural decisions of the workshop server split so future edits preserve them: a strictly one-way crate dependency graph, subsystem self-registration through a central registry, a five-hundred-line file ceiling, and the client-side conventions for feature directories, lazy loading, colocated styles, and token-only styling. Both rule files are scoped by glob to the crates they govern rather than applied on every prompt. A new pointer file names the document the workspace is currently executing, and that document's final section is marked complete. One doc comment in the UI build helper trades an intra-doc link for plain code formatting.

- `.cursor/rules/workshop-architecture.mdc` - New rule file, glob-scoped to `crates/workshop*/**` with `alwaysApply: false`. Pins the one-way tier graph (shell -> features -> services -> vocabulary), forbids same-tier dependencies, restricts `lib.rs` to a facade, and mandates self-registration into `workshop-registry` proxy slots with graceful no-op unregistered slots, sealed registry traits, `#[must_use]` registration handles, and a 500-line file ceiling.
- `.cursor/rules/workshop-spa.mdc` - New rule file, glob-scoped to `crates/workshop-server/ui/**` with `alwaysApply: false`. Prescribes feature directories with barrel `index.ts` exports and a `register()` entry point, dynamic `import()` lazy loading with lazy panels barred from importing the boot shell, colocated CSS, `--ws-*` token-only values split across `tokens/base.css`, `tokens/semantic.css`, and `tokens/component.css`, `.ws-` class prefixes, and kebab-case names.
- `vibe/ACTIVE` - New one-line file holding the path of the document the workspace is executing.
- `crates/build-ui/src/lib.rs` - The `UiBuild` doc comment now renders `finalize_hashing` as a plain code span instead of an intra-doc link.
- `vibe/2026-09-12-3-workshop-server-decomposition.md` - The final section heading gains a `[completed]` marker.
- `crates/build-ui/src/lib.rs` - This is the only Rust file touched and only in a doc comment; no executable code changes in this commit.

Deferred: porting the shell gateway supervisor into a shared shared-gateway-mon crate waits for the headless agent mode plan
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
Plan: vibe/2026-09-12-3-workshop-server-decomposition.md
The executor injected a synthetic user_input tool into every model loop whenever an input broker was configured, so a model could end its turn by calling the tool instead of replying. The chat agent never asked for that: it suspends on the script-side user_input function, and when the model took the tool path the streamed reply was discarded, the call and result leaked to the session feed as tool cards, the turn never settled to idle, and the accumulated transcript eventually failed the next model turn. The broker now backs the script-side function only; no user_input tool reaches the model unless a prompt adds one.

- `scheduler.rs` no longer pushes a broker-backed binding onto the loop's advertised tool set; the set is exactly what the prompt built.
- `InputTool` is removed along with its schema, description, and tests; the direct `user_input()` path shares none of it.
- `a_brokered_loop_with_no_prompt_tools_advertises_no_tools_to_the_model` pins the contract: a brokered loop with an empty tool set sends no tools on the wire.
- `serve_chat_over` and the chat-gate test that drove a fake model through the injected tool are removed; the built-in chat prompt adds no tools, so the path they exercised no longer exists.

Repairs: a model turn ends only by replying @ crates/promptforge-core/src/execute/scheduler.rs - the model could call user_input and strand the session on a running turn
Plan: none
The lazy panel wrapper introduced with chunk splitting sat between dockview's content container and the real panel with no stylesheet rule at all, so it took its height from its content. The agent panel's full-height rule then resolved against an auto-height parent, the panel grew with every turn instead of scrolling, and the prompt input was pushed below the window with no scrollbar to reach it. The wrapper is now a full-height flex column, and it forwards dockview's dimensions to the inner panel, replaying the last resize when the chunk resolves after it.

- `.ws-panel-lazy` gains the same full-height flex-column sizing the panels it hosts rely on; `.ws-panel-error` shares the existing unknown-panel rule.
- `LazyPanel.layout` forwards width and height to the swapped-in renderer and stores the last dimension so a resize that lands before the chunk loads is not lost.
- `test/lazy-panel-sizing.mjs` mounts a real dock with the stylesheets installed and asserts the shell, panel root, and feed sizing chain plus autoscroll and layout forwarding; it fails on the prior tree.

Repairs: the agent feed is the panel's scroll region @ crates/workshop-server/ui/src/ui/layout/zones.css - an unstyled lazy wrapper let the panel grow with its transcript and push the prompt input off-screen
Plan: none
Seeds the plan for removing the seven accepted debts left by the workshop decomposition: the registry redesign to contribution collections, facade and enforcement repairs, build drift tests, the docs repair, and record corrections, and marks it as the active plan. Also qualifies a broken intra-documentation link in the input broker docs; the link lost its resolution target when an earlier change removed the import behind it, which breaks the CI docs job.

- `vibe/2026-09-12-4-workshop-debt-removal.md`: new five-step plan in dependency order, one commit per step, with the registry redesign as the load-bearing item.
- `vibe/ACTIVE`: new pointer naming the active plan.
- `crates/promptforge-core/src/input.rs`: the module doc link now carries an explicit crate path so the docs gate resolves it.

Deferred: the workspace rustdoc deny lints for the root Cargo.toml
Plan: vibe/2026-09-12-4-workshop-debt-removal.md
Moves each subsystem's registry registration out of its crate root into a dedicated handles module so the root carries only documentation, module declarations, and re-exports. Splits the oversized agent integration test file by concern, opts the server shell into the architecture checks with an invariants marker, and rewords the workspace rules to state the enforced scope and the boot-composed state model.

- `crates/workshop-menu/src/handles.rs`, `crates/workshop-status/src/handles.rs`, `crates/workshop-workspace/src/handles.rs` receive the registration functions and handle bundles verbatim from their crate roots; each root re-exports them, so public paths are unchanged.
- `crates/workshop-server/tests/it/agents.rs` sheds its seven hundred lines of tests into four concern modules under `tests/it/agents/`; the shared helpers stay in the parent file.
- `crates/workshop-server/src/lib.rs` gains a `## Invariants` doc marker with the shell's dependency allow-list, opting the crate into the xtask checks.
- `crates/xtask/src/tidy.rs::tier_dependency_violations` now reports a tiered crate whose manifest is missing instead of skipping it, covered by a regression test that fails under the old skip behavior.
- `crates/workshop-server/AGENTS.md` replaces the construction-phased state rule with the boot-composition rule the tree already follows.
- `register` keeps its behavior in every moved subsystem: the same sinks, channels, routes, and state providers register through the same adapters.

Design: extends facade @ crates/workshop-menu/src/lib.rs
Design: extends facade @ crates/workshop-status/src/lib.rs
Design: extends facade @ crates/workshop-workspace/src/lib.rs
Design: removes oversized-unit @ crates/workshop-server/tests/it/agents.rs
Repairs: tiered crates depend only on lower tiers @ crates/xtask/src/tidy.rs::tier_dependency_violations - a tiered crate with no manifest was silently skipped, so the tier check passed without examining it
Plan: vibe/2026-09-12-4-workshop-debt-removal.md
Replaces the registry's fifteen per-subsystem proxy slots with four contribution collections: routes and background tasks as ordered vectors of trait objects, state handles and push sinks as maps keyed by type. Subsystems register their handle sets under their own types and consumers read them back the same way, so the registry never names a subsystem and the single downcast lives inside it. Background tasks now register beside the state handles, and the shell spawns and stops them from the task vector instead of holding each by name. Boot gains a typed failure mode: a missing required contribution fails startup with an error naming the absent type instead of panicking at first use.

- `TypeMap` is the type-keyed contribution cell behind the state and sink collections; it holds the registry's single downcast, so every caller sees a typed Option and a stale guard never evicts a newer occupant.
- `ShutdownHandle` pairs signaling and awaiting in one call, a concrete type rather than an async trait method, so task registration stays dyn-compatible and the graceful-shutdown closure cannot fire a stop it forgets to await.
- `SessionsState` now holds only the registry and the origin policy; the agent sessions, the gateway binding and health flag, and the catalog and menu buses are read back by type at the point of use, each an Option whose absence degrades its feature.
- `MissingContribution` is the new boot failure: the composition root requires the five subsystems' handle sets plus the workspace roots view before sharing state, and a boot test proves an omitted registration fails startup naming the absent type.
- `crates/workshop-server/src/serve.rs` spawns every registered task from the registry's task vector when serving starts and awaits each shutdown handle inside the graceful-shutdown signal, holding no task by name.
- `Omit` and `state_with_gateway_omitting` form the boot-composition test seam, removing one subsystem's register call so the startup refusal is observable.
- `crates/workshop-registry/src/slot.rs` is deleted with its proxy slot and generic guard; the registration guard is now a non-generic drop closure.
- `StateProvider`, `ShutdownHook`, and their adapters are deleted, and the shell's `registered` downcasting helper goes with them.

Design: new registry @ crates/workshop-registry/src/registry.rs::TypeMap
Design: constructor-injection -> service-locator @ crates/workshop-sessions/src/state.rs::SessionsState
Design: new dispatch-on-tag @ crates/workshop-server/src/app.rs::compose deps: Config,Option<Omit>,ResolvedGateway
Design: extends oversized-unit @ crates/workshop-server/src/app.rs::compose deps: Config,Option<Omit>,ResolvedGateway
Design: new surface-growth @ crates/workshop-server/src/app.rs::StateError::Composition boundary: pub
Plan: vibe/2026-09-12-4-workshop-debt-removal.md
The crate build scripts and the Node fast-iteration script are two implementers of the same bundling pipeline, and nothing kept them from drifting apart. A differential test now runs both implementers into scratch directories and compares the hash-normalized file sets, the manifest, and the stamped index page. The lazy-CSS premise is also settled empirically: esbuild hoists stylesheets reachable through dynamic imports into the entry bundle, so the ten eager feature-stylesheet imports were redundant. They are deleted, and a build-output test stands guard so a future bundler behavior change fails loudly instead of shipping unstyled panels.

- `crates/build-ui/src/lib.rs::build_in` takes the UI and output paths explicitly so tests can run the Rust implementer against a scratch directory without mutating process environment; `build` keeps its signature and delegates.
- `crates/workshop-server/ui/build.mjs` gains an `--out <dir>` flag (default `dist/`) that throws when the argument is missing, so tests never rebuild the working tree's output.
- `crates/build-ui/tests/it/main.rs::both_implementers_emit_the_same_layout` builds with both implementers and asserts identical hash-normalized file sets, manifest keys, and stamped index page; it skips with an explicit message when Node.js or the UI's installed dependencies are absent.
- `crates/workshop-server/ui/test/lazy-css-entry-bundle.mjs` builds for real into a scratch directory and fails unless the entry stylesheet carries one marker class per lazy feature directory.
- `crates/workshop-server/ui/src/main.ts` no longer re-imports the ten lazy feature stylesheets; the entry bundle carries them on its own.

Design: new surface-growth @ crates/build-ui/src/lib.rs::build_in deps: Path,UiBuild boundary: pub
Design: new pure-function @ crates/build-ui/tests/it/main.rs::normalize_hashes deps: str
Plan: vibe/2026-09-12-4-workshop-debt-removal.md
Annotates the earlier server-decomposition plan with dated corrections recording which success and exit criteria were unmet at close-out and superseding the per-subsystem proxy-slot registry design with contribution collections keyed by kind and type. Adds two standing rules to crate agent guides: the gateway supervisor stays in the shell crate until the headless agent mode plan shapes a shared API, and the script-side input broker never advertises a user-input tool to a model unless a prompt explicitly adds it.

- `vibe/2026-09-12-3-workshop-server-decomposition.md` - Decision-record entry supersedes the proxy-slot registry direction with contribution collections, citing the operator roadmap of 15-20 subsystems as rationale. Dated notes mark the unmet crate ceiling, file ceiling, hand-wiring, and CSS-lint criteria and name the four crates still over the line ceiling.
- `crates/workshop/AGENTS.md` - The gateway supervisor stays in this crate; porting it to a shared crate defers to the headless agent mode plan.
- `crates/promptforge-core/AGENTS.md` - The input broker backs only the script-side user-input function; no user-input tool is advertised to a model unless a prompt explicitly adds it.

Plan: vibe/2026-09-12-4-workshop-debt-removal.md
Plan: vibe/2026-09-12-4-workshop-debt-removal.md
@vinniefalco
vinniefalco merged commit 82cd96c into cppalliance:master Sep 13, 2026
17 checks passed
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